Skip to content

Commit 5fe2c79

Browse files
committed
feat(chat): collapse long user message text
1 parent 835a396 commit 5fe2c79

8 files changed

Lines changed: 538 additions & 8 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
schema: spec-driven
2+
created: 2026-06-07T00:00:00.000Z
3+
status: applying
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
## Context
2+
3+
`src/renderer/src/components/chat/message/UserMessage.vue` 当前直接用 `<p>` 渲染 `isTextUIPart(part) && !isSystemReminderPart(part)` 命中的 user text part。该组件由 `ChatMessageList.vue` 在 chat 主区域和 Proposal SidePanel 的共享消息列表中复用,因此改动会同时影响两个场景。
4+
5+
现有 `chat-interface` 规范约束 user 分支继续通过 `isUserImagePart``isUserFilePart``isTextUIPart``isSystemReminderPart` 派发;本变更只改变普通 user text part 的展示状态,不改变数据层 `message.parts`、附件分派或 assistant 渲染。
6+
7+
## Goals / Non-Goals
8+
9+
**Goals:**
10+
11+
- 给普通 user text part 增加长文本默认折叠和展开/收起交互。
12+
- 保持短文本、system-reminder 跳过、图片缩略图和文件名片的现有行为。
13+
- 让实现不依赖持久化字段或跨组件全局状态,状态只属于当前 `UserMessage.vue` 实例。
14+
- 补充组件测试覆盖折叠控制、展开/收起,以及 system-reminder 与附件不受影响。
15+
16+
**Non-Goals:**
17+
18+
- 不折叠 assistant text、reasoning、tool output 或 markdown 内容。
19+
- 不改变 `UIMessage` / `MessageMeta` / IPC schema / 存储格式。
20+
- 不新增用户偏好设置,也不记忆每条消息的展开状态。
21+
- 不把用户 text 改成 markdown 渲染。
22+
23+
## Decisions
24+
25+
-`UserMessage.vue` 内部实现折叠状态,而不是新增 store 或修改 `ChatMessageList.vue`。原因是折叠只影响 user text part 的局部展示,不需要跨会话、跨页面或持久化。
26+
- 每个可见 text part 使用由 `message.id``part.type``index` 组成的稳定 key 管理展开状态,避免同一 user message 含多个 text part 时互相影响。
27+
- 使用 DOM 高度检测判断是否需要显示展开控制:文本容器默认设置 `max-height``overflow-hidden`,挂载和消息内容变化后比较 `scrollHeight``clientHeight`。这样短文本不会出现无意义按钮,长文本才显示控制。
28+
- 推荐最大高度使用 Tailwind arbitrary value,例如 `max-h-40``max-h-[10rem]`,并保留当前气泡样式类:`whitespace-pre-wrap wrap-anywhere relative text-pretty px-4 py-3 rounded-lg min-h-12 bg-elevated/50 border border-default`。Apply 阶段可根据实际视觉选择等价 token,但必须是固定最大高度。
29+
- 展开/收起控制使用 `UButton` 或语义化 `button`,文案为 `展开` / `收起`,并通过 `aria-expanded` 表达状态。控制应贴近对应文本气泡,不影响图片或文件卡片。
30+
31+
## Risks / Trade-offs
32+
33+
- DOM 高度检测在测试环境中不会自动计算真实布局。测试需要通过 mock `scrollHeight` / `clientHeight` 或直接验证类名、按钮状态与点击行为,避免依赖浏览器真实排版。
34+
-`content-visibility:auto` 的消息行中,离屏消息可能延迟完成实际布局。实现应在 `nextTick` 后检测,并在展开/收起时重新计算当前 part;不需要为离屏元素引入全局 observer。
35+
- 如果只按字符数判断是否折叠,多行短字符或长单词场景会误判。高度检测更贴近真实 UI,但实现稍复杂。
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
## Why
2+
3+
当前 user message 的文本 part 直接完整渲染在消息气泡中。用户粘贴长文本、日志或大段需求时,单条消息会占据过多垂直空间,降低后续对话内容的可扫描性。
4+
5+
本变更为 user message 文本增加默认折叠展示,让长输入在聊天主区域和 Proposal SidePanel 中保持紧凑,同时仍允许用户展开查看完整内容。
6+
7+
## What Changes
8+
9+
- user message 的普通 text part 在内容高度超过阈值时默认折叠,文本容器使用固定最大高度并隐藏溢出内容。
10+
- 被折叠的 user text part 显示展开控制;用户点击后完整展示该 text part,再次点击可收起。
11+
- 未超过阈值的短文本不显示展开/收起控制,保持现有视觉表现。
12+
- system-reminder text part 继续跳过渲染,不参与折叠 UI。
13+
- user 图片缩略图和文件名片渲染保持现状。
14+
- assistant text/reasoning/tool 渲染保持现状,不纳入本次折叠行为。
15+
16+
## Capabilities
17+
18+
### New Capabilities
19+
20+
- 无。
21+
22+
### Modified Capabilities
23+
24+
- `chat-interface`: 明确 user message 普通 text part 的长文本折叠、展开/收起交互,以及与 system-reminder、附件和 assistant 消息的边界。
25+
26+
## Impact
27+
28+
- 受影响代码:`src/renderer/src/components/chat/message/UserMessage.vue`
29+
- 受影响测试:`test/renderer/src/components/shared/ui-message-list.spec.ts`
30+
- 受影响规范:`openspec/specs/chat-interface/spec.md` 的 delta spec。
31+
- 不涉及 IPC、共享类型、持久化数据、消息组装逻辑、附件读取逻辑或 markdown 渲染通路。
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
## MODIFIED Requirements
2+
3+
### Requirement: Chat 主区域与 Proposal SidePanel 共享 UIMessage 列表组件
4+
5+
系统 SHALL 将 `UIMessageList` 组件通过 `type: "chat" | "side"` prop 标识使用场景,并新增可选 `agentId?: string` prop 用于在 `type="chat"` 时解析 assistant 头像。`ChatContainer.vue``ProposalApplySidePanel.vue` 的消息列表部分 SHALL 都通过该组件渲染,不再各自编写 `v-for message / v-for part` 的渲染逻辑。
6+
7+
共享组件的必要 props:
8+
9+
- `messages: UIMessage<MessageMeta>[]`
10+
- `status: ChatStatus`
11+
- `type: "chat" | "side"`
12+
- `agentId?: string`(可选,仅在 `type="chat"` 时用于解析 assistant 头像)
13+
14+
组件内部 SHALL 使用 `ai` 包的 `isReasoningUIPart` / `isTextUIPart` / `isToolUIPart` 派发到对应子组件:`UChatMessages` 承载消息容器、`UChatTool` 承载工具调用;assistant text part 与 reasoning part 中的 markdown 文本 SHALL 统一交由项目内统一的 markdown 渲染组件渲染(输入语义为 `content: string``isStreaming: boolean`),保持与当前 chat 主区域一致的渲染通路。该 markdown 渲染组件的具体实现细节由代码层决定,spec 不绑定具体组件名或第三方库。
15+
16+
`message.role === 'user'` 分支 SHALL 在 text part 之外,通过 `isUserImagePart(part)``isUserFilePart(part)``src/renderer/src/utils/chat-message-parts.ts` 提供)派发:
17+
18+
- `isUserImagePart(part)` 命中 → 渲染图片缩略图卡片(`<img>``src` SHALL 从 `part.url` 解析;当 `part.url``file://` URI 时,组件 SHALL 通过 `chatApi.readAttachmentDataUrl(part.url, part.mediaType)` 获取 data URL,并使用返回的 `dataUrl` 作为 `src`;沿用 `AttachmentCard.vue` 图片分支样式)
19+
- `isUserFilePart(part)` 命中 → 渲染文件名片(图标 + `part.filename` + 扩展标签,沿用 `AttachmentCard.vue` 文件分支样式)
20+
- `isTextUIPart(part)` 命中 → 普通 user text 渲染(含 system-reminder 跳过逻辑);当该 text part 的渲染内容高度超过固定最大高度时,系统 SHALL 默认折叠该 text part,并提供展开/收起控制
21+
22+
普通 user text part 的折叠行为 SHALL 满足:
23+
24+
- 每个可见 text part 独立维护展开状态,同一条 user message 内多个 text part 不得互相影响。
25+
- 默认状态为折叠;折叠态 SHALL 设置固定最大高度并隐藏溢出内容。
26+
- 仅当 text part 内容实际超过折叠最大高度时,才显示展开/收起控制。
27+
- 点击展开控制后,当前 text part SHALL 完整显示;点击收起控制后,当前 text part SHALL 回到折叠态。
28+
- 展开状态 SHALL 只存在于当前渲染实例内,不写入 `UIMessage`、session metadata 或持久化存储。
29+
30+
`type="chat"``agentId` 提供时,assistant 头像 SHALL 显示该 agent 对应的 ACP agent icon(来自 `useAcpAgentsStore.icons`)。若 `agentId` 未提供或对应 icon 不存在,则不显示头像(保持与 `type="side"` 一致的行为)。
31+
32+
渲染端 SHALL 使用 `UIMessage.id` 作为 `v-for :key`;该 id 在流式活跃期间为渲染进程生成的临时 id,在 resume 后为磁盘加载的 id,系统 SHALL NOT 做跨进程 id 匹配。
33+
34+
`message.role === 'user'` 的 text part 渲染分支中,系统 SHALL 通过 `isSystemReminderPart(part)` 工具函数识别 system-reminder 内容并**跳过渲染**。识别规则:`part.type === "text"``part.text` 经过 trim 后以 `<system-reminder>` 开头、以 `</system-reminder>` 结尾。该工具函数位于 `src/renderer/src/utils/system-reminder.ts``UIMessageList.vue` 直接调用。类型 `system-reminder` 的 part 仅在磁盘与 `UIMessage.parts` 数据中保留,UI 不展示,也不显示折叠控制。
35+
36+
#### Scenario: Chat 主区域使用共享组件渲染消息列表并显示 agent 头像
37+
38+
- **WHEN** 用户打开 chat 页面
39+
- **THEN** `ChatContainer.vue` 通过 `<UIMessageList :messages :status type="chat" :agentId />` 渲染 `activeSession.messages`
40+
- **AND** assistant 消息的头像显示当前 session 对应 ACP agent 的 icon
41+
- **AND** 渲染结果与当前 chat 消息表现一致(text / tool / reasoning 分派保持现状)
42+
43+
#### Scenario: Proposal SidePanel 使用共享组件保持现有行为
44+
45+
- **WHEN** 用户打开 proposal 详情页,SidePanel 展开
46+
- **THEN** `ProposalApplySidePanel.vue` 通过 `<UIMessageList :messages :status type="side" />` 渲染 `messages`
47+
- **AND** SidePanel 外壳保持现状
48+
- **AND** 消息列表渲染通路与 chat 一致
49+
50+
#### Scenario: user 消息含图片 part
51+
52+
- **WHEN** user 消息的 `parts``{ type: "file", mediaType: "image/png", url: "file:///tmp/截图 1.png", filename }`
53+
- **THEN** `UIMessageList` 渲染图片缩略图卡片
54+
- **AND** `<img>``src``chatApi.readAttachmentDataUrl` 返回的 data URL,而不是未处理的 `file:///tmp/截图 1.png`
55+
- **AND** assistant 消息不渲染任何 file part
56+
57+
#### Scenario: user 消息含文件 part
58+
59+
- **WHEN** user 消息的 `parts``{ type: "file", mediaType: "application/pdf", url, filename }`
60+
- **THEN** `UIMessageList` 渲染文件名片,包含 PDF 图标、文件名、扩展标签
61+
62+
#### Scenario: user 消息中的 system-reminder part 不在 UI 展示
63+
64+
- **WHEN** user 消息的 `parts` 首位为 system-reminder text part(`part.text` 经 trim 后以 `<system-reminder>` 开头并以 `</system-reminder>` 结尾)
65+
- **THEN** `UIMessageList.vue``message.role === 'user'` 分支跳过该 part 的渲染
66+
- **AND** 同条 user 消息的其余 part 正常渲染
67+
- **AND** 数据层 `message.parts` 不做修改
68+
69+
#### Scenario: user 消息仅含 system-reminder 时不输出可见文本
70+
71+
- **WHEN** user 消息的 `parts` 全部为 system-reminder text part
72+
- **THEN** 该消息气泡不渲染任何 text 内容
73+
- **AND** 不抛错、不影响其他消息渲染
74+
75+
#### Scenario: user 长文本默认折叠并可展开
76+
77+
- **WHEN** user 消息包含一个普通 text part,且渲染后内容高度超过折叠最大高度
78+
- **THEN** 该 text part 默认以固定最大高度显示并隐藏溢出内容
79+
- **AND** 该 text part 显示展开控制
80+
- **WHEN** 用户点击展开控制
81+
- **THEN** 该 text part 完整显示
82+
- **AND** 控制状态变为收起
83+
84+
#### Scenario: user 短文本不显示折叠控制
85+
86+
- **WHEN** user 消息包含一个普通 text part,且渲染后内容高度未超过折叠最大高度
87+
- **THEN** 该 text part 按现有气泡样式完整显示
88+
- **AND** 不显示展开/收起控制
89+
90+
#### Scenario: 同一 user 消息多个 text part 独立折叠
91+
92+
- **WHEN** user 消息包含两个超过折叠最大高度的普通 text part
93+
- **AND** 用户只展开第一个 text part
94+
- **THEN** 第一个 text part 完整显示
95+
- **AND** 第二个 text part 仍保持折叠态
96+
97+
### Requirement: 附件用户消息渲染图片缩略图与文件名片
98+
99+
系统 SHALL 在 `src/renderer/src/utils/chat-message-parts.ts` 暴露:
100+
101+
```ts
102+
isUserImagePart(part: UIMessage["parts"][number]): boolean
103+
isUserFilePart(part: UIMessage["parts"][number]): boolean
104+
```
105+
106+
判定规则:
107+
108+
- `isUserImagePart``part.type === "file" && typeof part.mediaType === "string" && part.mediaType.startsWith("image/")`
109+
- `isUserFilePart``part.type === "file" && typeof part.mediaType === "string" && !part.mediaType.startsWith("image/")`
110+
111+
`UIMessageList``message.role === 'user'` 分支 SHALL 通过这两个 helper 派发:
112+
113+
- `isUserImagePart(part)` → 渲染缩略图卡片(`<img>``src` SHALL 从 `part.url` 解析;当 `part.url``file://` URI 时,组件 SHALL 通过 `chatApi.readAttachmentDataUrl(part.url, part.mediaType)` 获取 data URL,并使用返回的 `dataUrl` 作为 `src`;沿用 `AttachmentCard.vue` 风格的图片预览样式)
114+
- `isUserFilePart(part)` → 渲染文件名片(图标 + 文件名 `part.filename` + 扩展标签,沿用 `AttachmentCard.vue` 文件分支样式)
115+
- `isTextUIPart(part)``isSystemReminderPart(part)` 分支保持现状;普通 user text part 的长文本折叠行为由“Chat 主区域与 Proposal SidePanel 共享 UIMessage 列表组件” requirement 定义
116+
117+
assistant 分支 SHALL NOT 调这两个 helper(assistant 当前不渲染 file part)。
118+
119+
#### Scenario: user 消息含图片 part 渲染缩略图
120+
121+
- **WHEN** 历史 session 加载后,某条 user 消息 `parts``{ type: "file", mediaType: "image/png", url: "file:///abs/截图 1.png", filename: "截图 1.png" }`
122+
- **THEN** `UIMessageList` 渲染该 part 为图片缩略图
123+
- **AND** `<img>``src``chatApi.readAttachmentDataUrl` 返回的 data URL
124+
- **AND** 数据层 `part.url` 仍保持为 `file:///abs/截图 1.png`
125+
126+
#### Scenario: user 消息含非 file URL 图片 part 渲染缩略图
127+
128+
- **WHEN** 某条 user 消息 `parts``{ type: "file", mediaType: "image/png", url: "data:image/png;base64,abc", filename: "x.png" }`
129+
- **THEN** `UIMessageList` 渲染该 part 为图片缩略图
130+
- **AND** `<img>``src` 为原始 `data:image/png;base64,abc`
131+
132+
#### Scenario: user 消息含文件 part 渲染名片
133+
134+
- **WHEN** user 消息 `parts``{ type: "file", mediaType: "application/pdf", url: "file:///abs/doc.pdf", filename: "doc.pdf" }`
135+
- **THEN** 渲染包含文件图标、文件名 "doc.pdf"、扩展标签 "PDF" 的卡片
136+
- **AND** 不展开 PDF 内容预览
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
## 1. UserMessage 折叠实现
2+
3+
- [x] 1.1 修改 `src/renderer/src/components/chat/message/UserMessage.vue`,为普通 user text part 增加局部展开状态,状态 key 使用 `message.id``part.type``index` 组合,确保同一条消息多个 text part 独立展开/收起。
4+
- [x] 1.2 在 `UserMessage.vue` 的普通 text part 气泡上保留现有样式,并在折叠态增加固定最大高度与 `overflow-hidden`;展开态移除最大高度限制,完整显示 `part.text`
5+
- [x] 1.3 在 `UserMessage.vue` 中通过 DOM 高度检测判断 text part 是否超过折叠最大高度;仅超过时显示展开/收起控制,短文本不显示控制。
6+
- [x] 1.4 在 `UserMessage.vue` 的展开/收起控制中使用可访问的按钮语义,文案为 `展开` / `收起`,并设置 `aria-expanded`;点击控制只切换当前 text part,不影响图片卡片、文件卡片或其他 text part。
7+
- [x] 1.5 确认 `isSystemReminderPart(part)` 命中的 text part 仍完全跳过渲染,不创建文本气泡、不参与高度检测、不显示展开/收起控制。
8+
9+
## 2. 测试
10+
11+
- [x] 2.1 更新 `test/renderer/src/components/shared/ui-message-list.spec.ts`,增加 user 长文本用例:mock 对应文本元素的 `scrollHeight > clientHeight`,断言默认折叠类或状态存在,并显示 `展开` 控制。
12+
- [x] 2.2 在同一测试文件增加展开/收起交互用例:点击 `展开` 后当前 text part 完整展开并显示 `收起`,再次点击后回到折叠态。
13+
- [x] 2.3 在同一测试文件增加短文本用例:mock `scrollHeight <= clientHeight`,断言不显示 `展开` / `收起` 控制。
14+
- [x] 2.4 在同一测试文件增加多 text part 用例:两个长 text part 同时存在时,只展开第一个,第二个仍保持折叠态。
15+
- [x] 2.5 保持现有 system-reminder、图片附件和文件附件断言通过;如测试 stub 需要支持 `UButton``button`,只补充最小 stub,不引入真实 overlay 或 Nuxt UI 复杂行为。
16+
17+
## 3. 验证
18+
19+
- [x] 3.1 运行 `pnpm vitest run test/renderer/src/components/shared/ui-message-list.spec.ts`,确认新增和既有消息列表测试通过。
20+
- [x] 3.2 运行 `pnpm vitest run test/renderer/src/**/*.{test,spec}.{ts,vue}`,确认 renderer 组件相关测试无回归。
21+
- [x] 3.3 运行 `pnpm typecheck:web`,确认 Vue/TypeScript 类型检查通过。
22+
23+
## 4. 文档与规范
24+
25+
- [x] 4.1 不更新 `guidelines/RendererProcess.md`:本变更只细化 chat-interface 的业务展示行为,不改变 renderer 分层、目录职责、store/API 访问规则或 UI 通用约束。

0 commit comments

Comments
 (0)