Files
cc-web/.planning/2026-08-16-goal-bubble-rendering/findings.md

111 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 + 完整目标` 被渲染为居中、接近消息区宽度的大块磨砂面板。
- 长正文包含多行约束与反引号,居中排版和纯文本渲染明显降低可读性。
- 目标视觉应为右侧普通用户气泡;状态反馈应保持短小,不重复正文。
- 用户在真实桌面界面确认 Goal 已显示为普通用户气泡,但缺少明确的“目标模式”身份标签。
- `/goal` 由服务端 Slash/RPC 旁路处理,不会先进入模型回合,因此不能依赖模型主动调用 `ccweb_set_title`。
- 当前会话中直接调用 `ccweb_set_title` 成功,证明 MCP 本身可用;缺口位于 `/goal` 旁路没有标题联动,而不是标题 MCP 故障。
- 本轮开始时 `public/app.js`、`public/style.css` 与 `server.js` 没有未提交差异;工作树中的 Codex App 重试改动属于其他任务,必须避开。
- `.planning/.active_plan` 指向另一任务,本轮继续显式维护 `.planning/2026-08-16-goal-bubble-rendering/`,不改全局 active plan。
- 主题架构要求视觉修复只命中用户指出的消息层级;Goal 标签应使用专用 selector,不能扩大到普通用户气泡或其他卡片。
- CSS 接入需注意共享暗色 `:is()` 的 specificity,并在最终验收中核对真实 computed style;自动化结构断言不能替代浏览器视觉确认。
- 修改静态 CSS/JS 后需检查 cache bust 与服务实际加载版本;本次不新增主题资产,也不改 welcome 双路径。
- Trellis 当前没有 `.trellis/.current-task`,现有任务列表也没有 Goal 标记/标题修复任务;进入代码阶段前需建立任务资料并提供给实现与检查代理。
- Trellis 要求 Codex 的实现与质量检查由独立子代理执行;当前运行时没有名为 `trellis-implement`/`trellis-check` 的自定义角色,因此后续使用通用 worker,并在任务提示中显式要求读取 Trellis PRD、上下文清单和相关规范。
- `home-cc-web` 索引为 `ready`,当前 6444 个节点、14582 条边,可直接用于符号级定位。
- 精确命中 `createMsgElement()`、`buildMsgElement()`、`persistCodexAppGoalDisplayMessage()`、`handleCodexAppGoalSlashCommand()`、`isTitleLockedByUser()` 与 `setCurrentConversationTitle()`。
- `setCurrentConversationTitle()` 已具备标题历史、持久化、`session_renamed` 推送和 `broadcastSessionList()`,但它将来源写为 `llm`;Goal 自动派生标题需要复用相同广播契约并保留 `derived` 来源语义。
- 手动重命名路径会设置 `titleSource: 'manual'`,已有 `isTitleLockedByUser()` 可作为禁止 Goal 覆盖用户标题的第一道保护。
- `buildMsgElement()` 会把完整消息对象作为 `meta` 传给 `createMsgElement()`;因此在 `createMsgElement()` 读取 `meta.ccwebGoalCommand.action === 'set'`,即可让实时与历史 Goal 消息共用同一标签渲染,无需额外历史迁移。
- `persistCodexAppGoalDisplayMessage()` 当前在消息入栈后统一 `saveSession()`;最小写入方案是在同一保存前尝试派生默认标题,并把 `titleChanged` 返回给 Slash handler 负责发送 `session_renamed` 与列表广播。
- 普通消息自动标题只在标题严格等于 `New Chat`/`Untitled` 时生效,取用户正文前 60 字并把换行替为空格;Goal 应沿用同一默认标题判定和长度上限,同时压平连续空白,避免多行 objective 生成杂乱标题。
- `normalizeTitleHistory()` 只接受 `source: 'llm'`;现有普通消息的 `derived` 标题也不写 title history。因此 Goal 自动标题应保持 `titleSource: 'derived'` 并不伪造 LLM 标题历史事件,只推送 `session_renamed` 元数据和会话列表。
- Trellis 相关上下文应覆盖前端组件/质量规范、后端质量规范和跨层契约指南;本次改动同时涉及 DOM/CSS、WebSocket 事件、会话持久化与集成回归。
- 暗金荒野主题的真实 ID 是 `wasteland`,不是 `gilded-wasteland`;Goal 标签的主题适配必须限定 `html[data-theme='wasteland']`。
- `index.html` 的 `app.js` 使用动态 `__CC_WEB_FRONTEND_ASSET_VERSION__`,JS 无需手工版本号;`style.css` 仍使用固定 `20260805-usage-loading-inline`,新增标签样式后应推进样式 cache bust,并同步既有回归契约中的精确版本断言。
- 现有 Goal 集成回归集中在 `scripts/regression.js` 约 7135 行,已覆盖 display-only 元数据、稳定 request ID、重复请求和刷新持久化,适合在同一场景追加标题持久化/广播断言;前端 DOM 标签需要增加静态契约测试。
- Goal thread 的 MCP 注入链路已确认:`ensureCodexAppGoalThread()` 调用 `codexAppThreadParams()`,后者通过 `codexAppThreadConfig()` 和 `listRuntimeMcpServerConfigs()` 写入 `mcp_servers.ccweb`;内置配置携带当前 `sourceSessionId`。
- 用户怀疑“Goal 未注册 ccweb MCP”的根因不是注册缺失:`thread/goal/set` 是 app-server RPC,不是模型 turn/MCP 调用。工具可用性不会强制模型调用 `ccweb_set_title`,所以可靠标题仍应由服务端旁路直接派生。
- 第一轮独立计划审查发现:仅广播 `session_list` 只会刷新侧栏,当前聊天顶部标题依赖 `session_renamed`。计划已补成“先向 viewers 发送 `session_renamed`,再广播列表”,回归也必须分别断言。
- 第二轮独立计划复审已通过,无实施阻断项;建议将默认标题判定收敛为 helper,并把自动标题严格限定在 `/goal set` 专用路径。
- 现有 Goal 集成场景复用了已经发送过多条普通消息的 `codexAppSession`,其标题早已不是默认值;自动标题正向回归需要另建一个尚为 `New Chat` 的 Codex App 会话,不能在原场景上做错误断言。
- 当前 `wasteland` 用户气泡最大宽度为 `min(42%, 480px)`;标签必须是内容流内的紧凑元素,正文继续依赖现有 `word-break`/`pre-wrap`,不能增加固定宽度或绝对定位造成窄屏溢出。
- 当前目标文件中只有 `scripts/regression.js` 带并发 Codex App 重试差异;实现必须只在 Goal/缓存断言附近追加,保留约 2446 和 main target 注册处的现有重试代码。
- 本机可用 `/usr/bin/firefox`,虽未发现 Playwright/Puppeteer;可在服务重启条件满足后用 Firefox headless 做基础桌面/窄屏截图验收。
- 当前 `127.0.0.1:8002` 返回 200 且 `Cache-Control: no-store`;在线 HTML 仍引用 `style.css?v=20260805-usage-loading-inline` 与动态 app.js 版本,CSS cache bust 更新后需重新检查实际响应。
- 已完整读取主题验收清单;本次不新增主题/素材,因此资产、主题注册和 welcome 双路径条目不适用,但仍需执行消息 DOM 隔离、`wasteland` computed style、桌面/窄屏、缓存 URL、JS 回归与真实浏览器记录。
- `sendSessionEventToViewers()` 原样转发 payload,不自动补 `sessionId`;Goal 自动标题事件必须显式携带 `type`、`sessionId`、`title` 和 `publicTitleMetadata()`。
- `normalizeSession()` 已接受 `titleSource: 'derived'`;`VALID_TITLE_SOURCES` 包含 derived,因此 Goal 旁路可安全复用该来源而不修改清洗逻辑。
- `truncateTextValue()` 超长时会追加持久化截断标记,不适合标题;Goal 标题应使用独立的 60 个 Unicode code point 截取,避免标题中出现换行标记。
- 现有 mock 在 `thread/goal/set` 后会发 Goal 后台 turn;包含 `dynamic` 的普通 turn 会执行 `completeMcpToolTurn()`,其结果带 `hasCcwebMcpConfig`,可作为 Goal thread 注册 ccweb MCP 的集成证据。
- 现有 Codex App 回归会话在发送多条普通消息后才进入旧 Goal 场景;新增默认标题测试应在同一服务器内创建独立的 `codexapp-goal-title` 会话,避免旧会话标题已被普通消息派生。