# 固定式计划栏(Pinned Plan Bar)设计 - 日期:2026-06-28 - 状态:已批准,待实现 - 范围:会话页 UI(`ui/src/renderer/pages/conversation`) ## 背景与问题 会话过程中 Agent 产出的「计划 / 待办清单」(`type: 'plan'` 消息,组件 `Messages/components/MessagePlan.tsx`)目前作为普通消息内联渲染在滚动消息流里。 随着会话继续,它会被新消息推到上方、滚出可视区进入历史记录,用户无法随时看到 当前计划进度,体验不佳。 现状关键事实: - 计划数据形态:`IMessagePlan.content.entries: Array<{ content: string; status: 'pending' | 'in_progress' | 'completed'; priority? }>`(见 `common/types/platform/acpTypes.ts` 的 `PlanUpdate`)。 - 计划消息由 `transformMessage`(`common/chat/chatLib.ts`)构造,**所有平台** (acp / nomi / nanobot / openclaw / remote)都可能产生。 - 计划更新逻辑(`Messages/hooks.ts` 的 `composeMessageWithIndex`):同 `msg_id` 的新计划会替换旧内容并被移动到列表末尾,因此「列表中最后一条 plan」即为当前计划。 - 布局:每个平台 chat wrapper 结构统一为 ```
// 填满剩余高度 {!hideSendBox && } // 贴底发送框
``` `FlexFullContainer` 将子节点以 `absolute size-full` 填充。 ## 目标 把当前计划固定为一个**贴在输入框上方**的常驻栏: - 默认折叠,仅显示一行进度摘要;点击展开完整清单。 - 滚动消息流不再重复显示该计划(去重)。 - 适用于所有会话类型。 非目标(YAGNI): - 不做计划的编辑 / 手动勾选。 - 不做多计划并存的切换器(一个会话以「最新计划」为准)。 - 不做「智能自动展开 / 完成后自动折叠」(用户选择默认折叠 + 手动切换)。 - 不改动后端 / `hooks.ts` 的计划合并与排序逻辑。 ## 用户已确认的决策 1. **位置**:紧贴输入框上方(消息区与发送框之间)。 2. **折叠默认态**:默认折叠,显示进度摘要,点击展开。 3. **消息流去重**:计划只在固定栏显示,从滚动消息流中移除。 ## 方案 采用「独立 `PinnedPlan` 组件 + 各平台 wrapper 插入 + `MessageList` 过滤」的方案 (隔离性好、对滚动组件零侵入;代价是 5 处一行相同插入)。 ### 1. 新组件 `Messages/components/PinnedPlan.tsx` 职责:渲染当前计划的固定栏。自包含,无 plan 时不渲染。 - 数据来源:`useMessageList()`(组件位于 `MessageListProvider` 内,各平台 chat wrapper 均被 `HOC.Wrapper(MessageListProvider, ...)` 包裹,满足条件)。 - **纯逻辑抽离**:把「从消息列表派生固定栏数据」抽成纯函数 `derivePinnedPlan(list)`, 返回 `{ entries, done, total } | null`,与组件解耦以便单测(见「测试」)。建议放在 同目录 `pinnedPlanModel.ts`。 - 选取当前计划:从列表末尾向前找第一条 `type === 'plan'` 的消息,断言为 `IMessagePlan`。 - 隐藏条件:无 plan,或 `entries.length === 0` → 返回 `null`。 - 进度计算:`done = entries.filter(e => e.status === 'completed').length`, `total = entries.length`。 - 组件消费 `derivePinnedPlan(useMessageList())`,为 `null` 时返回 `null`(隐藏)。 - 本地状态:`expanded`,**初始 `false`(折叠)**。 - 折叠态(一行,整行可点击切换): - 「待办列表」徽标(复用 `messages.planTodoList`) - 进度文本(`messages.planProgress`,含 `done` / `total`) - 一条细进度条(`done/total` 宽度) - 展开 / 折叠箭头(`IconRight` / `IconDown`) - 展开态:在摘要行下方渲染完整 `entries` 列表,条目图标按状态区分: - `completed`:实心对勾(沿用现 `MessagePlan` 的 `IconCheckCircle` 绿色) - `in_progress`:进行中样式(高亮 / 半填充圈,与 pending 区分) - `pending`:空心圈 - 列表容器 `max-h-[30vh] overflow-y-auto`,避免长清单把输入框顶出屏幕。 - 样式:顶部分隔线 + 轻背景(`--color-fill-1` 等主题变量,过 `check:theme`), 宽度与消息列 / 发送框一致(`md:max-w-780px mx-auto`),`shrink-0`。 ### 2. `MessageList.tsx` 去重 在 `processedList` 构建循环顶部(与 `available_commands` 跳过同处)加入: ```ts if (message.type === 'plan') continue; ``` 计划不再进入渲染流。原始 `list`(含 plan)保持不变,`PinnedPlan` 仍可读取。 `useAutoScroll`(keyed by `messages: list`、`itemCount: processedList.length`) 不再因计划移动到末尾而抖动。 ### 3. 清理内联计划组件 内联渲染移除后 `MessagePlan.tsx` 不再被使用: - 将其条目渲染逻辑并入 `PinnedPlan`(展开态)。 - 删除 `Messages/components/MessagePlan.tsx`。 - 移除 `MessageList.tsx` 中 `MessagePlan` 的 import 与 `MessageItem` 的 `case 'plan'` 分支(由 step 2 的过滤兜底,计划永不到达 `renderItem`,无死代码)。 `hooks.ts` 中计划合并 / 移到末尾的逻辑**保持不变**(仅变为不可见,固定栏读取 其最新内容)。 ### 4. 插入点 在以下 5 个 wrapper 中,于 `` 之后、发送框之前插入 ``: - `platforms/acp/AcpChat.tsx` - `platforms/nomi/NomiChat.tsx` - `platforms/nanobot/NanobotChat.tsx` - `platforms/openclaw/OpenClawChat.tsx` - `platforms/remote/RemoteChat.tsx` `PinnedPlan` 无条件渲染(不受 `hideSendBox` 影响);无 plan 时自身返回 `null`, 不占布局。 ### 5. i18n - 复用:`messages.planTodoList`。 - 新增:`messages.planProgress` - en-US:`"{{done}}/{{total}} done"` - zh-CN:`"已完成 {{done}}/{{total}}"` - 同步 en-US 与 zh-CN 两套 `messages.json`,运行 `bun run gen:i18n` 更新类型, 过 `check:i18n`。 ## 数据流 ``` backend update ──► transformMessage ──► addOrUpdateMessage │ │ │ ▼ │ useMessageList() 列表(含最新 plan, │ 同 msg_id 计划被移到末尾) │ │ ├──────────────► MessageList: processedList 过滤掉 plan(不内联) │ │ └──────────────► PinnedPlan: 取末尾最新 plan → 固定栏渲染 ``` ## 边界情形 - 无 plan / `entries` 为空:固定栏隐藏(`null`)。 - 窗口化历史(nomi,初始仅加载最新窗口):若计划早于已加载窗口则暂不显示; 活跃计划必在近窗口内,可接受。 - `hideSendBox` 锁定 / 嵌入面板:固定栏仍显示(只读信息)。 - 长清单:展开态 `max-h-[30vh]` 内部滚动,不挤压输入框。 ## 测试 项目 UI 测试约定为 **`bun:test` + 纯逻辑测试**(无 testing-library / jsdom, 现有 `.test.tsx` 也仅测纯函数、不挂载 DOM)。因此对 `derivePinnedPlan` 纯函数 做单测(`pinnedPlanModel.test.ts`,`import { describe, expect, test } from 'bun:test'`): - 列表无 plan → 返回 `null`。 - `entries` 为空 → 返回 `null`。 - 单条 plan → `done` / `total` 计数正确(含 `in_progress` 不计入 done)。 - 多条 plan → 取最后一条(最新)。 折叠 / 展开等交互行为无 DOM 测试框架支撑,**通过手动验证**(运行应用观察固定栏 默认折叠、点击展开 / 折叠、计划更新时进度刷新、无 plan 时隐藏)。 回归校验:`bun run check`(typecheck + i18n + theme)通过;`bun test` 现有用例 不回归。 ## 改动清单 - 新增:`Messages/components/PinnedPlan.tsx` - 新增:`Messages/components/pinnedPlanModel.ts`(纯函数 `derivePinnedPlan`) - 新增:`Messages/components/pinnedPlanModel.test.ts`(`bun:test`) - 修改:`Messages/MessageList.tsx`(过滤 plan、移除 MessagePlan import/case) - 修改:5 个平台 wrapper(各插入一行 ``) - 修改:`locales/en-US/messages.json`、`locales/zh-CN/messages.json` - 删除:`Messages/components/MessagePlan.tsx` - 生成:i18n 类型(`gen:i18n`) 规模较小,在主流程直接实现,无需 Workflow 编排。