- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
8.4 KiB
固定式计划栏(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 结构统一为
<div className='flex-1 flex flex-col px-20px min-h-0'> <FlexFullContainer><MessageList/></FlexFullContainer> // 填满剩余高度 {!hideSendBox && <XSendBox/>} // 贴底发送框 </div>FlexFullContainer将子节点以absolute size-full填充。
目标
把当前计划固定为一个贴在输入框上方的常驻栏:
- 默认折叠,仅显示一行进度摘要;点击展开完整清单。
- 滚动消息流不再重复显示该计划(去重)。
- 适用于所有会话类型。
非目标(YAGNI):
- 不做计划的编辑 / 手动勾选。
- 不做多计划并存的切换器(一个会话以「最新计划」为准)。
- 不做「智能自动展开 / 完成后自动折叠」(用户选择默认折叠 + 手动切换)。
- 不改动后端 /
hooks.ts的计划合并与排序逻辑。
用户已确认的决策
- 位置:紧贴输入框上方(消息区与发送框之间)。
- 折叠默认态:默认折叠,显示进度摘要,点击展开。
- 消息流去重:计划只在固定栏显示,从滚动消息流中移除。
方案
采用「独立 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 跳过同处)加入:
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 中,于 </FlexFullContainer> 之后、发送框之前插入
<PinnedPlan />:
platforms/acp/AcpChat.tsxplatforms/nomi/NomiChat.tsxplatforms/nanobot/NanobotChat.tsxplatforms/openclaw/OpenClawChat.tsxplatforms/remote/RemoteChat.tsx
PinnedPlan 无条件渲染(不受 hideSendBox 影响);无 plan 时自身返回 null,
不占布局。
5. i18n
- 复用:
messages.planTodoList。 - 新增:
messages.planProgress- en-US:
"{{done}}/{{total}} done" - zh-CN:
"已完成 {{done}}/{{total}}"
- en-US:
- 同步 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(各插入一行
<PinnedPlan />) - 修改:
locales/en-US/messages.json、locales/zh-CN/messages.json - 删除:
Messages/components/MessagePlan.tsx - 生成:i18n 类型(
gen:i18n)
规模较小,在主流程直接实现,无需 Workflow 编排。