Files
MyAiDesk/nomifun-tauri/docs/superpowers/specs/2026-06-28-pinned-plan-bar-design.md
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

185 lines
8.4 KiB
Markdown
Raw Permalink 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.
# 固定式计划栏(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` 的计划合并与排序逻辑。
## 用户已确认的决策
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 中,于 `</FlexFullContainer>` 之后、发送框之前插入
`<PinnedPlan />`
- `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(各插入一行 `<PinnedPlan />`
- 修改:`locales/en-US/messages.json`、`locales/zh-CN/messages.json`
- 删除:`Messages/components/MessagePlan.tsx`
- 生成:i18n 类型(`gen:i18n`
规模较小,在主流程直接实现,无需 Workflow 编排。