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

8.4 KiB
Raw Blame History

固定式计划栏(Pinned Plan Bar)设计

  • 日期:2026-06-28
  • 状态:已批准,待实现
  • 范围:会话页 UIui/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.tsPlanUpdate)。
  • 计划消息由 transformMessagecommon/chat/chatLib.ts)构造,所有平台 acp / nomi / nanobot / openclaw / remote)都可能产生。
  • 计划更新逻辑(Messages/hooks.tscomposeMessageWithIndex):同 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:实心对勾(沿用现 MessagePlanIconCheckCircle 绿色)
    • 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 仍可读取。 useAutoScrollkeyed by messages: listitemCount: processedList.length 不再因计划移动到末尾而抖动。

3. 清理内联计划组件

内联渲染移除后 MessagePlan.tsx 不再被使用:

  • 将其条目渲染逻辑并入 PinnedPlan(展开态)。
  • 删除 Messages/components/MessagePlan.tsx
  • 移除 MessageList.tsxMessagePlan 的 import 与 MessageItemcase '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.tsimport { describe, expect, test } from 'bun:test'):

  • 列表无 plan → 返回 null
  • entries 为空 → 返回 null
  • 单条 plan → done / total 计数正确(含 in_progress 不计入 done)。
  • 多条 plan → 取最后一条(最新)。

折叠 / 展开等交互行为无 DOM 测试框架支撑,通过手动验证(运行应用观察固定栏 默认折叠、点击展开 / 折叠、计划更新时进度刷新、无 plan 时隐藏)。

回归校验:bun run checktypecheck + i18n + theme)通过;bun test 现有用例 不回归。

改动清单

  • 新增:Messages/components/PinnedPlan.tsx
  • 新增:Messages/components/pinnedPlanModel.ts(纯函数 derivePinnedPlan
  • 新增:Messages/components/pinnedPlanModel.test.tsbun:test
  • 修改:Messages/MessageList.tsx(过滤 plan、移除 MessagePlan import/case
  • 修改:5 个平台 wrapper(各插入一行 <PinnedPlan />
  • 修改:locales/en-US/messages.jsonlocales/zh-CN/messages.json
  • 删除:Messages/components/MessagePlan.tsx
  • 生成:i18n 类型(gen:i18n

规模较小,在主流程直接实现,无需 Workflow 编排。