diff --git a/.planning/2026-08-16-goal-bubble-rendering/findings.md b/.planning/2026-08-16-goal-bubble-rendering/findings.md new file mode 100644 index 0000000..2a7f12e --- /dev/null +++ b/.planning/2026-08-16-goal-bubble-rendering/findings.md @@ -0,0 +1,73 @@ +# Findings & Decisions: Goal 气泡显示修复 + +## Requirements + +- `/goal <目标>` 原文应显示为正常用户气泡,而不是居中大系统面板。 +- “正在同步 Goal”只承担进度反馈,不应长期占据消息区。 +- 成功反馈不重复回显完整目标正文。 +- 修复后刷新会话仍能看到 Goal 用户气泡。 +- 不改变 `/goal` 的 app-server RPC、后台 Goal 执行及其他 Slash 命令语义。 + +## Research Findings + +- `home-cc-web` 代码索引状态为 `ready`,当前包含 6208 个节点、14249 条边。 +- `sendMessage()` 明确跳过所有已知 Slash 命令的用户气泡。 +- `handleCodexAppGoalSlashCommand()` 将同步与结果统一发送为 `system_message`。 +- `createMsgElement('system')` 使用 `textContent`,不会渲染 Markdown。 +- 通用系统消息 CSS 使用居中、最大宽度 90%、`white-space: pre-line`,长 Goal 会成为大面板。 +- 暗金荒野主题把 `.msg.system` 与助手气泡共享磨砂规则,进一步强化面板感。 +- 现有回归明确断言 `Goal active` 是 `system_message`,需要随新契约更新。 +- 用户气泡入口是 `submitUserMessage()`;服务端普通消息入口是 `handleMessage()`,需在这两个边界间设计只用于展示的 Goal 持久化,避免再次调用普通模型链路。 +- `submitUserMessage()` 会乐观插入用户气泡、增加当前消息计数并启动生成状态,但发送时没有携带前端生成的 `messageId`。 +- `handleMessage()` 的普通路径会先把用户消息推入 `session.messages` 并 `saveSession()`,随后才进入 Codex App/Claude/Codex 运行时;因此不能直接复用整个普通路径来保存 Goal,否则会把 `/goal` 再发送给模型。 +- 会话持久化清洗会保留消息对象的额外元数据字段,可为 Goal 展示消息增加明确标记而不改底层序列化框架。 +- WebSocket 在进入 `handleMessage()` 前先调用 `handleSlashCommand()`;已知 `/goal` 因此完全绕过普通消息持久化。 +- 前端已经支持服务端 `session_message` 事件,可把服务端持久化的 Goal 展示消息追加到当前会话与缓存。 +- 代码中已有 `ccwebDisplayOnly: true` 的持久消息先例,适合标识不应被当作真实模型输入的 UI 展示记录。 +- 目标业务文件 `public/app.js`、`server.js`、`public/style.css`、`scripts/regression.js` 在本任务开始时没有未提交差异。 +- `session_message` 会把服务端消息同时追加到会话缓存和当前 DOM,并正常递增消息索引;因此 Goal 路径无需前端乐观插入,可由服务端持久化后立即推送,避免双气泡。 +- 前端将为设置型 `/goal` 额外发送稳定 `clientMessageId`,同时保留 Slash 草稿用 `requestId`;服务端以前者优先作为展示消息 ID。 +- Goal 展示消息正文只保存 `command.objective`,不重复展示 `/goal` 前缀、`Goal active` 标头或状态统计。 +- Slash 草稿在收到首个 `system_message` 时就会被移除;当前有效 `/goal` 的“正在同步”已清掉草稿,因此后续 RPC 错误实际上无法再恢复原输入。新契约将把校验失败保留在持久化前,RPC 启动后的失败明确保留气泡且不恢复草稿。 +- 现有集成回归只断言 `/goal` 不作为普通 `/goal...` 文本持久化;可替换为“目标正文作为 `ccwebDisplayOnly` Goal 用户消息持久化,同时后台 Goal 输出仍正常”。 +- 回归入口是 `npm run regression`,没有现成 Goal 专项参数;执行时需用 60 秒超时保护。 +- Mock app-server 的 `thread/goal/set` 会生成独立 `Goal background output` turn,可用“重复命令 ID 后没有第二个 Goal 背景输出”证明去重没有再次调用 Goal RPC。 +- 实现后服务端在 Goal RPC 前持久化并推送 `session_message`,前端沿用普通 `buildMsgElement()` 用户角色渲染;成功/同步系统提示均已改为 transient,成功文案不再包含 objective。 +- 同一 `clientMessageId` 再次提交会返回短暂“Goal 设置请求已处理”,不会追加消息或触发第二个背景 turn。 +- `node --check public/app.js server.js scripts/regression.js` 与 `timeout 60s npm run regression` 均通过。 +- PM2 `ccweb` 当前监听 `8002`,静态 `app.js` 已能返回新的 `isGoalSetCommandText` 与 `clientMessageId`;按仓库约束未重启,因为另有 `/home/cc-web` 运行会话。 +- 当前环境没有 `playwright`、`puppeteer` 或 Chromium 可执行文件,无法完成真实浏览器截图/多视口像素验收;需在有浏览器的环境补做。 +- 实施期间另一个 `/home/cc-web` 会话在 `public/app.js` 与 `scripts/regression.js` 写入了侧栏折叠改动;这些非 Goal 差异未回滚,当前回归在其共存状态下通过。 + +## Technical Decisions + +| Decision | Rationale | +|----------|-----------| +| 设置型 Goal 显示为用户气泡 | 这是用户提交的长文本,符合对话视觉语义 | +| Goal 进度和成功仍走系统反馈,但设为 transient | 保留操作反馈,不制造持久大块内容 | +| 持久化必须由服务端显式记录展示消息 | 仅前端乐观插入会在刷新后消失 | +| 控制型 `/goal` 命令不显示长用户气泡 | pause/resume/clear/show 是控制操作,保持精简 | +| 使用独立 Goal 展示消息 helper | 直接写入本地历史并推送 `session_message`,绝不复用会继续进入运行时的 `handleMessage()` | +| 每条 Goal 展示消息带稳定 ID 与专用元数据 | 支持传输去重、刷新识别和负向回归断言 | +| 有效设置命令在 RPC 前持久化;RPC 失败保留气泡 | 提供即时可见记录;失败后不恢复已提交正文,避免重试产生重复气泡 | + +## Issues Encountered + +| Issue | Resolution | +|-------|------------| +| 根目录已有其他任务规划与 TODO 文件 | 使用独立 `.planning/2026-08-16-goal-bubble-rendering/`,不改 `.planning/.active_plan` | +| 首轮计划未明确独立持久化、去重和失败边界 | 按审查意见补充 display-only Goal 消息、稳定命令 ID、禁止普通运行时与分阶段失败语义 | + +## Resources + +- `public/app.js`:Slash 发送、系统消息与用户气泡渲染 +- `server.js`:Goal RPC、会话消息持久化 +- `public/style.css`:系统消息与暗金荒野主题样式 +- `scripts/regression.js`:Codex App Goal 集成回归 + +## Visual/Browser Findings + +- 用户截图中顶部“正在同步 Goal...”为尺寸正常的短系统提示。 +- 完成后 `Goal active + 完整目标` 被渲染为居中、接近消息区宽度的大块磨砂面板。 +- 长正文包含多行约束与反引号,居中排版和纯文本渲染明显降低可读性。 +- 目标视觉应为右侧普通用户气泡;状态反馈应保持短小,不重复正文。 diff --git a/.planning/2026-08-16-goal-bubble-rendering/progress.md b/.planning/2026-08-16-goal-bubble-rendering/progress.md new file mode 100644 index 0000000..c2bbbac --- /dev/null +++ b/.planning/2026-08-16-goal-bubble-rendering/progress.md @@ -0,0 +1,112 @@ +# Progress Log: Goal 气泡显示修复 + +## Session: 2026-08-16 + +### 阶段 1:建立现状基线并固化 Goal 交互契约 + +- **Status:** complete +- **Started:** 2026-08-16 +- Actions taken: + - 读取主题、文件规划和 TODO CSV 技能说明。 + - 执行规划会话恢复检查,未发现需恢复的输出。 + - 记录 Goal 从 Slash 命令到 `system_message` 再到大面板样式的完整原因。 + - 固化用户气泡、短暂同步提示、精简成功提示和刷新持久化要求。 + - 确认 Slash 路由位于普通消息持久化之前,并找到 `session_message` 与 `ccwebDisplayOnly` 复用契约。 +- Files created/modified: + - `.planning/2026-08-16-goal-bubble-rendering/task_plan.md` + - `.planning/2026-08-16-goal-bubble-rendering/findings.md` + - `.planning/2026-08-16-goal-bubble-rendering/progress.md` + - `Goal 气泡显示 TO DO list.csv` + +### 阶段 2:审查实施计划并修正关键缺口 + +- **Status:** complete +- **Started:** 2026-08-16 +- Actions taken: + - 准备独立、无会话历史的计划审查上下文。 + - 首轮审查发现独立持久化、去重、失败顺序与负向回归断言不足。 + - 已将 display-only Goal 消息、稳定命令 ID、禁止普通模型链路及失败语义写入实施计划。 + - 第 2 轮独立审查已通过,无阻断实施的问题。 +- Files created/modified: + - `.planning/2026-08-16-goal-bubble-rendering/task_plan.md` + - `.planning/2026-08-16-goal-bubble-rendering/findings.md` + - `.planning/2026-08-16-goal-bubble-rendering/progress.md` + +### 阶段 3:增加 /goal 用户气泡发送与持久化契约 + +- **Status:** complete +- **Started:** 2026-08-16 +- Actions taken: + - 新增前端 `isGoalSetCommandText()`,仅为设置型 `/goal` 发送 `clientMessageId`。 + - 新增服务端 display-only Goal 展示消息持久化与稳定 ID 去重 helper。 + - 通过 `session_message` 推送目标正文,避免复用普通 `handleMessage()` 运行时链路。 +- Files created/modified: + - `public/app.js` + - `server.js` + +### 阶段 4:将 Goal 同步和成功反馈改为短暂精简提示 + +- **Status:** complete +- Actions taken: + - 同步、设置、暂停、恢复、清除反馈改为 transient 系统提示。 + - 设置/暂停/恢复成功反馈只显示状态和 token 统计,不再回显完整目标正文。 + - RPC 启动后失败保留已提交气泡,不恢复草稿;前置校验仍保留恢复语义。 +- Files created/modified: + - `server.js` + +### 阶段 5:补充 Goal 气泡及消息协议回归测试 + +- **Status:** complete +- Actions taken: + - 增加前端设置型 Goal 识别与 payload 契约断言。 + - 增加 session_message、display-only 元数据、成功文案、短暂属性和命令 ID 去重断言。 + - 更新旧的“完全不持久化 /goal 用户消息”断言为新的展示消息契约。 +- Files created/modified: + - `scripts/regression.js` + +### 阶段 6:运行语法、专项和全量回归 + +- **Status:** complete +- Actions taken: + - 已通过 `node --check public/app.js`、`node --check server.js`、`node --check scripts/regression.js`。 + - 已通过 `timeout 60s npm run regression`,输出 `Regression checks passed.`。 +- Files created/modified: + - 无 + +### 阶段 7:执行真实浏览器桌面与窄屏验收 + +- **Status:** blocked by environment (not a code blocker) +- Actions taken: + - 确认 PM2 `ccweb` 服务在线并监听 `8002`。 + - 确认运行服务返回新前端脚本,普通用户气泡 CSS 仍保留右对齐布局。 + - 按仓库规则检查运行会话,发现另一个 `/home/cc-web` 会话正在运行,因此未执行重启。 + - 检查本机浏览器工具,未找到 Playwright、Puppeteer 或 Chromium 可执行文件。 +- Files created/modified: + - 无 +- Limitation: + - 未能完成真实浏览器桌面/窄屏截图与像素验收;功能与隔离回归已完成,后端新逻辑需在允许重启且有浏览器的环境做最终视觉确认。 +- Concurrent work: + - 另一个运行会话同时修改了 `public/app.js` 与 `scripts/regression.js` 的侧栏折叠逻辑;未回滚,已通过当前工作树下的回归。 + +## Test Results + +| Test | Input | Expected | Actual | Status | +|------|-------|----------|--------|--------| +| JavaScript 语法 | `node --check` 三个目标文件 | 无语法错误 | 全部退出 0 | passed | +| 全量隔离回归 | `timeout 60s npm run regression` | 回归通过 | `Regression checks passed.` | passed | + +## Error Log + +| Timestamp | Error | Attempt | Resolution | +|-----------|-------|---------|------------| +| 2026-08-16 | 仓库内 `todo-list-csv` 技能短路径不存在 | 1 | 使用技能清单中的实际安装路径 | + +## 5-Question Reboot Check + +| Question | Answer | +|----------|--------| +| Where am I? | 阶段 3:增加 /goal 用户气泡发送与持久化契约 | +| Where am I going? | 计划审查、实现、回归、浏览器验收与差异清理 | +| What's the goal? | 把设置型 `/goal` 改为持久用户气泡,并收敛系统反馈 | +| What have I learned? | 见 `findings.md` | +| What have I done? | 已完成原因定位与任务规划 | diff --git a/.planning/2026-08-16-goal-bubble-rendering/task_plan.md b/.planning/2026-08-16-goal-bubble-rendering/task_plan.md new file mode 100644 index 0000000..0eb2ad5 --- /dev/null +++ b/.planning/2026-08-16-goal-bubble-rendering/task_plan.md @@ -0,0 +1,97 @@ +# Task Plan: Goal 气泡显示修复 + +## Goal + +让 `/goal <目标>` 在会话中显示并持久化为正常用户气泡,同时将同步与成功反馈收敛为短暂、简洁的系统提示,避免长目标正文渲染成居中的大面板。 + +## Current Phase + +阶段 7:执行真实浏览器桌面与窄屏验收 + +## Phases + +### 阶段 1:建立现状基线并固化 Goal 交互契约 + +- [x] 核对工作树、索引和现有 Goal 消息链路 +- [x] 固化用户气泡、同步提示、成功提示及刷新恢复的验收语义 +- **Status:** complete + +### 阶段 2:审查实施计划并修正关键缺口 + +- [x] 由独立计划审查员核对完整性、范围与可实施性 +- [x] 修正任何阻断实施的计划缺口 +- **Status:** complete + +### 阶段 3:增加 /goal 用户气泡发送与持久化契约 + +- [x] 新增 Goal 展示消息持久化 helper:写入带稳定消息 ID、`ccwebDisplayOnly` 与 Goal 专用元数据的 `role: user` 消息 +- [x] 通过 `session_message` 推送已持久化消息,使设置型 `/goal` 的目标正文生成普通用户气泡 +- [x] Goal 展示消息只进入本地会话历史,禁止复用 `handleMessage()`,禁止触发 `handleCodexAppMessage`、普通 `turn/start` 或 `turn/steer` +- [x] 使用 `clientMessageId/requestId` 作为去重边界;同一命令 ID 只持久化一次且只调用一次 Goal RPC +- [x] 刷新只从 `session.messages` 恢复展示气泡,不把历史 Goal 展示消息重新解释为命令 +- [x] 保持 `/goal` 查看、暂停、恢复、清除等控制命令语义不变 +- **Status:** complete + +### 阶段 4:将 Goal 同步和成功反馈改为短暂精简提示 + +- [x] 将同步提示设为短暂提示 +- [x] 成功提示不再回显完整目标正文 +- [x] 明确失败顺序:参数/会话校验失败发生在持久化前并恢复草稿;RPC 启动后的失败保留已提交气泡、不恢复草稿,避免重试产生重复气泡 +- **Status:** complete + +### 阶段 5:补充 Goal 气泡及消息协议回归测试 + +- [x] 覆盖用户气泡呈现与持久化 +- [x] 覆盖系统提示类型、短暂属性和精简文案 +- [x] 增加负向断言:Goal 展示消息带 display-only/Goal 元数据,不进入普通模型消息链路,不额外触发 turn/start/steer +- [x] 覆盖同一命令 ID 去重、刷新历史恢复且不重放 Goal RPC、RPC 失败后不重复恢复草稿 +- [x] 更新被新交互契约替代的旧断言 +- **Status:** complete + +### 阶段 6:运行语法、专项和全量回归 + +- [x] 执行 JavaScript 语法检查 +- [x] 执行 Goal/前端专项回归 +- [x] 执行相关全量回归并检查差异格式 +- **Status:** complete + +### 阶段 7:执行真实浏览器桌面与窄屏验收 + +- [ ] 在桌面视口验证长 Goal 为用户气泡 +- [ ] 在窄屏验证换行、溢出和系统提示尺寸 +- [ ] 确认暗金荒野主题未再生成大块 Goal 系统面板 +- **Status:** in_progress + +### 阶段 8:审查差异并清理临时任务记录 + +- [ ] 审查目标文件差异且不混入其他任务改动 +- [ ] 完成计划记录并删除本任务 TODO CSV +- **Status:** pending + +## Key Questions + +1. 设置型 `/goal` 气泡如何持久化而不进入普通模型对话链路? +2. 如何只改变 Goal 命令,不改变其他 Slash 命令的既有行为? +3. 长 Goal 在桌面与窄屏下是否仍保持普通用户气泡布局? + +## Decisions Made + +| Decision | Rationale | +|----------|-----------| +| 只修改 Goal 消息语义,不重做通用气泡或主题 | 控制范围,避免影响其他系统提示和主题 | +| 同步提示与成功提示采用短暂、精简反馈 | Goal 原文已在用户气泡中,不应重复铺满消息区 | +| 计划文件放在独立 `.planning` 目录 | 根目录已有其他任务的规划文件,必须避免覆盖 | +| Goal 气泡使用独立展示消息路径 | 写入 `session.messages` 并发出 `session_message`,但不调用普通消息运行时,兼顾刷新恢复与防止重复模型执行 | +| `clientMessageId/requestId` 是命令与消息去重键 | 传输重放时可识别同一次设置,避免重复气泡与重复 Goal RPC | +| 校验前失败恢复草稿;RPC 已启动后失败保留气泡且不恢复草稿 | 保持即时提交记录,同时避免用户重试产生两条相同气泡 | + +## Errors Encountered + +| Error | Attempt | Resolution | +|-------|---------|------------| +| 仓库内 `todo-list-csv` 技能短路径不存在 | 1 | 改用技能清单给出的 `/home/hdzx/.codex/skills/todo-list-csv/` | + +## Notes + +- 任务开始时已有 `public/task-board.css`、`public/task-board.js`、`scripts/task-board-frontend-unit.js` 未提交改动,本任务不触碰这些文件。 +- 根目录已有其他任务的 TODO CSV,本任务只维护 `Goal 气泡显示 TO DO list.csv`。 diff --git a/.planning/sidebar-collapse-button/findings.md b/.planning/sidebar-collapse-button/findings.md new file mode 100644 index 0000000..85a5ba4 --- /dev/null +++ b/.planning/sidebar-collapse-button/findings.md @@ -0,0 +1,25 @@ +# 调查记录 + +- 用户截图视口约为 1997×1247,左侧栏约 443px,侧栏处于完整展开状态。 +- 截图中项目节点自己的展开箭头可见;缺失的是控制整个左侧栏收起的入口。 +- 任务开始时工作树已有 `public/task-board.css`、`public/task-board.js`、`scripts/task-board-frontend-unit.js` 改动及另一任务的计划文件,均不属于本任务。 +- 修复应只覆盖侧栏控件,不扩展到主题整体视觉。 +- 独立计划审查已通过;审查器补充确认代码中已有 `#menu-btn` 和 `sidebar-collapse` 回归目标,应优先排查现有入口的 DOM、状态和最终 CSS。 +- `codebase-memory-mcp` 项目 `home-cc-web` 索引状态为 ready(6226 节点、14256 边),无需重建索引。 +- 仓库存在 `.trellis/workflow.md` 和前端规范;未发现 `.agents/skills`、`.codex/agents` 目录。 +- Trellis 工作流要求:文件改动任务应建立独立 task/PRD/context,实施与检查由独立代理完成;当前前端规范索引仍是占位型说明,后续需读取具体质量文件。 +- 本环境未暴露 `trellis-brainstorm`、`trellis-implement`、`trellis-check` 技能或专用 agent 类型,将在不改写并行会话状态的前提下用 Trellis 脚本和通用 worker/explorer 代理降级执行。 +- Trellis 开发者身份为 `shiyue`;当前全局 task 指向 `.trellis/tasks/08-11-task-board`,与已存在的 task-board 脏改动一致。 +- 为避免打断并行任务,本任务只创建独立 Trellis task,不执行 `task.py start`,也不改写 `.trellis/.current-task`;所有代理改用显式任务路径加载上下文。 +- 现有 DOM、JS 和专项回归均保留 `#menu-btn`;用户截图来自带 Home Indicator 的横屏触控设备,最可能的根因是宽度大于 768px 但无 hover/精细指针时同时错过 drawer 与 desktop-collapse 两种媒体条件。 +- 修复方向应把“宽屏固定折叠”与“鼠标 hover 临时预览”拆开:前者按宽度可用,后者仍按设备能力渐进增强。 +- 本地源码已确认根因:`#menu-btn` 基础样式为 `display: none`,仅在 `max-width: 768px` 或 `(width > 768px) and (hover: hover) and (pointer: fine)` 中显示;JS 的桌面点击分支也只接受后一个条件。 +- 因而宽触控横屏设备确实同时错过两个条件,截图中的按钮消失与点击空档可由现有代码直接解释。 +- 现有专项回归已经集中在 `assertFrontendSidebarCollapseContract()`,适合先扩展媒体条件断言,再改 `app.js` / `style.css`。 +- `package.json` 仅提供 `npm run regression`;侧栏专项目标可直接用 `node scripts/regression.js --target sidebar-collapse` 执行,无独立前端构建步骤。 +- 实施 diff 只在侧栏相关区块新增宽屏媒体条件拆分、hover 预览监听和专项断言;同文件另有并行会话加入的 `/goal` 变更,必须保留并在最终审查中区分。 +- 当前 cc-web PM2 服务监听 `8002`,可直接返回本地 `index.html`、`style.css` 和 `app.js`;资源内容已包含本轮宽屏媒体条件拆分,无需重启服务即可确认静态文件生效。 +- `3000` 端口是另一套 PinchChat nginx 页面,不能用于本项目视觉验收;已停止使用该端口。 +- 当前环境没有 Playwright npm 模块,也没有 `chromium`/`google-chrome` 可执行文件,因此无法执行真实浏览器截图、触控点击和 computed style 验收;不能把静态资源检查当成视觉通过。 +- 全量 `npm run regression` 也通过(`Regression checks passed.`);宽触控专项与现有其它前端契约未出现冲突。 +- 8002 服务的入口、cache-busted CSS 和 JS 请求均返回 HTTP 200;服务无需重启即可提供修改后的静态内容。 diff --git a/.planning/sidebar-collapse-button/progress.md b/.planning/sidebar-collapse-button/progress.md new file mode 100644 index 0000000..31e9dda --- /dev/null +++ b/.planning/sidebar-collapse-button/progress.md @@ -0,0 +1,21 @@ +# 进度日志 + +## 2026-08-16 +- 已读取适用技能、主题架构和视觉回归经验。 +- 已查看用户截图并记录缺失入口的层级。 +- 已执行工作树基线检查,确认需避让任务看板相关脏文件。 +- 独立计划审查通过;第 1 阶段完成,开始定位现有折叠链路。 +- 已确认 codebase-memory 索引可用,并完成 Trellis 文件入口扫描。 +- 已读取 Trellis 工作流、workspace 入口和前端规范索引,准备确认开发者身份与当前 task,避免覆盖其他会话。 +- 已运行 Trellis 会话上下文检查;确认当前 task 属于并行任务看板工作,本任务将使用独立但不激活的 task。 +- 已创建 `.trellis/tasks/08-16-sidebar-collapse-button`,补充 PRD 与根因研究记录。 +- 已完成 Trellis implement/check context 配置;一次 `search_graph` 传输错误已记录,改用备用检索路径。 +- MCP 备用搜索再次传输关闭,已切换到本地源码检索并保留失败记录。 +- 本地 `rg` 已确认 `#menu-btn` 的显示规则与 JS 媒体条件存在宽触控空档,根因假设成立;进入回归断言设计。 +- 第 2 阶段完成,进入专项回归断言阶段。 +- 已确认专项回归命令与 npm 脚本入口,实施代理已开始修改目标链路。 +- 实施代理已完成 2.1;目标侧栏 diff 已审阅,确认未触碰 task-board 文件,但 `app.js` / `regression.js` 同时存在并行 `/goal` 改动。 +- 已通过 `node --check`、侧栏专项回归和 `git diff --check`;8002 服务返回的 HTML/CSS/JS 已包含最新侧栏改动。 +- 进入真实浏览器验收,但环境缺少 Playwright/Chromium,记录为明确的验证缺口。 +- 全量回归通过;代码层与静态资源层验证完成,视觉层仍受浏览器运行时缺失限制。 +- 按主题验收清单补做 8002 入口/CSS/JS HTTP 200 检查;真实浏览器截图与多视口 computed style 仍未完成。 diff --git a/.planning/sidebar-collapse-button/task_plan.md b/.planning/sidebar-collapse-button/task_plan.md new file mode 100644 index 0000000..2414df9 --- /dev/null +++ b/.planning/sidebar-collapse-button/task_plan.md @@ -0,0 +1,30 @@ +# 左侧树折叠按钮修复计划 + +## 目标 +恢复左侧会话/项目树的整栏折叠入口,确保当前主题和响应式视口下可见、可操作,并保持现有项目节点展开逻辑不变。 + +## 范围 +- 只处理整个左侧栏的折叠/展开控件、状态和必要样式。 +- 不改项目节点自身的展开箭头,不重做主题,不触碰任务看板改动。 + +## 阶段 +1. [DONE] 记录现状并复现左侧栏折叠入口缺失 +2. [DONE] 定位侧栏折叠状态、控件与主题样式链路 +3. [DONE] 补充折叠入口可见性与交互回归断言 +4. [DONE] 恢复折叠按钮并限定响应式与主题样式 +5. [DONE] 运行语法检查与相关自动化回归 +6. [IN_PROGRESS] 在真实浏览器验证展开折叠及多视口表现 +7. [TODO] 审查最终差异并清理临时跟踪文件 + +## 完成标准 +- 展开态存在清晰可点击的收起按钮,折叠态存在恢复按钮。 +- 鼠标、触控与键盘均可操作,具备正确无障碍名称和状态。 +- 目标截图视口、常规桌面和窄屏不遮挡关键内容。 +- 自动化回归、语法检查、差异检查通过。 + +## 错误记录 +- `rg --files .trellis .agents/skills .codex/agents` 因后两个目录不存在返回状态 2,但成功列出 `.trellis`;后续仅查询实际存在路径。 +- `codebase-memory-mcp/search_graph` 首次调用传输关闭;已记录,后续改用已成功的 `search_code` 与本地 `rg` 交叉校验,不重复同一失败调用。 +- 随后的 `codebase-memory-mcp/search_code` 也因传输关闭失败;本阶段降级为本地 `rg`/`sed`,不再重试已关闭的 MCP 通道。 +- 最初访问 `127.0.0.1:3000` 命中另一套 PinchChat nginx 页面;已定位 cc-web 实际端口为 `8002`,后续资源检查改用该端口。 +- 计划状态更新补丁曾因 hunk 格式错误失败;未修改业务文件,随后基于文件尾部重新提交。 diff --git a/.trellis/tasks/08-16-sidebar-collapse-button/check.jsonl b/.trellis/tasks/08-16-sidebar-collapse-button/check.jsonl new file mode 100644 index 0000000..b11aec5 --- /dev/null +++ b/.trellis/tasks/08-16-sidebar-collapse-button/check.jsonl @@ -0,0 +1,4 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} +{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "复核前端质量、回归和资源版本要求。"} +{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "复核按钮可访问性与样式边界。"} +{"file": ".trellis/tasks/08-16-sidebar-collapse-button/research/root-cause.md", "reason": "复核媒体条件与宽触控视口的根因假设。"} diff --git a/.trellis/tasks/08-16-sidebar-collapse-button/implement.jsonl b/.trellis/tasks/08-16-sidebar-collapse-button/implement.jsonl new file mode 100644 index 0000000..bb7611f --- /dev/null +++ b/.trellis/tasks/08-16-sidebar-collapse-button/implement.jsonl @@ -0,0 +1,4 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} +{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "前端质量与回归要求,尤其是入口资源和测试约束。"} +{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "按钮组件结构与可访问性边界。"} +{"file": ".trellis/tasks/08-16-sidebar-collapse-button/research/root-cause.md", "reason": "侧栏媒体条件空档的调查证据与实施方向。"} diff --git a/.trellis/tasks/08-16-sidebar-collapse-button/prd.md b/.trellis/tasks/08-16-sidebar-collapse-button/prd.md new file mode 100644 index 0000000..ddb9244 --- /dev/null +++ b/.trellis/tasks/08-16-sidebar-collapse-button/prd.md @@ -0,0 +1,28 @@ +# 修复左侧树折叠按钮 + +## 背景 +用户在平板横屏/宽触控视口中看到左侧会话与项目树完整展开,但用于收起整个左侧栏的按钮消失。项目节点自身的展开箭头仍正常,本任务不处理项目节点展开逻辑。 + +## 目标 +恢复现有侧栏折叠能力在宽触控视口中的可发现性和可操作性,并保持桌面鼠标与窄屏抽屉行为不回退。 + +## 功能要求 +1. 左侧栏展开时,聊天标题栏必须显示可点击、可触控、可键盘聚焦的“收起侧栏”按钮。 +2. 左侧栏折叠后,同一按钮必须保留并可恢复侧栏,标签与 `aria-expanded` 同步更新。 +3. 宽触控视口(宽度大于 768px、`pointer: coarse` 或无 hover)不得落入“按钮隐藏且点击无行为”的空档。 +4. 宽桌面鼠标视口继续支持固定折叠;如保留 hover 临时预览,只在设备真实支持 hover 时启用。 +5. 768px 及以下继续使用现有抽屉开关与遮罩,不改变其语义。 +6. 所有现有主题均能看到按钮;暗金荒野主题不得因专属选择器隐藏或压暗按钮。 +7. 不修改项目节点展开箭头,不修改任务看板业务逻辑,不触碰现有 task-board 脏改动。 + +## 验收标准 +- 目标平板横屏视口:展开态按钮可见;点击后侧栏收起;再次点击恢复。 +- 常规桌面视口:展开/折叠、持久化、hover/focus 行为正常。 +- 窄屏视口:按钮继续控制抽屉开关,遮罩可关闭。 +- 按钮具备可见焦点,`aria-controls="sidebar"` 与真实状态一致。 +- 侧栏专项回归、相关前端回归、语法检查和 `git diff --check` 通过。 + +## 非目标 +- 不重做侧栏视觉主题。 +- 不调整项目/会话树的数据或展开状态。 +- 不修改任务看板打开时是否隐藏全局导航控件的产品决策。 diff --git a/.trellis/tasks/08-16-sidebar-collapse-button/research/root-cause.md b/.trellis/tasks/08-16-sidebar-collapse-button/research/root-cause.md new file mode 100644 index 0000000..ec39f8d --- /dev/null +++ b/.trellis/tasks/08-16-sidebar-collapse-button/research/root-cause.md @@ -0,0 +1,16 @@ +# 侧栏折叠入口初步调查 + +## 证据 +- `public/index.html` 已存在 `#menu-btn`,包含 `aria-controls="sidebar"` 和初始 `aria-expanded="true"`。 +- `public/app.js` 已缓存 `menuBtn`,并区分窄屏 drawer 与桌面 fixed collapse 两条点击路径。 +- 桌面折叠能力当前使用 `(width > 768px) and (hover: hover) and (pointer: fine)`。 +- `scripts/regression.js` 已有 `assertFrontendSidebarCollapseContract()`,覆盖 DOM、持久化、无障碍状态、窄屏 drawer 与桌面 hover rail。 +- 用户截图底部有触控设备 Home Indicator,且视口为横屏宽屏;这与 `width > 768px`、`pointer: coarse`、无 hover 的组合一致。 + +## 根因假设 +宽触控设备既不满足 `max-width: 768px` 的 drawer 条件,也不满足要求 `hover: hover`、`pointer: fine` 的桌面折叠条件,因此落入能力空档:现有按钮可能被 CSS 隐藏,即使存在于 DOM 中也不会进入任何有效点击分支。 + +## 实施方向 +- 宽屏固定折叠能力应按宽度提供,而不是按鼠标能力整体禁用。 +- hover 临时展开只属于鼠标增强,可继续用 hover/fine 条件隔离。 +- 先用本地源码与浏览器 computed style 验证假设,再做最小实现。 diff --git a/.trellis/tasks/08-16-sidebar-collapse-button/task.json b/.trellis/tasks/08-16-sidebar-collapse-button/task.json new file mode 100644 index 0000000..94378b5 --- /dev/null +++ b/.trellis/tasks/08-16-sidebar-collapse-button/task.json @@ -0,0 +1,26 @@ +{ + "id": "sidebar-collapse-button", + "name": "sidebar-collapse-button", + "title": "修复左侧树折叠按钮", + "description": "", + "status": "planning", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "shiyue", + "assignee": "shiyue", + "createdAt": "2026-08-16", + "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/Goal 气泡显示 TO DO list.csv b/Goal 气泡显示 TO DO list.csv new file mode 100644 index 0000000..7c5954a --- /dev/null +++ b/Goal 气泡显示 TO DO list.csv @@ -0,0 +1,9 @@ +id,item,status,done_at,notes +1,建立现状基线并固化 Goal 交互契约,DONE,2026-08-16T21:23:03+08:00,已定位 Slash 与 system_message 链路并固化验收语义 +2,审查实施计划并修正关键缺口,DONE,2026-08-16T21:27:35+08:00,第 2 轮独立审查通过 +3,增加 /goal 用户气泡发送与持久化契约,DONE,2026-08-16T21:36:08+08:00,已持久化 display-only Goal user 消息并通过 session_message 推送 +4,将 Goal 同步和成功反馈改为短暂精简提示,DONE,2026-08-16T21:36:08+08:00,同步与成功反馈 transient 且成功文案不回显目标 +5,补充 Goal 气泡及消息协议回归测试,DONE,2026-08-16T21:36:08+08:00,覆盖消息协议、元数据、去重与背景 turn 负向断言 +6,运行语法、专项和全量回归,DONE,2026-08-16T21:36:08+08:00,node --check 与 npm run regression 均通过 +7,执行真实浏览器桌面与窄屏验收,IN_PROGRESS,, +8,审查差异并清理临时任务记录,TODO,, 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 b256951..57a4521 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/public/app.js b/public/app.js index 12c4315..986a1c8 100644 --- a/public/app.js +++ b/public/app.js @@ -13,7 +13,8 @@ const PROJECT_COLLAPSE_STORAGE_KEY = 'cc-web-collapsed-projects'; const SIDEBAR_COLLAPSE_STORAGE_KEY = 'cc-web-sidebar-collapsed'; const SIDEBAR_DRAWER_MEDIA_QUERY = '(max-width: 768px)'; - const SIDEBAR_DESKTOP_COLLAPSE_MEDIA_QUERY = '(width > 768px) and (hover: hover) and (pointer: fine)'; + const SIDEBAR_DESKTOP_COLLAPSE_MEDIA_QUERY = '(width > 768px)'; + const SIDEBAR_HOVER_PREVIEW_MEDIA_QUERY = '(width > 768px) and (hover: hover) and (pointer: fine)'; const CROSS_CONVERSATION_REPLY_COLLAPSE_STORAGE_KEY = 'cc-web-collapsed-cross-replies'; const CROSS_CONVERSATION_REPLY_COLLAPSE_LIMIT = 500; const ASSISTANT_LAST_SECTION_BUTTON_CLASS = 'msg-last-section-btn'; @@ -65,6 +66,13 @@ return SLASH_COMMANDS.some((item) => item.cmd.toLowerCase() === normalized); } + function isGoalSetCommandText(text) { + const match = /^\s*\/goal(?:\s+([\s\S]*))?$/i.exec(String(text || '')); + if (!match) return false; + const rest = String(match[1] || '').trim().toLowerCase(); + return !!rest && !['clear', 'pause', 'resume'].includes(rest); + } + const MODE_LABELS = { default: '默认', plan: 'Plan', @@ -345,6 +353,7 @@ const mobileInputMedia = window.matchMedia('(max-width: 768px), (pointer: coarse)'); const sidebarDrawerMedia = window.matchMedia(SIDEBAR_DRAWER_MEDIA_QUERY); const sidebarDesktopCollapseMedia = window.matchMedia(SIDEBAR_DESKTOP_COLLAPSE_MEDIA_QUERY); + const sidebarHoverPreviewMedia = window.matchMedia(SIDEBAR_HOVER_PREVIEW_MEDIA_QUERY); const chatMain = document.querySelector('.chat-main'); const newChatSplit = sidebar.querySelector('.new-chat-split'); const newChatBtn = $('#new-chat-btn'); @@ -11247,8 +11256,17 @@ return; } const requestId = createLocalId('slash'); + const clientMessageId = isGoalSetCommandText(text) ? createLocalId('user') : ''; rememberPendingSlashDraft(requestId, text, currentSessionId, currentAgent); - send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent, requestId }); + send({ + type: 'message', + text, + sessionId: currentSessionId, + mode: currentMode, + agent: currentAgent, + requestId, + ...(clientMessageId ? { clientMessageId } : {}), + }); msgInput.value = ''; autoResize(); return; @@ -11310,7 +11328,7 @@ }); sidebarOverlay.addEventListener('click', closeSidebar); - [sidebarDrawerMedia, sidebarDesktopCollapseMedia].forEach((media) => { + [sidebarDrawerMedia, sidebarDesktopCollapseMedia, sidebarHoverPreviewMedia].forEach((media) => { if (typeof media.addEventListener === 'function') { media.addEventListener('change', handleSidebarInputModeChange); } else if (typeof media.addListener === 'function') { diff --git a/public/style.css b/public/style.css index 12695a1..d28d8d1 100644 --- a/public/style.css +++ b/public/style.css @@ -9925,7 +9925,7 @@ html[data-theme='gilded'] { outline-offset: 2px; } -@media (width > 768px) and (hover: hover) and (pointer: fine) { +@media (width > 768px) { .menu-btn { display: inline-flex; align-items: center; @@ -9951,11 +9951,6 @@ html[data-theme='gilded'] { box-shadow 180ms ease; } - .app.sidebar-collapsed .sidebar:is(:hover, :focus-within) { - transform: translateX(0); - box-shadow: var(--sidebar-hover-shadow); - } - /* 收起后控制条会贴近左侧轨道,定位浮层需朝聊天画布展开,避免越过应用边界被裁切。 */ .app.sidebar-collapsed .user-outline-panel { left: 0; @@ -9981,10 +9976,6 @@ html[data-theme='gilded'] { transition: opacity 120ms ease; } - .app.sidebar-collapsed .sidebar:is(:hover, :focus-within)::after { - opacity: 0; - } - html[data-theme='gilded'] .app.sidebar-collapsed .sidebar::after { border-left-color: rgba(138, 97, 37, 0.34); background: @@ -10002,6 +9993,17 @@ html[data-theme='gilded'] { } } +@media (width > 768px) and (hover: hover) and (pointer: fine) { + .app.sidebar-collapsed .sidebar:is(:hover, :focus-within) { + transform: translateX(0); + box-shadow: var(--sidebar-hover-shadow); + } + + .app.sidebar-collapsed .sidebar:is(:hover, :focus-within)::after { + opacity: 0; + } +} + @media (prefers-reduced-motion: reduce) { .app.sidebar-collapsed .sidebar, .app.sidebar-collapsed .sidebar::after { diff --git a/public/task-board.css b/public/task-board.css index ca7cc92..00a7a57 100644 --- a/public/task-board.css +++ b/public/task-board.css @@ -222,6 +222,14 @@ overflow: hidden; } +.ccweb-task-board__clock { + display: none; +} + +.ccweb-task-board__wall { + display: none; +} + .ccweb-task-board__lanes { display: grid; grid-auto-flow: column; @@ -1171,6 +1179,646 @@ html[data-theme='wasteland'] .ccweb-task-board__card[data-runtime-state='running } } +/* 横屏常亮模式:看板覆盖完整网页视口,宿主聊天界面不再参与排版。 */ +@media (orientation: landscape) and (max-height: 768px) { + body:has(.task-board-panel:not([hidden])) .menu-btn { + display: none !important; + } + + .task-board-panel { + position: fixed; + inset: 0; + z-index: 400; + display: block; + width: 100vw; + height: 100vh; + height: 100dvh; + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--bg-primary); + } + + .task-board-panel__root { + position: absolute; + inset: 0; + width: 100vw; + height: 100vh; + height: 100dvh; + min-width: 0; + min-height: 0; + overflow: hidden; + } + + .task-board-panel__header { + position: absolute; + top: calc(env(safe-area-inset-top, 0px) + 0.3rem); + right: calc(env(safe-area-inset-right, 0px) + 0.35rem); + z-index: 50; + display: block; + width: 1.9rem; + min-height: 0; + padding: 0; + border: 0; + background: transparent; + pointer-events: none; + } + + .task-board-panel__header .usage-dashboard__icon-button { + width: 1.9rem; + min-width: 1.9rem; + height: 1.9rem; + padding: 0; + border: 1px solid var(--border-color); + border-radius: 0.5rem; + color: var(--text-secondary); + background: var(--bg-tertiary); + pointer-events: auto; + } + + .ccweb-task-board { + position: absolute; + inset: 0; + grid-template-rows: minmax(0, 1fr); + width: auto; + height: auto; + min-height: 0; + animation: none; + } + + .ccweb-task-board__toolbar { + display: none; + grid-template-columns: minmax(0, auto) minmax(5.2rem, 1fr) auto; + gap: 0.45rem; + min-height: 2.3rem; + padding: + calc(env(safe-area-inset-top, 0px) + 0.22rem) + calc(env(safe-area-inset-right, 0px) + 2.4rem) + 0.22rem + calc(env(safe-area-inset-left, 0px) + 0.42rem); + border-bottom: 1px solid var(--border-color); + background: var(--bg-primary); + } + + .ccweb-task-board__heading { + display: flex; + align-items: baseline; + gap: 0.38rem; + min-width: 0; + } + + .ccweb-task-board__title { + flex: 0 0 auto; + font-size: 0.78rem; + letter-spacing: -0.015em; + white-space: nowrap; + } + + .ccweb-task-board__summary { + display: none; + } + + .ccweb-task-board__clock { + display: flex; + min-width: 0; + min-height: 0; + padding: clamp(0.65rem, 2.2vw, 1.2rem); + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: clamp(0.35rem, 1.4vh, 0.65rem); + color: var(--text-primary); + white-space: nowrap; + background: var(--bg-secondary); + } + + .ccweb-task-board__clock-time { + color: var(--text-primary); + font-size: clamp(2.25rem, 6.4vw, 4.6rem); + font-weight: 680; + font-variant-numeric: tabular-nums; + letter-spacing: -0.07em; + line-height: 0.9; + } + + .ccweb-task-board__clock-date { + color: var(--text-secondary); + font-size: clamp(0.6rem, 1.2vw, 0.78rem); + font-weight: 560; + letter-spacing: 0.025em; + } + + .ccweb-task-board__controls { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.28rem; + } + + .ccweb-task-board__search { + flex: 0 1 min(9rem, 30vw); + width: min(9rem, 30vw); + transition: flex-basis 160ms ease, width 160ms ease; + } + + .ccweb-task-board__search:not(:focus-within) { + flex-basis: 1.9rem; + width: 1.9rem; + } + + .ccweb-task-board[data-search-active='true'] .ccweb-task-board__search, + .ccweb-task-board__search:focus-within { + flex-basis: min(9rem, 30vw); + width: min(9rem, 30vw); + } + + .ccweb-task-board__search-glyph { + left: 0.55rem; + font-size: 0.9rem; + } + + .ccweb-task-board__input { + height: 1.75rem; + padding: 0 0.5rem 0 1.65rem; + border-radius: 0.5rem; + font-size: 0.65rem; + } + + .ccweb-task-board[data-search-active='false'] .ccweb-task-board__search:not(:focus-within) .ccweb-task-board__input { + padding-right: 0; + color: transparent; + cursor: pointer; + } + + .ccweb-task-board[data-search-active='false'] .ccweb-task-board__search:not(:focus-within) .ccweb-task-board__input::placeholder { + opacity: 0; + } + + .ccweb-task-board__button { + min-height: 1.75rem; + padding: 0.2rem 0.4rem; + border-radius: 0.42rem; + font-size: 0.6rem; + } + + .ccweb-task-board__workspace { + display: block; + height: 100%; + min-height: 0; + padding: 0; + } + + .ccweb-task-board__wall { + display: block; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + padding: + env(safe-area-inset-top, 0px) + env(safe-area-inset-right, 0px) + env(safe-area-inset-bottom, 0px) + env(safe-area-inset-left, 0px); + overflow: hidden; + background: var(--border-color); + } + + .ccweb-task-board__wall-grid { + display: grid; + grid-template-columns: repeat(var(--task-board-wall-columns), minmax(0, 1fr)); + grid-template-rows: repeat(var(--task-board-wall-rows), minmax(0, 1fr)); + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; + gap: 1px; + background: var(--border-color); + } + + .ccweb-task-board__wall-column { + --task-board-status-color: var(--text-muted); + display: grid; + grid-template-rows: auto minmax(0, 1fr); + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--bg-primary); + } + + .ccweb-task-board__wall-column-header, + .ccweb-task-board__wall-column-identity, + .ccweb-task-board__wall-card-meta, + .ccweb-task-board__wall-card-runtime { + display: flex; + align-items: center; + } + + .ccweb-task-board__wall-column-header { + min-width: 0; + min-height: clamp(2rem, 6.8vh, 2.55rem); + padding: clamp(0.35rem, 1.2vh, 0.58rem) clamp(0.5rem, 1.4vw, 0.82rem); + justify-content: space-between; + gap: 0.5rem; + border-bottom: 1px solid var(--border-color); + background: var(--bg-secondary); + } + + .ccweb-task-board__wall-column-identity { + min-width: 0; + gap: clamp(0.34rem, 0.8vw, 0.52rem); + } + + .ccweb-task-board__wall-column-dot { + display: block; + flex: 0 0 auto; + width: clamp(0.38rem, 0.8vw, 0.5rem); + height: clamp(0.38rem, 0.8vw, 0.5rem); + border-radius: 50%; + background: var(--task-board-status-color); + box-shadow: 0 0 0 0.12rem color-mix(in srgb, var(--task-board-status-color) 18%, transparent); + } + + .ccweb-task-board__wall-column-title { + margin: 0; + overflow: hidden; + color: var(--text-primary); + font-size: clamp(0.7rem, 1.5vw, 0.98rem); + font-weight: 680; + letter-spacing: -0.015em; + text-overflow: ellipsis; + white-space: nowrap; + } + + .ccweb-task-board__wall-column-count { + flex: 0 0 auto; + color: var(--text-secondary); + font-size: clamp(0.6rem, 1.15vw, 0.76rem); + font-variant-numeric: tabular-nums; + } + + .ccweb-task-board__wall-column-page { + flex: 0 0 auto; + color: var(--text-muted); + font-size: clamp(0.55rem, 1vw, 0.68rem); + font-variant-numeric: tabular-nums; + } + + .ccweb-task-board__wall-column-body { + display: grid; + grid-auto-rows: minmax(0, 1fr); + min-width: 0; + min-height: 0; + padding: clamp(0.35rem, 1.1vw, 0.65rem); + overflow: hidden; + gap: clamp(0.28rem, 0.8vw, 0.5rem); + } + + .ccweb-task-board__wall-card { + --task-board-status-color: var(--text-muted); + position: relative; + z-index: 1; + display: flex; + min-width: 0; + min-height: 0; + padding: clamp(0.4rem, 1.15vw, 0.72rem) clamp(0.45rem, 1.25vw, 0.78rem); + overflow: hidden; + flex-direction: column; + gap: clamp(0.22rem, 0.8vh, 0.42rem); + border: 1px solid var(--border-color); + border-left: 0.16rem solid var(--task-board-status-color); + border-radius: clamp(0.35rem, 0.8vw, 0.55rem); + color: var(--text-primary); + background: var(--bg-secondary); + transform-origin: 0 50%; + will-change: opacity, transform, clip-path; + } + + .ccweb-task-board__wall-card-title { + margin: 0; + color: var(--text-primary); + font-size: clamp(0.68rem, 1.45vw, 1rem); + font-weight: 690; + letter-spacing: -0.018em; + line-height: 1.28; + overflow-wrap: anywhere; + } + + .ccweb-task-board__wall-card-summary { + display: block; + margin: 0; + color: var(--text-secondary); + font-size: clamp(0.56rem, 1.12vw, 0.76rem); + line-height: 1.38; + overflow-wrap: anywhere; + white-space: normal; + } + + .ccweb-task-board__wall-card-meta { + margin-top: auto; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.3rem 0.6rem; + color: var(--text-muted); + font-size: clamp(0.52rem, 1vw, 0.68rem); + } + + .ccweb-task-board__wall-card-runtime { + gap: 0.3rem; + } + + .ccweb-task-board__wall-card-runtime-dot { + width: 0.36rem; + height: 0.36rem; + border-radius: 50%; + background: var(--text-muted); + } + + .ccweb-task-board__wall-card-runtime[data-running='true'] { + color: var(--success); + } + + .ccweb-task-board__wall-card-runtime[data-running='true'] .ccweb-task-board__wall-card-runtime-dot { + background: currentColor; + animation: ccweb-task-board-pulse 850ms ease-in-out infinite alternate; + } + + .ccweb-task-board__wall-card-updated { + color: var(--text-muted); + } + + .ccweb-task-board__wall-column-empty, + .ccweb-task-board__wall-empty { + display: flex; + min-width: 0; + min-height: 0; + align-items: center; + justify-content: center; + gap: 0.38rem; + color: var(--text-muted); + font-size: clamp(0.58rem, 1.15vw, 0.76rem); + } + + .ccweb-task-board__wall-empty { + flex-direction: column; + background: var(--bg-primary); + } + + .ccweb-task-board__wall-column-empty-mark, + .ccweb-task-board__wall-empty-mark { + color: var(--text-muted); + font-size: 1.1rem; + } + + .ccweb-task-board__lanes { + display: none; + grid-auto-flow: row; + grid-auto-columns: auto; + grid-auto-rows: auto; + align-items: stretch; + gap: 0; + height: 100%; + padding: 0; + overflow-y: hidden; + scroll-snap-type: none; + } + + .ccweb-task-board__lanes[data-column-layout='fit'] { + grid-template-columns: repeat(var(--task-board-column-count), minmax(0, 1fr)); + grid-template-rows: minmax(0, 1fr); + overflow-x: hidden; + } + + .ccweb-task-board__lanes[data-column-layout='scroll'] { + grid-template-columns: repeat(var(--task-board-column-count), minmax(10rem, 1fr)); + grid-template-rows: minmax(0, 1fr); + overflow-x: auto; + } + + .ccweb-task-board__column { + height: 100%; + min-height: 0; + overflow: hidden; + border: 0; + border-left: 1px solid var(--border-color); + border-radius: 0; + scroll-snap-align: none; + } + + .ccweb-task-board__column:last-child { + border-right: 1px solid var(--border-color); + } + + .ccweb-task-board__column-header { + position: static; + flex: 0 0 auto; + min-height: 1.72rem; + gap: 0.3rem; + padding: 0.27rem 0.36rem; + border-radius: 0; + } + + .ccweb-task-board__column-title-wrap { + gap: 0.32rem; + } + + .ccweb-task-board__column-dot { + width: 0.4rem; + height: 0.4rem; + box-shadow: 0 0 0 0.12rem color-mix(in srgb, var(--task-board-status-color) 18%, transparent); + } + + .ccweb-task-board__column-title { + font-size: clamp(0.6rem, 1.08vw, 0.72rem); + } + + .ccweb-task-board__column-count { + min-width: 1.15rem; + height: 1.08rem; + padding: 0 0.25rem; + font-size: 0.56rem; + } + + .ccweb-task-board__column-disabled, + .ccweb-task-board__column-empty { + display: none; + } + + .ccweb-task-board__column-body { + flex: 1 1 auto; + gap: 0.28rem; + min-height: 0; + padding: 0.3rem; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior-y: contain; + scrollbar-width: thin; + -webkit-overflow-scrolling: touch; + } + + .ccweb-task-board__card { + flex: 0 0 auto; + gap: 0.24rem; + padding: 0.34rem; + border-radius: 0.38rem; + } + + .ccweb-task-board__card:hover, + .ccweb-task-board__card:focus-within { + transform: none; + } + + .ccweb-task-board__card-top, + .ccweb-task-board__card-summary, + .ccweb-task-board__metadata { + display: none; + } + + .ccweb-task-board__card-title { + font-size: clamp(0.61rem, 1.05vw, 0.72rem); + line-height: 1.26; + -webkit-line-clamp: 2; + } + + .ccweb-task-board__card[data-runtime-state='idle'] .ccweb-task-board__state-row { + display: none; + } + + .ccweb-task-board__state-row { + gap: 0.2rem; + } + + .ccweb-task-board__runtime-state { + min-height: 1.05rem; + padding: 0.08rem 0.23rem; + border-radius: 0.3rem; + font-size: 0.52rem; + } + + .ccweb-task-board__card-footer { + align-items: center; + flex-direction: row; + padding-top: 0.22rem; + } + + .ccweb-task-board__card-actions { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + width: 100%; + gap: 0.22rem; + } + + .ccweb-task-board__status-select-wrap { + width: auto; + min-width: 0; + } + + .ccweb-task-board__card-select { + height: 1.36rem; + padding: 0 1rem 0 0.32rem; + border-radius: 0.35rem; + font-size: 0.52rem; + } + + .ccweb-task-board__card-action { + min-height: 1.36rem; + padding: 0.15rem 0.34rem; + border-radius: 0.35rem; + font-size: 0.52rem; + } + + .ccweb-task-board__empty { + width: 100%; + height: 100%; + min-height: 0; + padding: 1rem; + } + + .ccweb-task-board__status-layer { + display: none !important; + grid-template-columns: minmax(0, 1fr) minmax(19rem, 70vw); + } + + .ccweb-task-board__status-header { + padding: 0.55rem 0.7rem; + } + + .ccweb-task-board__status-body { + padding: 0.55rem; + } +} + +@media (orientation: landscape) and (max-height: 430px) { + .ccweb-task-board__toolbar { + min-height: 2.15rem; + padding-top: calc(env(safe-area-inset-top, 0px) + 0.2rem); + padding-bottom: 0.2rem; + } + + .ccweb-task-board__title { + font-size: 0.78rem; + } + + .ccweb-task-board__summary { + display: none; + } + + .ccweb-task-board__clock-time { + font-size: clamp(2.25rem, 5.5vw, 3.25rem); + } + + .ccweb-task-board__clock-date { + display: block; + } + + .ccweb-task-board__clock { + padding: 0.55rem; + } + + .ccweb-task-board__wall-column-header { + min-height: 1.8rem; + padding: 0.28rem 0.48rem; + } + + .ccweb-task-board__wall-column-body { + padding: 0.28rem; + gap: 0.24rem; + } + + .ccweb-task-board__wall-card { + padding: 0.32rem 0.42rem; + gap: 0.18rem; + } + + .ccweb-task-board__wall-card-title { + font-size: clamp(0.64rem, 1.4vw, 0.82rem); + } + + .ccweb-task-board__wall-card-summary { + font-size: clamp(0.52rem, 1.05vw, 0.66rem); + line-height: 1.3; + } + + .ccweb-task-board__column-header { + min-height: 1.72rem; + padding-block: 0.24rem; + } + + .ccweb-task-board__column-body { + gap: 0.28rem; + padding: 0.28rem; + } + + .ccweb-task-board__card { + gap: 0.24rem; + padding: 0.34rem; + } + + .ccweb-task-board__card-title { + -webkit-line-clamp: 2; + } +} + @media (prefers-reduced-motion: reduce) { html[data-theme='wasteland'] .ccweb-task-board__card[data-runtime-state='running']::after { top: 50%; @@ -1197,11 +1845,17 @@ html[data-theme='wasteland'] .ccweb-task-board__card[data-runtime-state='running .ccweb-task-board__card:focus-within { transform: none; } + + .ccweb-task-board__wall-card { + will-change: auto; + } } @media (forced-colors: active) { .ccweb-task-board__column-dot, - .ccweb-task-board__definition-dot { + .ccweb-task-board__definition-dot, + .ccweb-task-board__wall-column-dot, + .ccweb-task-board__wall-card-runtime-dot { border: 1px solid CanvasText; } diff --git a/public/task-board.js b/public/task-board.js index c4a2f1d..8de4291 100644 --- a/public/task-board.js +++ b/public/task-board.js @@ -265,13 +265,24 @@ error: null, destroyed: false, }; + const fitColumnLimit = Math.min(6, Math.max(1, Math.floor(finiteNumber(options.fitColumnLimit, 6)))); + const wallRotationMs = Math.max(20, finiteNumber(options.wallRotationMs, 12000)); + const wallUpdateHoldMs = Math.max(wallRotationMs, finiteNumber(options.wallUpdateHoldMs, 20000)); + const wallCardsPerPage = Math.min(4, Math.max(1, Math.floor(finiteNumber(options.wallCardsPerPage, 2)))); let searchTimer = null; + let clockTimer = null; + let wallRotationTimer = null; + let wallHasRendered = false; + let wallRotationCursor = 0; let previousFocus = null; let latestQueryRequestId = ''; let hasRenderedBoard = false; const columnRecords = new Map(); const cardRecords = new Map(); + const wallColumnRecords = new Map(); + const wallCardRecords = new Map(); + let wallVisibleSessionIds = new Set(); let boardEmpty = null; const shell = createNode(doc, 'section', 'ccweb-task-board'); @@ -285,6 +296,12 @@ summary.setAttribute('aria-live', 'polite'); headingGroup.append(title, summary); + const clock = createNode(doc, 'aside', 'ccweb-task-board__clock'); + clock.setAttribute('aria-label', '当前时间'); + const clockTime = createNode(doc, 'time', 'ccweb-task-board__clock-time'); + const clockDate = createNode(doc, 'span', 'ccweb-task-board__clock-date'); + clock.append(clockTime, clockDate); + const controls = createNode(doc, 'div', 'ccweb-task-board__controls'); controls.setAttribute('role', 'group'); controls.setAttribute('aria-label', '任务看板搜索与操作'); @@ -321,7 +338,21 @@ const board = createNode(doc, 'div', 'ccweb-task-board__lanes'); board.setAttribute('aria-label', '任务状态列'); board.tabIndex = 0; - workspace.appendChild(board); + + const wall = createNode(doc, 'section', 'ccweb-task-board__wall'); + wall.setAttribute('aria-label', '横屏任务看板'); + const wallGrid = createNode(doc, 'div', 'ccweb-task-board__wall-grid'); + wallGrid.setAttribute('role', 'list'); + const wallEmpty = createNode(doc, 'section', 'ccweb-task-board__wall-empty'); + wallEmpty.setAttribute('role', 'status'); + wallEmpty.append( + createNode(doc, 'span', 'ccweb-task-board__wall-empty-mark', '◇'), + createNode(doc, 'span', '', '等待状态定义'), + ); + wallGrid.append(clock, wallEmpty); + wall.appendChild(wallGrid); + + workspace.append(board, wall); const statusLayer = createNode(doc, 'div', 'ccweb-task-board__status-layer'); statusLayer.hidden = true; @@ -361,7 +392,13 @@ searchInput, archiveButton, statusButton, + clock, + clockTime, + clockDate, board, + wall, + wallGrid, + wallEmpty, statusLayer, statusPanel, statusClose, @@ -374,6 +411,463 @@ refs.liveRegion.textContent = text(message); } + function renderClock() { + const now = new Date(); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']; + refs.clockTime.textContent = `${hours}:${minutes}`; + refs.clockTime.setAttribute('datetime', now.toISOString()); + refs.clockDate.textContent = `${now.getMonth() + 1}月${now.getDate()}日 · ${weekdays[now.getDay()]}`; + } + + function scheduleClock() { + renderClock(); + if (typeof global.setTimeout !== 'function') return; + const now = new Date(); + const delay = Math.max(1000, (60 - now.getSeconds()) * 1000 - now.getMilliseconds() + 20); + clockTimer = global.setTimeout(scheduleClock, delay); + if (clockTimer && typeof clockTimer.unref === 'function') clockTimer.unref(); + } + + function wallTaskTimestamp(task) { + return Date.parse(task?.updatedAt || '') || 0; + } + + function compareWallTasks(left, right) { + if (left.isRunning !== right.isRunning) return left.isRunning ? -1 : 1; + const timeDifference = wallTaskTimestamp(right) - wallTaskTimestamp(left); + if (timeDifference) return timeDifference; + if (left.order !== right.order) return left.order - right.order; + return left.sessionId.localeCompare(right.sessionId); + } + + function getWallTasks() { + return state.tasks + .filter((task) => task.trackingEnabled && !task.archivedAt) + .slice() + .sort(compareWallTasks); + } + + function wallTaskSignature(task) { + if (!task) return ''; + return JSON.stringify([ + task.sessionId, + task.title, + task.statusId, + task.summary, + task.reason, + task.isRunning, + task.runtimeState, + task.runtimeDetail, + task.updatedAt, + task.version, + ]); + } + + function clearWallRotation() { + if (wallRotationTimer === null) return; + global.clearTimeout(wallRotationTimer); + wallRotationTimer = null; + } + + function wallLayoutSize(statusCount) { + const cellCount = Math.max(1, statusCount + 1); + const preferredColumns = cellCount <= 4 + ? Math.min(cellCount, 2) + : Math.round(Math.sqrt(cellCount * (16 / 9))); + const columns = Math.min(cellCount, Math.max(1, preferredColumns)); + return { cellCount, columns, rows: Math.max(1, Math.ceil(cellCount / columns)) }; + } + + function wallTasksByStatus(definitions, tasks) { + const grouped = new Map(definitions.map((definition) => [definition.id, []])); + for (const task of tasks) grouped.get(task.statusId)?.push(task); + return grouped; + } + + function overflowingWallStatusIds() { + const definitions = getBoardDefinitions(); + const grouped = wallTasksByStatus(definitions, getWallTasks()); + const pageSize = wallLayoutSize(definitions.length || 1).rows >= 3 ? 1 : wallCardsPerPage; + return definitions + .filter((definition) => (grouped.get(definition.id)?.length || 0) > pageSize) + .map((definition) => definition.id); + } + + function scheduleWallRotation(delay = wallRotationMs) { + clearWallRotation(); + if (state.destroyed || prefersReducedBoardMotion() || !overflowingWallStatusIds().length) return; + wallRotationTimer = global.setTimeout(() => { + wallRotationTimer = null; + advanceWallPage(); + }, delay); + if (wallRotationTimer && typeof wallRotationTimer.unref === 'function') wallRotationTimer.unref(); + } + + function readWallCardRect(card) { + if (!card || typeof card.getBoundingClientRect !== 'function' || !refs.wall.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 }; + } + + function cancelWallCardAnimation(record, kind = '') { + if (!record?.animations) return; + const entries = kind + ? [[kind, record.animations.get(kind)]] + : Array.from(record.animations.entries()); + for (const [animationKind, animation] of entries) { + if (!animation) continue; + animation.onfinish = null; + animation.oncancel = null; + record.animations.delete(animationKind); + if (typeof animation.cancel !== 'function') continue; + try { + animation.cancel(); + } catch (_) { + // 动画对象可能已由浏览器回收;卡片节点仍继续复用。 + } + } + if (!record.animations.size) delete record.element.dataset.motion; + } + + function runWallCardAnimation(record, kind, keyframes, options, onFinish) { + cancelWallCardAnimation(record, kind); + const card = record.element; + card.dataset.motion = kind; + const complete = () => { + record.animations.delete(kind); + if (!record.animations.size) delete card.dataset.motion; + if (typeof onFinish === 'function') onFinish(); + }; + if (prefersReducedBoardMotion() || typeof card.animate !== 'function') { + complete(); + return; + } + let animation; + try { + animation = card.animate(keyframes, { fill: 'both', ...options }); + } catch (_) { + complete(); + return; + } + record.animations.set(kind, animation); + animation.onfinish = () => { + if (record.animations.get(kind) !== animation) return; + animation.onfinish = null; + animation.oncancel = null; + try { + animation.cancel(); + } catch (_) { + // 基础样式接管完成帧;旧浏览器回收动画时无需阻断渲染。 + } + complete(); + }; + animation.oncancel = () => { + if (record.animations.get(kind) !== animation) return; + animation.onfinish = null; + animation.oncancel = null; + record.animations.delete(kind); + if (!record.animations.size) delete card.dataset.motion; + }; + } + + function animateWallCardPageLeave(sessionId, record) { + runWallCardAnimation(record, 'page-leaving', [ + { opacity: 1, transform: 'translate3d(0, 0, 0) scaleY(1)', clipPath: 'inset(0 0 0 0)' }, + { opacity: 0, transform: 'translate3d(-0.7rem, 0, 0) scaleY(0.72)', clipPath: 'inset(0 88% 0 0)' }, + ], { + id: 'ccweb-task-board-wall-card-page-leave', + duration: 300, + easing: 'cubic-bezier(0.55, 0.05, 0.72, 0.35)', + }, () => { + if (!wallVisibleSessionIds.has(sessionId)) record.element.remove(); + }); + } + + function animateWallCardPageEnter(record, index) { + runWallCardAnimation(record, 'page-entering', [ + { opacity: 0, transform: 'translate3d(0.7rem, 0, 0) scaleY(0.76)', clipPath: 'inset(0 0 100% 0)' }, + { opacity: 1, transform: 'translate3d(0, 0, 0) scaleY(1)', clipPath: 'inset(0 0 0 0)' }, + ], { + id: 'ccweb-task-board-wall-card-page-enter', + duration: 430, + delay: Math.min(index, 3) * 55, + easing: 'cubic-bezier(0.16, 1, 0.3, 1)', + }); + } + + function animateWallCardMove(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; + runWallCardAnimation(record, 'moving', [ + { transform: `translate3d(${deltaX}px, ${deltaY}px, 0)`, zIndex: 4 }, + { transform: 'translate3d(0, 0, 0)', zIndex: 1 }, + ], { + id: 'ccweb-task-board-wall-card-move', + duration: 520, + easing: 'cubic-bezier(0.22, 1, 0.36, 1)', + }); + } + + function animateWallCardUpdate(record) { + runWallCardAnimation(record, 'updating', [ + { boxShadow: 'inset 0.16rem 0 0 var(--task-board-status-color)', backgroundColor: 'var(--bg-secondary)' }, + { boxShadow: 'inset 0.3rem 0 0 var(--task-board-status-color), 0 0 0 0.16rem color-mix(in srgb, var(--task-board-status-color) 28%, transparent)', backgroundColor: 'color-mix(in srgb, var(--task-board-status-color) 10%, var(--bg-secondary))', offset: 0.36 }, + { boxShadow: 'inset 0.16rem 0 0 var(--task-board-status-color)', backgroundColor: 'var(--bg-secondary)' }, + ], { + id: 'ccweb-task-board-wall-card-update', + duration: 1050, + easing: 'cubic-bezier(0.22, 1, 0.36, 1)', + }); + } + + function createWallColumnRecord(definition, definitionIndex) { + const element = createNode(doc, 'section', 'ccweb-task-board__wall-column'); + element.setAttribute('role', 'listitem'); + const header = createNode(doc, 'header', 'ccweb-task-board__wall-column-header'); + const identity = createNode(doc, 'div', 'ccweb-task-board__wall-column-identity'); + const dot = createNode(doc, 'span', 'ccweb-task-board__wall-column-dot'); + dot.setAttribute('aria-hidden', 'true'); + const title = createNode(doc, 'h2', 'ccweb-task-board__wall-column-title'); + const count = createNode(doc, 'span', 'ccweb-task-board__wall-column-count'); + identity.append(dot, title, count); + const page = createNode(doc, 'span', 'ccweb-task-board__wall-column-page'); + header.append(identity, page); + const body = createNode(doc, 'div', 'ccweb-task-board__wall-column-body'); + body.setAttribute('role', 'list'); + element.append(header, body); + return { + element, + header, + title, + count, + page, + body, + empty: null, + pageIndex: 0, + totalPages: 1, + definitionIndex, + }; + } + + function updateWallColumnRecord(record, definition, definitionIndex, taskCount, pageSize) { + const titleId = `task-board-wall-column-${domToken(definition.id, definitionIndex)}`; + record.element.dataset.statusId = definition.id; + record.element.setAttribute('aria-labelledby', titleId); + setStatusColor(record.element, definition.color); + record.title.id = titleId; + 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.totalPages = Math.max(1, Math.ceil(taskCount / pageSize)); + record.pageIndex = ((record.pageIndex % record.totalPages) + record.totalPages) % record.totalPages; + record.page.textContent = record.totalPages > 1 ? `${record.pageIndex + 1} / ${record.totalPages}` : ''; + record.page.hidden = record.totalPages <= 1; + record.definitionIndex = definitionIndex; + } + + function wallCardSignature(task, definition) { + return JSON.stringify([wallTaskSignature(task), definition.label, definition.color]); + } + + function renderWallCard(task, definition, existingCard = null) { + const card = existingCard || createNode(doc, 'article', 'ccweb-task-board__wall-card'); + card.replaceChildren(); + card.dataset.sessionId = task.sessionId; + card.dataset.statusId = task.statusId; + card.dataset.runtimeState = task.isRunning ? 'running' : 'idle'; + card.setAttribute('role', 'listitem'); + setStatusColor(card, definition.color); + card.appendChild(createNode(doc, 'h3', 'ccweb-task-board__wall-card-title', task.title)); + const supportingText = task.summary || task.reason; + if (supportingText) card.appendChild(createNode(doc, 'p', 'ccweb-task-board__wall-card-summary', supportingText)); + const meta = createNode(doc, 'div', 'ccweb-task-board__wall-card-meta'); + const runtimeLabel = task.runtimeState || (task.isRunning ? '运行中' : ''); + if (runtimeLabel) { + const runtime = createNode(doc, 'span', 'ccweb-task-board__wall-card-runtime', runtimeLabel); + runtime.dataset.running = task.isRunning ? 'true' : 'false'; + runtime.prepend(createNode(doc, 'span', 'ccweb-task-board__wall-card-runtime-dot')); + meta.appendChild(runtime); + } + const updatedLabel = formatTime(task.updatedAt); + if (updatedLabel) { + const updated = createNode(doc, 'time', 'ccweb-task-board__wall-card-updated', updatedLabel); + updated.setAttribute('datetime', String(task.updatedAt)); + meta.appendChild(updated); + } + if (meta.children.length) card.appendChild(meta); + return card; + } + + function ensureWallColumnEmpty(record) { + if (!record.empty) { + record.empty = createNode(doc, 'div', 'ccweb-task-board__wall-column-empty'); + record.empty.append( + createNode(doc, 'span', 'ccweb-task-board__wall-column-empty-mark', '·'), + createNode(doc, 'span', '', '暂无任务'), + ); + } + if (record.empty.parentNode !== record.body) record.body.appendChild(record.empty); + } + + function renderWall(options = {}) { + if (state.destroyed) return; + const definitions = getBoardDefinitions(); + const tasks = getWallTasks(); + const grouped = wallTasksByStatus(definitions, tasks); + const layout = wallLayoutSize(definitions.length || 1); + const pageSize = layout.rows >= 3 ? 1 : wallCardsPerPage; + refs.wallGrid.style.setProperty('--task-board-wall-columns', String(layout.columns)); + refs.wallGrid.style.setProperty('--task-board-wall-rows', String(layout.rows)); + refs.wallGrid.dataset.cellCount = String(definitions.length + 1); + refs.wallGrid.dataset.rowCount = String(layout.rows); + + const updatedSessionId = text(options.updatedSessionId); + const updatedTask = updatedSessionId ? tasks.find((task) => task.sessionId === updatedSessionId) : null; + const oldVisibleSessionIds = new Set(wallVisibleSessionIds); + const oldRects = new Map(); + for (const [sessionId, record] of wallCardRecords) { + const rect = readWallCardRect(record.element); + if (rect) oldRects.set(sessionId, rect); + cancelWallCardAnimation(record); + } + + if (!definitions.length) { + refs.wallEmpty.hidden = false; + syncNodeOrder(refs.wallGrid, [refs.clock, refs.wallEmpty]); + wallVisibleSessionIds = new Set(); + for (const [sessionId, record] of wallCardRecords) { + record.element.remove(); + wallCardRecords.delete(sessionId); + } + for (const [statusId, record] of wallColumnRecords) { + record.element.remove(); + wallColumnRecords.delete(statusId); + } + clearWallRotation(); + wallHasRendered = true; + return; + } + + refs.wallEmpty.hidden = true; + refs.wallEmpty.remove(); + const desiredStatusIds = new Set(); + const desiredColumnNodes = [refs.clock]; + definitions.forEach((definition, definitionIndex) => { + desiredStatusIds.add(definition.id); + let record = wallColumnRecords.get(definition.id); + if (!record) { + record = createWallColumnRecord(definition, definitionIndex); + wallColumnRecords.set(definition.id, record); + } + const columnTasks = grouped.get(definition.id) || []; + if (updatedTask?.statusId === definition.id) { + const updatedIndex = columnTasks.findIndex((task) => task.sessionId === updatedTask.sessionId); + if (updatedIndex >= 0) record.pageIndex = Math.floor(updatedIndex / pageSize); + } + updateWallColumnRecord(record, definition, definitionIndex, columnTasks.length, pageSize); + desiredColumnNodes.push(record.element); + }); + syncNodeOrder(refs.wallGrid, desiredColumnNodes); + + const desiredVisibleSessionIds = new Set(); + const taskSessionIds = new Set(tasks.map((task) => task.sessionId)); + const desiredCardsByStatus = new Map(definitions.map((definition) => [definition.id, []])); + const visibleTransitions = []; + + definitions.forEach((definition) => { + const column = wallColumnRecords.get(definition.id); + const columnTasks = grouped.get(definition.id) || []; + const start = column.pageIndex * pageSize; + const visibleTasks = columnTasks.slice(start, start + pageSize); + for (const task of visibleTasks) { + desiredVisibleSessionIds.add(task.sessionId); + let record = wallCardRecords.get(task.sessionId); + if (!record) { + record = { + element: createNode(doc, 'article', 'ccweb-task-board__wall-card'), + signature: '', + statusId: '', + animations: new Map(), + }; + wallCardRecords.set(task.sessionId, record); + } + const signature = wallCardSignature(task, definition); + if (record.signature !== signature) { + renderWallCard(task, definition, record.element); + record.signature = signature; + } + record.statusId = task.statusId; + desiredCardsByStatus.get(definition.id).push(record.element); + visibleTransitions.push({ sessionId: task.sessionId, record }); + } + }); + + wallVisibleSessionIds = desiredVisibleSessionIds; + definitions.forEach((definition) => { + const column = wallColumnRecords.get(definition.id); + const desiredCards = desiredCardsByStatus.get(definition.id); + if (desiredCards.length) { + column.empty?.remove(); + syncNodeOrder(column.body, desiredCards); + } else { + ensureWallColumnEmpty(column); + } + }); + + for (const [sessionId, record] of Array.from(wallCardRecords.entries())) { + if (desiredVisibleSessionIds.has(sessionId)) continue; + if (!taskSessionIds.has(sessionId)) { + cancelWallCardAnimation(record); + record.element.remove(); + wallCardRecords.delete(sessionId); + continue; + } + if (record.element.parentNode && wallHasRendered) animateWallCardPageLeave(sessionId, record); + else record.element.remove(); + } + + for (const transition of visibleTransitions) { + const after = readWallCardRect(transition.record.element); + const before = oldRects.get(transition.sessionId); + if (wallHasRendered && before) animateWallCardMove(transition.record, before, after); + else if (wallHasRendered && !oldVisibleSessionIds.has(transition.sessionId)) { + const siblings = desiredCardsByStatus.get(transition.record.statusId) || []; + animateWallCardPageEnter(transition.record, siblings.indexOf(transition.record.element)); + } + if (transition.sessionId === updatedSessionId) animateWallCardUpdate(transition.record); + } + + for (const [statusId, record] of Array.from(wallColumnRecords.entries())) { + if (desiredStatusIds.has(statusId)) continue; + record.element.remove(); + wallColumnRecords.delete(statusId); + } + + wallHasRendered = true; + if (updatedTask) scheduleWallRotation(wallUpdateHoldMs); + else if (wallRotationTimer === null) scheduleWallRotation(); + } + + function advanceWallPage() { + const statusIds = overflowingWallStatusIds(); + if (!statusIds.length) { + clearWallRotation(); + return; + } + const statusId = statusIds[wallRotationCursor % statusIds.length]; + wallRotationCursor = (wallRotationCursor + 1) % Math.max(statusIds.length, 1); + const record = wallColumnRecords.get(statusId); + if (record) record.pageIndex = (record.pageIndex + 1) % Math.max(record.totalPages, 1); + renderWall({ rotatedStatusId: statusId }); + } + function emitStateChange(reason) { if (!adapters.onStateChange) return; adapters.onStateChange(getState(), reason); @@ -685,7 +1179,7 @@ function taskCardSignature(task, definition) { const selectableDefinitions = sortDefinitions( - state.statusDefinitions.filter((item) => item.enabled || item.id === task.statusId), + 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, @@ -750,13 +1244,14 @@ 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); + statusSelect.setAttribute('aria-label', `移动 ${task.title} 到其他列`); + statusSelect.appendChild(optionNode(doc, '', '移动到…', true)); + 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.appendChild(optionNode(doc, item.id, item.label)); } - statusSelect.value = task.statusId; - statusSelect.disabled = !adapters.send; + statusSelect.value = ''; + statusSelect.disabled = !adapters.send || !selectableDefinitions.length; statusLabel.appendChild(statusSelect); const archive = createNode( @@ -867,6 +1362,11 @@ if (state.destroyed) return; const visibleTasks = sortTasks(state.tasks.filter(matchesFilters)); const definitions = getBoardDefinitions(); + const columnCount = definitions.length; + refs.board.style.setProperty('--task-board-column-count', String(Math.max(columnCount, 1))); + refs.board.dataset.columnCount = String(columnCount); + refs.board.dataset.columnLayout = columnCount <= fitColumnLimit ? 'fit' : 'scroll'; + refs.shell.dataset.searchActive = state.filters.search ? 'true' : 'false'; const animateChanges = options.animate === true && hasRenderedBoard; const oldRects = new Map(); // 数据事件只移动 keyed 节点;更新前后的位置差用于 FLIP,避免整板重绘闪烁。 @@ -987,6 +1487,7 @@ const archiveLabel = state.filters.archived ? '归档任务' : '当前任务'; refs.summary.textContent = `${visibleTasks.length} 个${archiveLabel} · ${definitions.length} 个状态`; if (state.statusManagerOpen) renderStatusManager(); + renderWall({ updatedSessionId: options.wallUpdatedSessionId }); hasRenderedBoard = true; } @@ -1213,6 +1714,9 @@ } function applyData(data, options = {}) { + const previousWallSignatures = new Map( + state.tasks.map((task) => [task.sessionId, wallTaskSignature(task)]), + ); const envelope = collectEnvelope(data); const definitions = findArray(envelope, ['statusDefinitions', 'definitions', 'statuses']); const tasks = findArray(envelope, ['tasks', 'items']); @@ -1235,7 +1739,13 @@ 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 }); + const changedWallTask = wallHasRendered + ? state.tasks + .filter((item) => previousWallSignatures.get(item.sessionId) !== wallTaskSignature(item)) + .filter((item) => !item.archivedAt) + .sort(compareWallTasks)[0] + : null; + renderBoard({ animate: true, wallUpdatedSessionId: changedWallTask?.sessionId || '' }); emitStateChange(options.reason || 'data'); } @@ -1384,13 +1894,16 @@ const select = event.target?.closest?.('[data-action="set-status"]'); if (!select) return; const sessionId = text(select.dataset.sessionId); + const statusId = text(select.value); + if (!sessionId || !statusId) return; const task = state.tasks.find((item) => item.sessionId === sessionId); sendRequest({ type: MESSAGE_TYPES.statusSet, sessionId, - statusId: text(select.value), + statusId, ...(task?.version === null ? {} : { expectedVersion: task?.version }), }, 'task-status'); + select.value = ''; } function onBoardClick(event) { @@ -1456,6 +1969,12 @@ if (state.destroyed) return; state.destroyed = true; if (searchTimer !== null) global.clearTimeout(searchTimer); + if (clockTimer !== null) global.clearTimeout(clockTimer); + clearWallRotation(); + for (const record of wallCardRecords.values()) cancelWallCardAnimation(record); + wallCardRecords.clear(); + wallColumnRecords.clear(); + wallVisibleSessionIds = new Set(); clearBoardRecords(); boardEmpty?.remove(); refs.searchInput.removeEventListener('input', onSearchInput); @@ -1485,6 +2004,7 @@ refs.shell.addEventListener('click', onShellClick); refs.statusBody.addEventListener('submit', onStatusSubmit); doc.addEventListener('keydown', onDocumentKeydown); + scheduleClock(); if (isObject(options.filters)) { state.filters.search = text(options.filters.search || options.filters.query); diff --git a/scripts/regression.js b/scripts/regression.js index 950b6b6..0c15b48 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -541,7 +541,13 @@ function assertFrontendSidebarCollapseContract() { }); assert(source.includes("const SIDEBAR_COLLAPSE_STORAGE_KEY = 'cc-web-sidebar-collapsed'"), 'Desktop sidebar preference should use a stable storage key'); assert(source.includes("const SIDEBAR_DRAWER_MEDIA_QUERY = '(max-width: 768px)'"), 'Sidebar drawer mode should match the mobile CSS breakpoint'); - assert(source.includes("const SIDEBAR_DESKTOP_COLLAPSE_MEDIA_QUERY = '(width > 768px) and (hover: hover) and (pointer: fine)'"), 'Desktop collapse mode should match the hover rail CSS capabilities'); + assert(source.includes("const SIDEBAR_DESKTOP_COLLAPSE_MEDIA_QUERY = '(width > 768px)'"), 'Wide sidebar collapse mode should be available by viewport width'); + assert(source.includes("const SIDEBAR_HOVER_PREVIEW_MEDIA_QUERY = '(width > 768px) and (hover: hover) and (pointer: fine)'"), 'Hover preview should retain the fine-pointer capability gate'); + assert( + source.includes('const sidebarDesktopCollapseMedia = window.matchMedia(SIDEBAR_DESKTOP_COLLAPSE_MEDIA_QUERY)') + && source.includes('const sidebarHoverPreviewMedia = window.matchMedia(SIDEBAR_HOVER_PREVIEW_MEDIA_QUERY)'), + 'Wide fixed collapse and hover preview should use separate media queries' + ); assert(source.includes("app.classList.toggle('sidebar-collapsed', nextCollapsed)"), 'Desktop sidebar should keep its fixed state on the app root'); assert( indexSource.includes('id="user-outline-panel" class="user-outline-panel"') @@ -566,19 +572,35 @@ function assertFrontendSidebarCollapseContract() { 'Sidebar toggle should preserve the mobile drawer path before using desktop fixed collapse' ); assert( - source.includes('[sidebarDrawerMedia, sidebarDesktopCollapseMedia].forEach((media) =>') + source.includes('[sidebarDrawerMedia, sidebarDesktopCollapseMedia, sidebarHoverPreviewMedia].forEach((media) =>') && source.includes("media.addEventListener('change', handleSidebarInputModeChange)"), 'Sidebar should reset transient state when either drawer or desktop collapse mode changes' ); assert(styleSource.includes('--sidebar-rail-width: 14px'), 'Collapsed desktop sidebar should retain a narrow hover rail'); - const desktopCollapseStyleStart = styleSource.indexOf('@media (width > 768px) and (hover: hover) and (pointer: fine)'); - const desktopCollapseStyleEnd = styleSource.indexOf('@media (prefers-reduced-motion: reduce)', desktopCollapseStyleStart); + const desktopCollapseStyleStart = styleSource.indexOf('@media (width > 768px)'); + const hoverPreviewStyleStart = styleSource.indexOf('@media (width > 768px) and (hover: hover) and (pointer: fine)'); + const desktopCollapseStyleEnd = hoverPreviewStyleStart; assert( - desktopCollapseStyleStart >= 0 && desktopCollapseStyleEnd > desktopCollapseStyleStart, - 'Desktop hover behavior should stay isolated from touch and narrow-screen drawers' + desktopCollapseStyleStart >= 0 + && hoverPreviewStyleStart > desktopCollapseStyleStart + && desktopCollapseStyleEnd > desktopCollapseStyleStart, + 'Wide fixed collapse styles should be separate from hover preview styles' ); const desktopCollapseStyle = styleSource.slice(desktopCollapseStyleStart, desktopCollapseStyleEnd); + assert( + /\.menu-btn\s*\{[^}]*display:\s*inline-flex;/.test(desktopCollapseStyle), + 'Wide touch viewports should keep the sidebar toggle visible' + ); + assert( + !/\.app\.sidebar-collapsed \.sidebar:is\(:hover, :focus-within\)/.test(desktopCollapseStyle), + 'Wide fixed collapse should not enable hover preview on touch viewports' + ); + const hoverPreviewStyle = styleSource.slice(hoverPreviewStyleStart, styleSource.indexOf('@media (prefers-reduced-motion: reduce)', hoverPreviewStyleStart)); + assert( + /\.app\.sidebar-collapsed \.sidebar:is\(:hover, :focus-within\)/.test(hoverPreviewStyle), + 'Hover-capable desktops should retain temporary sidebar preview' + ); assert( styleSource.includes('margin-right: calc(var(--sidebar-rail-width) - var(--sidebar-width))') && styleSource.includes('transform: translateX(calc(var(--sidebar-rail-width) - var(--sidebar-width)))'), @@ -641,7 +663,7 @@ function assertFrontendSidebarCollapseContract() { assert( source.includes('if (isSidebarDesktopCollapseMode())') && source.includes("const expanded = isDrawer\n ? sidebar.classList.contains('open')\n : (isDesktopCollapsible ? !app.classList.contains('sidebar-collapsed') : true)"), - 'Unsupported wide-screen pointer modes should stay visually and accessibly expanded' + 'Wide touch viewports should use the fixed collapse state for labels and aria-expanded' ); assert( styleSource.includes("html[data-theme='gilded'] .app.sidebar-collapsed .sidebar::after") @@ -895,7 +917,8 @@ function assertComposerSlashRoutingContract() { assert(source.includes('if (!msg.preserveComposerDraft) return false;'), 'Frontend should restore slash drafts only for explicit failure responses'); assert(source.includes("if (msgInput.value !== '' || pendingAttachments.length > 0) return false;"), 'Frontend should not overwrite user input typed after a slash request'); assert(source.includes('rememberPendingSlashDraft(requestId, text, currentSessionId, currentAgent);'), 'Frontend should remember slash draft before clearing the composer'); - assert(source.includes("send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent, requestId });"), 'Frontend slash sends should carry requestId'); + assert(source.includes("const clientMessageId = isGoalSetCommandText(text) ? createLocalId('user') : '';"), 'Frontend should allocate a stable user-message id for setting /goal'); + assert(source.includes('...(clientMessageId ? { clientMessageId } : {}),'), 'Frontend should send the Goal user-message id without changing other slash payloads'); assert((source.match(/applyPendingSlashDraftResponse\(msg\);/g) || []).length >= 2, 'Frontend should handle slash draft restoration on system_message and error'); const frontendCommandsMatch = source.match(/const\s+SLASH_COMMANDS\s*=\s*\[([\s\S]*?)\n\s*\];/); @@ -919,6 +942,15 @@ function assertComposerSlashRoutingContract() { }); assert(!knownSlashApi('/report/mcps?search') && !knownSlashApi('/help/topic'), 'Unknown slash paths should remain ordinary messages'); + const goalSetSource = extractFunctionSource(source, 'isGoalSetCommandText'); + const goalSetApi = new Function(` + ${goalSetSource} + return isGoalSetCommandText; + `)(); + assert(goalSetApi('/goal improve benchmark coverage'), 'Frontend should recognize setting /goal as a Goal bubble command'); + assert(goalSetApi('/GOAL\nlong objective'), 'Frontend should recognize multiline and case-insensitive Goal objectives'); + assert(!goalSetApi('/goal') && !goalSetApi('/goal pause') && !goalSetApi('/goal resume') && !goalSetApi('/goal clear'), 'Frontend should keep Goal control commands out of user bubbles'); + const tokenSource = extractFunctionSource(source, 'findActiveComposerToken'); const tokenApi = new Function(` let msgInput = null; @@ -7032,17 +7064,40 @@ async function main() { assert(codexAppRetryMismatchUsers.length === 1, 'Codex App retry mismatch should not duplicate the user message'); assert(!storedCodexAppAfterRetryMismatch.messages.some((message) => message.role === 'assistant' && /codexapp retry thread mismatch prompt/.test(String(message.content || ''))), 'Codex App retry mismatch should not persist a successful assistant response on the wrong thread'); - ws.send(JSON.stringify({ type: 'message', text: '/goal improve benchmark coverage', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); + const codexAppGoalRequestId = 'regression-goal-request'; + const codexAppGoalMessageId = 'regression-goal-user-message'; + ws.send(JSON.stringify({ + type: 'message', + text: '/goal improve benchmark coverage', + sessionId: codexAppSession.sessionId, + mode: 'yolo', + agent: 'codexapp', + requestId: codexAppGoalRequestId, + clientMessageId: codexAppGoalMessageId, + })); + const codexAppGoalBubble = await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_message' && + msg.sessionId === codexAppSession.sessionId && + msg.message?.id === codexAppGoalMessageId + ), 5000); + assert(codexAppGoalBubble.message.role === 'user', 'Setting /goal should render through the normal user-message role'); + assert(codexAppGoalBubble.message.content === 'improve benchmark coverage', 'Goal user bubble should contain only the objective'); + assert(codexAppGoalBubble.message.ccwebDisplayOnly === true, 'Goal user bubble should be display-only local history'); + assert(codexAppGoalBubble.message.ccwebGoalCommand?.action === 'set', 'Goal user bubble should carry dedicated Goal metadata'); + assert(codexAppGoalBubble.message.ccwebGoalCommand?.requestId === codexAppGoalRequestId, 'Goal metadata should preserve the stable request id'); const codexAppGoalSyncing = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /正在同步 Goal/.test(msg.message || ''), 5000); assert(/正在同步 Goal/.test(codexAppGoalSyncing.message || ''), 'Codex App /goal should immediately show a syncing notice'); + assert(codexAppGoalSyncing.transient === true, 'Codex App /goal syncing notice should auto-dismiss'); const codexAppGoalRunningList = await nextMessage(messages, ws, (msg) => ( msg.type === 'session_list' && Array.isArray(msg.sessions) && msg.sessions.some((session) => session.id === codexAppSession.sessionId && session.isRunning) ), 5000); assert(codexAppGoalRunningList.sessions.some((session) => session.id === codexAppSession.sessionId && session.isRunning), 'Codex App /goal RPC should mark the session running while waiting for app-server'); - const codexAppGoalSet = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || '') && /improve benchmark coverage/.test(msg.message || '')); + const codexAppGoalSet = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || '')); assert(/Goal active/.test(codexAppGoalSet.message || ''), 'Codex App /goal should set an active goal'); + assert(!/improve benchmark coverage/.test(codexAppGoalSet.message || ''), 'Goal success notice should not repeat the full objective'); + assert(codexAppGoalSet.transient === true, 'Goal success notice should auto-dismiss'); const codexAppGoalBackgroundDelta = await nextMessage(messages, ws, (msg) => ( msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && @@ -7056,6 +7111,24 @@ async function main() { msg.sessions.some((session) => session.id === codexAppSession.sessionId && !session.isRunning) ), 5000); assert(codexAppGoalIdleList.sessions.some((session) => session.id === codexAppSession.sessionId && !session.isRunning), 'Codex App /goal RPC should clear running state after app-server responds'); + ws.send(JSON.stringify({ + type: 'message', + text: '/goal improve benchmark coverage', + sessionId: codexAppSession.sessionId, + mode: 'yolo', + agent: 'codexapp', + requestId: codexAppGoalRequestId, + clientMessageId: codexAppGoalMessageId, + })); + const codexAppGoalDuplicate = await nextMessage(messages, ws, (msg) => ( + msg.type === 'system_message' && + msg.sessionId === codexAppSession.sessionId && + /Goal 设置请求已处理/.test(msg.message || '') + ), 5000); + assert(codexAppGoalDuplicate.transient === true, 'Duplicate Goal command ids should receive a compact transient acknowledgement'); + await sleep(250); + assert(!messages.some((msg) => msg.type === 'session_message' && msg.message?.id === codexAppGoalMessageId), 'Duplicate Goal command ids should not append another user bubble'); + assert(!messages.some((msg) => msg.type === 'text_delta' && /Goal background output: improve benchmark coverage/.test(msg.text || '')), 'Duplicate Goal command ids should not trigger another Goal RPC background turn'); ws.send(JSON.stringify({ type: 'message', text: '/goal', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); const codexAppGoalShow = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || '') && /improve benchmark coverage/.test(msg.message || '')); assert(/improve benchmark coverage/.test(codexAppGoalShow.message || ''), 'Codex App /goal should show the current goal'); @@ -7072,7 +7145,11 @@ async function main() { const codexAppGoalEmpty = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /用法: \/goal <目标描述>/.test(msg.message || '')); assert(/\/goal <目标描述>/.test(codexAppGoalEmpty.message || ''), 'Codex App /goal should show usage when no goal exists'); const storedCodexAppAfterGoal = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8')); - assert(!storedCodexAppAfterGoal.messages.some((message) => message.role === 'user' && /^\/goal/.test(String(message.content || ''))), 'Codex App /goal slash commands should not be persisted as normal user messages'); + const storedGoalMessages = storedCodexAppAfterGoal.messages.filter((message) => message.id === codexAppGoalMessageId); + assert(storedGoalMessages.length === 1, 'Codex App should persist exactly one Goal user bubble for a stable command id'); + assert(storedGoalMessages[0].role === 'user' && storedGoalMessages[0].content === 'improve benchmark coverage', 'Persisted Goal bubble should restore as the objective-only user message'); + assert(storedGoalMessages[0].ccwebDisplayOnly === true && storedGoalMessages[0].ccwebGoalCommand?.action === 'set', 'Persisted Goal bubble should remain marked as display-only Goal history'); + assert(!storedCodexAppAfterGoal.messages.some((message) => message.role === 'user' && /^\/goal/.test(String(message.content || ''))), 'Raw /goal commands should never enter ordinary user-message history'); assert(storedCodexAppAfterGoal.messages.some((message) => ( message.role === 'assistant' && /Goal background output: improve benchmark coverage/.test(String(message.content || '')) diff --git a/scripts/task-board-frontend-unit.js b/scripts/task-board-frontend-unit.js index 0639f12..ff9815e 100644 --- a/scripts/task-board-frontend-unit.js +++ b/scripts/task-board-frontend-unit.js @@ -291,6 +291,23 @@ class FakeElement { } getBoundingClientRect() { + const wallColumn = this.closest('.ccweb-task-board__wall-column'); + if (wallColumn) { + const wallGrid = wallColumn.parentNode; + const cells = wallGrid?.children?.filter((node) => ( + node.classList.contains('ccweb-task-board__clock') + || node.classList.contains('ccweb-task-board__wall-column') + )) || []; + const cellIndex = Math.max(0, cells.indexOf(wallColumn)); + const columnCount = Number(wallGrid?.style?.getPropertyValue('--task-board-wall-columns')) || 1; + const wallCards = this.parentNode?.children?.filter((node) => node.classList.contains('ccweb-task-board__wall-card')) || []; + const wallCardIndex = Math.max(0, wallCards.indexOf(this)); + const left = 8 + ((cellIndex % columnCount) * 288); + const top = 8 + (Math.floor(cellIndex / columnCount) * 210) + 42 + (wallCardIndex * 78); + const width = 270; + const height = 70; + return { x: left, y: top, left, top, right: left + width, bottom: top + height, width, height }; + } const column = this.closest('.ccweb-task-board__column'); const lanes = column?.parentNode; const columns = lanes?.children?.filter((node) => node.classList.contains('ccweb-task-board__column')) || []; @@ -440,6 +457,10 @@ async function flush() { await new Promise((resolve) => setTimeout(resolve, 0)); } +async function wait(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + async function main() { const source = fs.readFileSync(SCRIPT_PATH, 'utf8'); const css = fs.readFileSync(STYLE_PATH, 'utf8'); @@ -464,6 +485,77 @@ async function main() { assert(css.includes('.ccweb-task-board__lanes'), 'CSS 应包含横向看板工作区'); assert(/overflow-x:\s*auto/.test(css), '看板应允许横向滚动'); assert(/grid-auto-columns:/.test(css), '看板列数和宽度应动态生成'); + assert(source.includes("'--task-board-column-count'"), '看板应把服务端动态列数暴露给响应式布局'); + assert(css.includes('@media (orientation: landscape) and (max-height: 768px)'), 'CSS 应覆盖桌面模式浏览器下的手机横屏高度'); + assert( + /@media \(orientation: landscape\) and \(max-height: 768px\)[\s\S]*?\.task-board-panel\s*\{[^}]*position:\s*fixed;[^}]*inset:\s*0;/.test(css), + '横屏看板必须覆盖整个网页视口而不是留在聊天内容区', + ); + assert( + /@media \(orientation: landscape\) and \(max-height: 768px\)[\s\S]*?\.task-board-panel\s*\{[^}]*height:\s*100vh;[^}]*height:\s*100dvh;/.test(css), + '横屏看板必须为旧浏览器提供 100vh 回退并优先使用动态视口高度', + ); + assert( + /@media \(orientation: landscape\) and \(max-height: 768px\)[\s\S]*?\.task-board-panel__root\s*\{[^}]*height:\s*100vh;[^}]*height:\s*100dvh;/.test(css), + '横屏挂载根必须把完整视口高度继续传给看板矩阵', + ); + assert( + /@media \(orientation: landscape\) and \(max-height: 768px\)[\s\S]*?\.ccweb-task-board\s*\{[^}]*grid-template-rows:\s*minmax\(0,\s*1fr\);/.test(css), + '横屏隐藏工具栏后根网格必须只保留一行,不能留下空白的第二轨道', + ); + assert( + /@media \(orientation: landscape\) and \(max-height: 768px\)[\s\S]*?\.task-board-panel__header\s*\{[^}]*position:\s*absolute;/.test(css), + '横屏返回按钮必须叠入看板顶带而不是单占一行', + ); + assert( + /@media \(orientation: landscape\) and \(max-height: 768px\)[\s\S]*?\.ccweb-task-board__toolbar\s*\{[^}]*display:\s*none;/.test(css), + '横屏只读看板不得保留任务看板工具条', + ); + assert( + css.includes('.ccweb-task-board__clock-time') && css.includes('.ccweb-task-board__clock-date'), + '数字时钟应具有明确的时间与日期层级', + ); + assert( + /@media \(orientation: landscape\) and \(max-height: 768px\)[\s\S]*?\.ccweb-task-board__lanes\s*\{[^}]*display:\s*none;/.test(css), + '横屏展示不得暴露可操作的传统看板列', + ); + assert( + /@media \(orientation: landscape\) and \(max-height: 768px\)[\s\S]*?\.ccweb-task-board__wall-grid\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*repeat\(var\(--task-board-wall-columns\),\s*minmax\(0,\s*1fr\)\);[^}]*grid-template-rows:\s*repeat\(var\(--task-board-wall-rows\),\s*minmax\(0,\s*1fr\)\);/.test(css), + '横屏看板应把时钟格与状态格排成动态矩阵', + ); + assert( + css.includes('.ccweb-task-board__wall-column') + && css.includes('.ccweb-task-board__wall-card-title') + && css.includes('.ccweb-task-board__wall-card-summary'), + '横屏看板应具有动态状态格和多任务卡层级', + ); + assert(source.includes('wallRotationMs') && source.includes('wallUpdateHoldMs'), '状态格应提供列内轮换与更新延长停留时长'); + assert(!source.includes('ccweb-task-board__wall-focus'), '横屏看板不得再渲染单任务聚焦舞台'); + assert(!source.includes('ccweb-task-board__wall-stage'), '横屏看板不得再保留巨幅单任务舞台'); + for (const animationId of [ + 'ccweb-task-board-wall-card-page-leave', + 'ccweb-task-board-wall-card-page-enter', + 'ccweb-task-board-wall-card-move', + 'ccweb-task-board-wall-card-update', + ]) { + assert(source.includes(animationId), `横屏看板缺少卡片动效 ${animationId}`); + } + assert( + /\.ccweb-task-board__wall-card-summary\s*\{[^}]*white-space:\s*normal;[^}]*\}/.test(css), + '横屏任务摘要应在卡片内自然换行', + ); + assert( + !/\.ccweb-task-board__wall-card-(?:title|summary)\s*\{[^}]*-webkit-line-clamp:/.test(css), + '横屏任务标题和摘要不得使用行数截断', + ); + assert( + /\.ccweb-task-board__clock\s*\{[^}]*background:\s*var\(--bg-secondary\);/.test(css), + '时钟应作为矩阵中的普通主题格而不是巨型侧栏', + ); + assert( + /body:has\(\.task-board-panel:not\(\[hidden\]\)\)\s+\.menu-btn\s*\{[^}]*display:\s*none/.test(css), + '横屏看板打开时应隐藏宿主侧栏浮动菜单按钮', + ); assert(css.includes(':focus-visible'), 'CSS 应提供键盘焦点样式'); assert(css.includes('@media (prefers-reduced-motion: reduce)'), 'CSS 应尊重减少动态效果偏好'); assert( @@ -639,6 +731,35 @@ async function main() { '状态列应按服务端 order 排序', ); const lanesNode = byClass(root, 'ccweb-task-board__lanes')[0]; + assert.strictEqual(lanesNode.style.getPropertyValue('--task-board-column-count'), '4', '横屏布局列数应跟随服务端定义'); + assert.strictEqual(lanesNode.style.getPropertyValue('--task-board-grid-columns'), '', '看板不得再计算宫格列数'); + assert.strictEqual(lanesNode.dataset.columnCount, '4', 'CSS 应能读取真实泳道数量'); + assert.strictEqual(lanesNode.dataset.columnLayout, 'fit', '常规动态列数应使用整屏均分布局'); + const wall = byClass(root, 'ccweb-task-board__wall')[0]; + assert(wall, '独立看板应同时提供横屏只读看板'); + const wallGrid = byClass(wall, 'ccweb-task-board__wall-grid')[0]; + assert(wallGrid, '横屏看板应提供时钟与状态格共用的矩阵'); + const clock = byClass(root, 'ccweb-task-board__clock')[0]; + assert(clock, '横屏看板应提供数字时钟'); + assert.strictEqual(clock.parentNode, wallGrid, '数字时钟应直接占据矩阵中的一格'); + assert(/^\d{2}:\d{2}$/.test(byClass(clock, 'ccweb-task-board__clock-time')[0].textContent), '时钟应显示 24 小时制时分'); + assert(/\d{1,2}月\d{1,2}日/.test(byClass(clock, 'ccweb-task-board__clock-date')[0].textContent), '时钟应显示月日'); + assert.strictEqual(byClass(wall, 'ccweb-task-board__wall-column').length, definitions.length, '状态格必须由服务端定义动态生成'); + assert.strictEqual(wallGrid.style.getPropertyValue('--task-board-wall-columns'), '3', '五个矩阵格应排成三列两行'); + assert.strictEqual(wallGrid.style.getPropertyValue('--task-board-wall-rows'), '2', '五个矩阵格应排成三列两行'); + assert.strictEqual(byClass(wall, 'ccweb-task-board__wall-card').length, 3, '横屏看板应同时显示多个状态中的任务卡'); + assert.strictEqual( + walk(wall).filter((node) => ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'].includes(node.tagName)).length, + 0, + '横屏看板必须完全只读', + ); + const runningWallCard = byClass(wall, 'ccweb-task-board__wall-card') + .find((card) => card.dataset.sessionId === 'session-one'); + assert(runningWallCard, '运行任务应与其他任务一起出现在所属状态格'); + assert.strictEqual(runningWallCard.closest('.ccweb-task-board__wall-column').dataset.statusId, 'queue-x'); + assert(byClass(runningWallCard, 'ccweb-task-board__wall-card-title')[0].textContent.includes('定位协议边界'), '任务格应显示完整任务标题文本'); + assert.strictEqual(walk(runningWallCard).some((node) => node.tagName === 'IMG'), false, '横屏看板也不得把任务标题解析成 HTML'); + assert.strictEqual(byClass(wall, 'ccweb-task-board__wall-focus').length, 0, '横屏看板不得退化成单任务聚焦屏'); const originalColumns = new Map( byClass(root, 'ccweb-task-board__column').map((column) => [column.dataset.statusId, column]), ); @@ -666,7 +787,9 @@ async function main() { assert.strictEqual(firstCard.textContent.includes('42%'), false, '任务卡不得把历史百分比渲染为文案'); assert.strictEqual(firstCard.textContent.includes('任务:入口'), false, '卡片不得重复显示只读业务状态'); assert.strictEqual(byClass(firstCard, 'ccweb-task-board__business-state').length, 0, '卡片不得渲染业务状态胶囊'); - assert.strictEqual(byAction(firstCard, 'set-status')[0].value, 'queue-x', '状态选择器应作为业务状态的唯一卡片入口'); + assert.strictEqual(byAction(firstCard, 'set-status')[0].value, '', '移动控件不得重复显示当前所在列'); + assert.strictEqual(byAction(firstCard, 'set-status')[0].textContent.includes('入口'), false, '移动选项不得再次列出当前状态'); + assert(byAction(firstCard, 'set-status')[0].textContent.includes('移动到…'), '状态入口应明确表达移动动作'); assert(firstCard.textContent.includes('运行:运行中'), '卡片应独立显示运行态'); assert.strictEqual(firstTaskState.isRunning, true, '前端公开任务状态应保留服务端明确下发的运行布尔值'); assert.strictEqual(firstCard.dataset.runtimeState, 'running', '运行卡片应暴露稳定的 data-runtime-state 契约'); @@ -689,7 +812,7 @@ async function main() { board.setFilters({ archived: true }); assert.strictEqual(byClass(root, 'ccweb-task-board__card').length, 1, '归档视图应只显示软归档任务'); const archivedCard = byClass(root, 'ccweb-task-board__card')[0]; - assert.strictEqual(byAction(archivedCard, 'set-status')[0].value, 'queue-x', '归档不应改写任务业务状态'); + assert.strictEqual(byAction(archivedCard, 'set-status')[0].value, '', '归档任务的移动控件也不得重复当前状态'); board.setFilters({ archived: false, search: '' }); const activeFirstCard = byClass(root, 'ccweb-task-board__card').find((card) => card.dataset.sessionId === 'session-one'); @@ -707,6 +830,7 @@ async function main() { assert.strictEqual(Object.prototype.hasOwnProperty.call(statusRequest, 'progress'), false, '状态写请求不得发送百分比进度'); assert.strictEqual(statusRequest.expectedVersion, 5, '写请求应携带期望任务版本'); assert(statusRequest.requestId, '写请求应携带 requestId'); + assert.strictEqual(statusSelect.value, '', '提交移动后应恢复为紧凑动作提示'); const archiveButton = byAction(activeFirstCard, 'set-archive')[0]; emit(archiveButton, 'click'); @@ -747,6 +871,9 @@ async function main() { assert.strictEqual(byClass(root, 'ccweb-task-board__toolbar')[0].hasAttribute('inert'), false, '关闭后应解除 inert'); const moveAnimationCount = activeFirstCard.animations.length; + const activeWallCard = byClass(root, 'ccweb-task-board__wall-card') + .find((card) => card.dataset.sessionId === 'session-one'); + const wallMoveAnimationCount = activeWallCard.animations.length; statusSelect.focus(); board.handleMessage({ type: 'task_board_event', @@ -776,6 +903,15 @@ async function main() { const movedStatusSelect = byAction(movedFirstCard, 'set-status')[0]; assert.strictEqual(movedFirstCard, activeFirstCard, '跨列更新必须移动原卡片节点,而非销毁后新建'); assert.strictEqual(movedFirstCard.parentNode.parentNode.dataset.statusId, 'review-custom', '卡片应进入服务端指定状态列'); + const movedWallCard = byClass(root, 'ccweb-task-board__wall-card') + .find((card) => card.dataset.sessionId === 'session-one'); + assert.strictEqual(movedWallCard, activeWallCard, '横屏看板也必须复用并移动原只读任务卡'); + assert.strictEqual(movedWallCard.closest('.ccweb-task-board__wall-column').dataset.statusId, 'review-custom'); + assert( + movedWallCard.animations.slice(wallMoveAnimationCount) + .some((animation) => animation.options.id === 'ccweb-task-board-wall-card-move'), + '横屏任务卡跨状态格时应触发可见的 FLIP 位移动画', + ); assert.strictEqual(document.activeElement, movedStatusSelect, '卡片内容更新后应把键盘焦点恢复到状态控件'); assert.strictEqual(byClass(root, 'ccweb-task-board__lanes')[0], lanesNode, '数据事件不得替换看板滚动节点'); for (const [statusId, originalColumn] of originalColumns) { @@ -891,20 +1027,139 @@ async function main() { }); assert.strictEqual(board.getState().tasks.length, 1, '最新查询结果应替换当前快照'); + const rotatingRoot = document.createElement('div'); + document.body.appendChild(rotatingRoot); + const rotatingBoard = api.mount(rotatingRoot, { + document, + autoLoad: false, + wallRotationMs: 50, + wallUpdateHoldMs: 140, + wallCardsPerPage: 2, + initialData: { + statusDefinitions: definitions, + tasks: [ + { + sessionId: 'wall-running', + title: '正在执行的任务', + summary: '这是一段应在状态格内完整换行展示的任务摘要。', + isRunning: true, + runtimeState: '运行中', + updatedAt: '2026-08-11T06:00:00.000Z', + taskTracking: { enabled: true, statusId: 'doing-z', version: 1 }, + }, + { + sessionId: 'wall-newer', + title: '同列最近更新的任务', + updatedAt: '2026-08-11T07:00:00.000Z', + taskTracking: { enabled: true, statusId: 'doing-z', version: 1 }, + }, + { + sessionId: 'wall-old', + title: '同列较早任务', + updatedAt: '2026-08-11T05:00:00.000Z', + taskTracking: { enabled: true, statusId: 'doing-z', version: 1 }, + }, + { + sessionId: 'wall-review', + title: '另一状态中的任务', + updatedAt: '2026-08-11T04:00:00.000Z', + taskTracking: { enabled: true, statusId: 'review-custom', version: 1 }, + }, + ], + }, + }); + const rotatingWall = byClass(rotatingRoot, 'ccweb-task-board__wall')[0]; + const doingWallColumn = byClass(rotatingWall, 'ccweb-task-board__wall-column') + .find((column) => column.dataset.statusId === 'doing-z'); + const reviewWallColumn = byClass(rotatingWall, 'ccweb-task-board__wall-column') + .find((column) => column.dataset.statusId === 'review-custom'); + const initialDoingCards = byClass(doingWallColumn, 'ccweb-task-board__wall-card'); + assert.deepStrictEqual( + initialDoingCards.map((card) => card.dataset.sessionId), + ['wall-running', 'wall-newer'], + '状态格第一页应同时显示多张任务卡,并优先显示运行与最近更新任务', + ); + assert.strictEqual( + byClass(initialDoingCards[0], 'ccweb-task-board__wall-card-summary')[0].textContent, + '这是一段应在状态格内完整换行展示的任务摘要。', + '横屏任务卡必须保留完整摘要文本', + ); + const stableReviewCard = byClass(reviewWallColumn, 'ccweb-task-board__wall-card')[0]; + await wait(70); + const rotatedDoingCards = byClass(doingWallColumn, 'ccweb-task-board__wall-card'); + assert.deepStrictEqual( + rotatedDoingCards.map((card) => card.dataset.sessionId), + ['wall-old'], + '溢出时只应轮换当前状态格内的下一页任务', + ); + assert.strictEqual(byClass(reviewWallColumn, 'ccweb-task-board__wall-card')[0], stableReviewCard, '列内轮换不得替换其他状态格的任务'); + assert( + initialDoingCards.some((card) => card.animations.some((animation) => animation.options.id === 'ccweb-task-board-wall-card-page-leave')), + '列内换页应让旧页卡片收缩离场', + ); + assert( + rotatedDoingCards[0].animations.some((animation) => animation.options.id === 'ccweb-task-board-wall-card-page-enter'), + '列内换页应让新页卡片展开进入', + ); + assert.strictEqual(byClass(doingWallColumn, 'ccweb-task-board__wall-column-page')[0].textContent, '2 / 2', '溢出状态格应显示当前页码'); + rotatingBoard.handleMessage({ + type: 'task_board_event', + task: { + sessionId: 'wall-running', + title: '刚刚更新的任务', + isRunning: true, + runtimeState: '运行中', + updatedAt: '2026-08-11T08:00:00.000Z', + statusId: 'doing-z', + trackingEnabled: true, + version: 2, + }, + }); + const updatedWallCard = byClass(doingWallColumn, 'ccweb-task-board__wall-card') + .find((card) => card.dataset.sessionId === 'wall-running'); + assert(updatedWallCard, '任务更新后必须立即切回并显示该任务所在页'); + assert.strictEqual(byClass(updatedWallCard, 'ccweb-task-board__wall-card-title')[0].textContent, '刚刚更新的任务'); + assert( + updatedWallCard.animations.some((animation) => animation.options.id === 'ccweb-task-board-wall-card-update'), + '更新任务应只在所属状态格内触发强调动画', + ); + await wait(80); + assert(byClass(doingWallColumn, 'ccweb-task-board__wall-card').some((card) => card.dataset.sessionId === 'wall-running'), '更新任务所在页应获得更长停留时间'); + await wait(85); + assert.deepStrictEqual( + byClass(doingWallColumn, 'ccweb-task-board__wall-card').map((card) => card.dataset.sessionId), + ['wall-old'], + '延长停留结束后应恢复该状态格的列内轮换', + ); + rotatingBoard.destroy(); + const reducedEnvironment = loadTaskBoard(source, { reducedMotion: true }); const reducedRoot = reducedEnvironment.document.createElement('div'); reducedEnvironment.document.body.appendChild(reducedRoot); const reducedBoard = reducedEnvironment.api.mount(reducedRoot, { document: reducedEnvironment.document, autoLoad: false, - initialData: { statusDefinitions: definitions, tasks: [tasks[0]] }, + wallRotationMs: 30, + wallCardsPerPage: 1, + initialData: { statusDefinitions: definitions, tasks: [tasks[0], tasks[1], tasks[2]] }, }); - const reducedCard = byClass(reducedRoot, 'ccweb-task-board__card')[0]; + const reducedWallColumn = byClass(reducedRoot, 'ccweb-task-board__wall-column') + .find((column) => column.dataset.statusId === 'queue-x'); + const reducedWallSessionId = byClass(reducedWallColumn, 'ccweb-task-board__wall-card')[0]?.dataset.sessionId; + await wait(45); + assert.strictEqual( + byClass(reducedWallColumn, 'ccweb-task-board__wall-card')[0]?.dataset.sessionId, + reducedWallSessionId, + '减少动态偏好下应停止状态格自动换页', + ); + const reducedCard = byClass(reducedRoot, 'ccweb-task-board__card') + .find((card) => card.dataset.sessionId === 'session-one'); reducedBoard.handleMessage({ type: 'task_board_event', task: { sessionId: 'session-one', statusId: 'review-custom', trackingEnabled: true, version: 6 }, }); - const reducedMovedCard = byClass(reducedRoot, 'ccweb-task-board__card')[0]; + const reducedMovedCard = byClass(reducedRoot, 'ccweb-task-board__card') + .find((card) => card.dataset.sessionId === 'session-one'); assert.strictEqual(reducedMovedCard, reducedCard, '减少动态效果时仍应复用并移动原卡片节点'); assert.strictEqual(reducedMovedCard.parentNode.parentNode.dataset.statusId, 'review-custom'); assert.strictEqual(reducedMovedCard.animations.length, 0, '减少动态效果偏好下不得启动脚本动画'); @@ -918,8 +1173,39 @@ async function main() { initialData: { statusDefinitions: definitions.slice(0, 1), tasks: [] }, }); assert.strictEqual(secondBoard.getState().statusDefinitions.length, 1, '不同挂载实例应保持状态隔离'); + assert.strictEqual( + byClass(secondRoot, 'ccweb-task-board__lanes')[0].style.getPropertyValue('--task-board-column-count'), + '1', + '不同挂载实例应独立维护响应式列数', + ); secondBoard.destroy(); + const crowdedRoot = document.createElement('div'); + document.body.appendChild(crowdedRoot); + const crowdedDefinitions = Array.from({ length: 7 }, (_, index) => ({ + id: `crowded-${index}`, + label: `状态 ${index + 1}`, + prompt: `归入状态 ${index + 1} 的任务。`, + order: index, + enabled: true, + })); + const crowdedBoard = api.mount(crowdedRoot, { + document, + autoLoad: false, + initialData: { statusDefinitions: crowdedDefinitions, tasks: [] }, + }); + assert.strictEqual( + byClass(crowdedRoot, 'ccweb-task-board__lanes')[0].dataset.columnLayout, + 'scroll', + '超过六列时应降级为可读的横向滚动布局', + ); + assert.strictEqual( + byClass(crowdedRoot, 'ccweb-task-board__lanes')[0].style.getPropertyValue('--task-board-grid-columns'), + '', + '七列降级布局仍不得拆成上下两排', + ); + crowdedBoard.destroy(); + const openedBeforeDestroy = opened.length; board.destroy(); emit(openButton, 'click'); diff --git a/server.js b/server.js index cef965d..45d9b08 100644 --- a/server.js +++ b/server.js @@ -7961,7 +7961,7 @@ function formatCodexGoalStatus(status) { return normalized || 'updated'; } -function formatCodexGoalUsage(goal) { +function formatCodexGoalUsage(goal, options = {}) { const normalized = normalizeCodexThreadGoal(goal); if (!normalized) return 'Goal updated'; const parts = [`Goal ${formatCodexGoalStatus(normalized.status)}`]; @@ -7970,10 +7970,45 @@ function formatCodexGoalUsage(goal) { } else if (normalized.tokensUsed > 0) { parts.push(`${normalized.tokensUsed} tokens`); } - const objective = normalized.objective ? `\n目标: ${normalized.objective}` : ''; + const objective = options.includeObjective === false || !normalized.objective + ? '' + : `\n目标: ${normalized.objective}`; return `${parts.join(' · ')}${objective}`; } +function persistCodexAppGoalDisplayMessage(session, command, source = {}) { + if (!session || command?.action !== 'set' || !command.objective) { + return { message: null, created: false, duplicate: false }; + } + const messageId = goalString(source.clientMessageId || source.requestId) || crypto.randomUUID(); + session.messages = Array.isArray(session.messages) ? session.messages : []; + const existing = session.messages.find((message) => message?.id === messageId); + if (existing) { + if (existing.ccwebGoalCommand?.action === 'set') { + return { message: existing, created: false, duplicate: true }; + } + throw new Error(`Goal 展示消息 ID 冲突: ${messageId}`); + } + + const timestamp = new Date().toISOString(); + const message = { + id: messageId, + role: 'user', + content: command.objective, + attachments: [], + timestamp, + ccwebDisplayOnly: true, + ccwebGoalCommand: { + action: 'set', + requestId: goalString(source.requestId) || messageId, + }, + }; + session.messages.push(message); + session.updated = timestamp; + saveSession(session); + return { message, created: true, duplicate: false }; +} + async function ensureCodexAppGoalThread(session) { const clientResult = getCodexAppClient(); if (clientResult.error) throw new Error(clientResult.error); @@ -8061,15 +8096,47 @@ async function handleCodexAppGoalSlashCommand(ws, text, session, source = {}) { return; } + let goalDisplayMessage = null; + if (command.action === 'set') { + try { + const persisted = persistCodexAppGoalDisplayMessage(session, command, source); + if (persisted.duplicate) { + sendGoalSystemMessage(ws, 'Goal 设置请求已处理。', { + sessionId: session.id, + tone: 'info', + transient: true, + autoDismissMs: 4000, + }); + sendSessionList(ws); + return; + } + goalDisplayMessage = persisted.message; + sendGoalResponse(ws, { + type: 'session_message', + sessionId: session.id, + message: goalDisplayMessage, + }); + } catch (err) { + sendGoalSystemMessage(ws, `Goal failed: ${err?.message || err}`, { sessionId: session.id }, { preserveComposerDraft: true }); + return; + } + } + const activeGoalCommand = { id: crypto.randomUUID(), ws, action: command.action, + goalMessageId: goalDisplayMessage?.id || null, cancelled: false, startedAt: new Date().toISOString(), }; activeCodexAppGoalCommands.set(session.id, activeGoalCommand); - sendGoalSystemMessage(ws, '正在同步 Goal...', { sessionId: session.id }); + sendGoalSystemMessage(ws, '正在同步 Goal...', { + sessionId: session.id, + tone: 'info', + transient: true, + autoDismissMs: 5000, + }); broadcastSessionList(); try { @@ -8089,7 +8156,12 @@ async function handleCodexAppGoalSlashCommand(ws, text, session, source = {}) { const response = await client.request('thread/goal/clear', { threadId }, 30000); if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return; const targetWs = activeGoalCommand.ws || ws; - sendGoalSystemMessage(targetWs, response?.cleared ? 'Goal cleared' : 'No goal to clear', { sessionId: session.id }); + sendGoalSystemMessage(targetWs, response?.cleared ? 'Goal cleared' : 'No goal to clear', { + sessionId: session.id, + tone: 'info', + transient: true, + autoDismissMs: 4000, + }); sendSessionList(targetWs); return; } @@ -8102,14 +8174,24 @@ async function handleCodexAppGoalSlashCommand(ws, text, session, source = {}) { if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return; const goal = normalizeCodexThreadGoal(response?.goal, threadId); const targetWs = activeGoalCommand.ws || ws; - sendGoalSystemMessage(targetWs, goal ? formatCodexGoalUsage(goal) : 'Goal updated', { sessionId: session.id }); + sendGoalSystemMessage(targetWs, goal ? formatCodexGoalUsage(goal, { includeObjective: false }) : 'Goal updated', { + sessionId: session.id, + tone: 'info', + transient: true, + autoDismissMs: 5000, + }); sendSessionList(targetWs); } catch (err) { const message = isCodexGoalUnsupportedError(err) ? '当前 Codex app-server 不支持 /goal,请升级 Codex 或启用 goals feature。' : `Goal failed: ${err?.message || err}`; if (isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) { - sendGoalSystemMessage(activeGoalCommand.ws || ws, message, { sessionId: session.id }, { preserveComposerDraft: true }); + sendGoalSystemMessage(activeGoalCommand.ws || ws, message, { + sessionId: session.id, + tone: 'danger', + transient: true, + autoDismissMs: 7000, + }, { preserveComposerDraft: !goalDisplayMessage }); } } finally { finishCodexAppGoalCommand(session.id, activeGoalCommand); diff --git a/修复左侧树折叠按钮 TO DO list.csv b/修复左侧树折叠按钮 TO DO list.csv new file mode 100644 index 0000000..817ace6 --- /dev/null +++ b/修复左侧树折叠按钮 TO DO list.csv @@ -0,0 +1,8 @@ +id,item,status,done_at,notes +1,记录现状并复现左侧栏折叠入口缺失,DONE,2026-08-16T21:27:17+08:00,截图与工作树基线已记录,计划审查通过 +2,定位侧栏折叠状态、控件与主题样式链路,DONE,2026-08-16T21:34:00+08:00,已确认宽触控设备错过 drawer 与 desktop-collapse 两种媒体条件 +3,补充折叠入口可见性与交互回归断言,DONE,2026-08-16T21:42:06+08:00,已补充专项断言并完成宽触控折叠实现 +4,恢复折叠按钮并限定响应式与主题样式,DONE,2026-08-16T21:42:06+08:00,已将宽屏固定折叠与鼠标 hover 预览样式隔离 +5,运行语法检查与相关自动化回归,DONE,2026-08-16T21:57:44+08:00,语法、专项回归、差异检查通过,8002 静态资源已核对 +6,在真实浏览器验证展开折叠及多视口表现,IN_PROGRESS,, +7,审查最终差异并清理临时跟踪文件,TODO,,