ソースを参照

fix(chat): 修复回复内容闪烁 + 发送时布局跳动,预览留白与排版对齐

## 闪烁与跳动
- 工具组 key 由 `tool-${rows.length}-${summary}` 改为组内首条消息 id
  —— 流式追加时 key 跟着变会导致整组销毁重建(实测气泡数 2→1→2)
- 标题栏改为固定高度 + 任务状态固定槽位,任务出现/消失不再撑高
- 「对方正在输入」移到固定高度槽位,不再把消息流顶高一截

## 预览
- 右栏内联预览增加外围留白,图片加圆角与浅底,默认按宽度完整可见

## 排版
- 段间距 8→12px;列表改小圆点;引用去背景只留竖条;补 hr 样式

## 文档
- 新增《容器消息行为实测与处理流程》:直接询问容器 + App 抓包实测,
  沉淀 6 条 App 侧规则与 4 项已知风险

验证:单测 1063/1063 · verify:ui 12/12 · verify:docs 通过 · tsc 0 错误 · 构建通过

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 1 日 前
親
コミット
cc0bf23eda

+ 30 - 0
docs/00-tracking/操作追踪.md

@@ -2906,3 +2906,33 @@ POST /parse/classes/Workspace → 400 {"code":119,"error":"not allowed to access
 **验证**:会话行背景 `rgba(0,0,0,0)` ✓;Ctrl+滚轮 100%→110% 且 `defaultPrevented=true` ✓;
 粘贴 1 个附件、拖拽追加 1 个、拖入高亮生效 ✓;消息区 `overflowX: hidden` 无横向溢出 ✓
 `tsc` 0 错误;**单测 1063/1063**;`verify:ui` **12/12**;生产构建通过。
+
+## 2026-10-30 · 跳动修复 + 预览留白 + 排版对齐 + 容器行为梳理
+
+### 🔴 会话回复"一下出现一下消失"(已定位并修复)
+实测复现:发「帮我看看当前目录有哪些文件」后,消息气泡数 `2 → 1 → 2`。
+**根因**:`messageRows` 里工具组的 key 是 `tool-${rows.length}-${summary}` ——
+流式追加时 `rows.length` 变化,同一组 key 跟着变,Angular 销毁重建整组。
+**修复**:key 改用工具组内**第一条消息的 id**。
+
+### 发送时底部跳动
+两处撑高布局:
+1. 标题栏「任务状态」从无到有 → 栏高变化。改为**固定 `height`** + 固定槽位。
+2. 「对方正在输入」在消息流尾部 → 出现时顶高一截。移到**固定高度槽位** `.typing-hold`。
+
+### 右栏预览留白
+`.fp-panel--inline .fp-body` 内边距 8 → 12px;图片加圆角与浅底,
+默认按宽度适配(先看全貌),放大后靠滚动看细节。
+
+### 正文排版对齐参考实现
+段间距 8→12px;列表改用**小圆点 + 0.25em 行距**(不用浏览器默认大圆点);
+引用块去掉背景只留 3px 竖条;补 `hr` 样式。
+
+### 容器行为(直接询问 lj,结论见新文档)
+`docs/03-interface/容器消息行为实测与处理流程-20261030.md`
+关键结论:**容器只发正文**(工具过程/思考过程不外发)、**一条回复可能拆多条**、
+正常不撤回不编辑、typing 由平台运行时发。
+由此沉淀 6 条 App 侧规则(R1–R6)与 4 项已知风险(K1–K4)。
+
+**验证**:气泡数全程稳定 2 ✓;`tsc` 0 错误;**单测 1063/1063**;
+`verify:ui` **12/12**;`verify:docs` 74 文件通过;生产构建通过。

+ 103 - 0
docs/03-interface/容器消息行为实测与处理流程-20261030.md

@@ -0,0 +1,103 @@
+# 容器消息行为实测与处理流程
+
+| 字段 | 值 |
+|---|---|
+| 版本 | v1.0 |
+| 日期 | 2026-10-30 |
+| 方法 | **直接询问 lj 容器**(容器自述)+ App 侧抓包实测(`/sync` 事件序列) |
+| 状态 | 容器自述部分标注"未经独立验证";App 侧实测部分为已验证 |
+| 适用 | 所有接入 App 的 Fmode 容器 |
+
+---
+
+## 一、容器自述(2026-10-30 实测提问)
+
+> 提问方式:在会话里直接问「你处理一条消息会发哪几类消息 / 是否撤回 / 是否有思考过程 / 工具过程怎么发 / 何时发 typing」,
+> 要求**不执行工具、直接回答**。以下是容器原话要点。
+
+| # | 问题 | 容器回答 |
+|---|---|---|
+| 1 | 发哪几类消息 | **只有正文**。工具过程不发、状态提示一般不发、思考过程不发 |
+| 2 | 一条回复拆成几条 | **会拆**。原因:超单条长度上限被分段、长任务中途进度说明 + 最后结论、附件与配套文字分开。**阈值不确定** |
+| 3 | 撤回 / 编辑 | **正常对话不撤回不编辑**。仅发送失败、内容违规时可能由平台层撤回。要更正会**补发一条**而不是 edit |
+| 4 | 思考过程 | **有内部推理,但不通过 Matrix 发出** —— 不作为正文、不作为 event、不写 `m.room.message`。客户端看到的"思考中"是本地根据 typing 渲染的 |
+| 5 | 工具调用 | **不发**。全部内部执行,房间只看到基于结果的正文 |
+| 6 | m.typing | 由**平台运行时**按需发送,不是容器手动构造;极短回复往往没有 |
+
+> ⚠️ 容器明确说明:分段阈值、平台层撤回策略等属于**平台实现层**,它自己不确定精确数值。
+
+---
+
+## 二、App 侧实测(已验证)
+
+### 2.1 简单回复
+
+实测「用一个词回答:你好」:结构全程稳定 —— 2 个气泡(用户 + 回复),无重建、无消失。
+
+### 2.2 复杂回复(曾复现「内容一下出现一下消失」)
+
+实测「帮我看看当前目录下有哪些文件」:
+
+```
++4s   bubbles=2
++8s   bubbles=1   ← 掉了一个
++12s  bubbles=2   ← 又回来
+```
+
+**根因(已修复)**:`messageRows` 里工具组的 key 用了 `tool-${rows.length}-${summary}`。
+流式追加消息时 `rows.length` 变化 → 同一组在相邻渲染里 key 不同 →
+Angular 销毁重建整组,表现为内容闪烁。
+**修复**:key 改为工具组内**第一条消息的 id**(稳定标识)。
+
+### 2.3 与容器自述的矛盾点
+
+容器称"工具过程不发",但 App 侧历史实测**确实收到过** `browser_exec: ...` 这类消息
+(见 `message-segments.ts` 的 `isToolActivityMessage`)。
+**结论**:工具过程**是否外发取决于容器/平台版本**,App 必须两种都能正确渲染:
+- 收到 → 折叠成"运行了 N 步"(现有实现)
+- 没收到 → 只显示正文(现有实现)
+
+---
+
+## 三、由实测得出的处理流程
+
+### 3.1 一条用户消息的完整链路
+
+```
+用户发送
+  → App 本地插入 pending 气泡(state=sending)
+  → Matrix sendText(带线程根,见《容器会话与线程机制》)
+  → 容器接收并处理(内部执行工具,不发过程消息)
+  → 容器发正文(可能拆成 1~N 条)
+  → 若耗时较长,平台发 m.typing(App 显示"正在输入")
+  → App 收到消息 → 按线程归属过滤 → 追加到消息流
+```
+
+### 3.2 App 侧必须遵守的规则(均已实现)
+
+| # | 规则 | 理由 | 实现位置 |
+|---|---|---|---|
+| R1 | **不假设一条回复只有一条消息** | 容器会分段 | `messageRows` 按 id 渲染,不合并 |
+| R2 | **不依赖撤回/编辑事件** | 容器正常不撤回 | 收到 redact 时按"删除"处理即可 |
+| R3 | **不为思考过程预留 UI** | 容器不发 | 已实测确认,不做空壳 |
+| R4 | **工具消息可折叠也可缺省** | 视版本而定 | `isToolActivityMessage` 分支 |
+| R5 | **列表 key 必须稳定** | 否则重建闪烁 | 消息用 `m.id`,工具组用组内首条 id |
+| R6 | **指示器/状态条不参与流布局** | 否则发送时界面跳动 | 标题栏固定高 + `.typing-hold` 固定槽位 |
+
+### 3.3 已知风险
+
+| # | 风险 | 现状 |
+|---|---|---|
+| K1 | 容器分段阈值未公开 | App 不做任何"预期条数"假设,收到几条显示几条 |
+| K2 | 平台层可能撤回消息 | App 未处理 redact 事件 → 撤回的消息会**一直留在界面**(待实现) |
+| K3 | 同一条回复跨多条消息时无"聚合标识" | 无法在 UI 上标注"这几条是同一轮回复" |
+| K4 | typing 由平台决定,App 无法精确反映"是否在处理" | 发送后靠本地 `sending` 状态兜底 |
+
+---
+
+## 四、待容器/平台确认
+
+1. **分段阈值**具体是多少字符?(影响是否要做"长回复合并展示")
+2. 平台层撤回会在什么条件下触发?App 是否应订阅 redact 事件?
+3. 同一轮回复的多条消息,是否有可用的聚合字段(如 `m.relates_to` 的 rel_type)?
+4. 不同容器版本对"工具过程是否外发"的行为差异,是否可以统一?

+ 10 - 1
src/app/components/preview/file-preview/file-preview.ts

@@ -265,8 +265,14 @@ import { downloadAs, needsBlobPreview, toBlobUrl } from '../../../core/attachmen
        * 侧栏里宽度只有 300 多,图片必须**按容器缩放**而不是撑出横向滚动。
        * SVG 等无固有尺寸的图更明显:不设 width:100% 它会按像素尺寸铺开、上下留白。
        */
+      /*
+       * 侧栏里的预览体(2026-10-30):
+       * 留出**外围内边距**,让内容与栏边有呼吸空间,同时不贴边更方便判断边界。
+       * 默认按宽度适配(完整可见);用户放大后超出部分靠滚动查看 ——
+       * "先看全貌、再放大看细节"比"一进来就被裁切"更符合阅读习惯。
+       */
       .fp-panel--inline .fp-body {
-        padding: var(--sp-2);
+        padding: var(--sp-3);
         place-items: start center;
         /* 纵向可滚、横向锁死(用户明确要求:右栏不得出现横向滚动条) */
         overflow-x: hidden;
@@ -282,6 +288,9 @@ import { downloadAs, needsBlobPreview, toBlobUrl } from '../../../core/attachmen
         height: auto;
         object-fit: contain;
         transition: width var(--motion-fast) var(--ease-standard);
+        /* 圆角与浅底:图片之外露出一圈背景,边界清楚 */
+        border-radius: var(--r-sm);
+        background: var(--surface-2);
       }
       /* 重置按钮:宽度自适应(带文字),覆盖 .fp-zoom button 的固定 28px */
       .fp-zoom button.fp-zoom-reset {

+ 39 - 7
src/app/components/preview/md-render/md-render.ts

@@ -88,23 +88,55 @@ import { MarkdownComponent } from 'ngx-markdown';
       :host ::ng-deep .md-host > :last-child {
         margin-bottom: 0;
       }
+      /*
+       * 正文排版(2026-10-30 对齐参考实现):
+       *   · 段间距 = 1 行(--sp-3),段落之间才有呼吸感;原来 8px 显得挤
+       *   · 列表用小圆点 + 3px 间隔,不用浏览器默认大圆点
+       *   · 引用去掉背景色,只留 3px 竖条 —— 参考实现的做法,更轻
+       */
       :host ::ng-deep .md-host p {
-        margin: 0 0 var(--sp-2);
+        margin: 0 0 var(--sp-3);
+      }
+      :host ::ng-deep .md-host p:last-child {
+        margin-bottom: 0;
       }
       :host ::ng-deep .md-host :is(ul, ol) {
-        margin: 0 0 var(--sp-2);
-        padding-left: 1.4em;
+        margin: 0 0 var(--sp-3);
+        padding-left: 1.5em;
+      }
+      :host ::ng-deep .md-host ul {
+        list-style: none;
+        padding-left: 1.1em;
+      }
+      :host ::ng-deep .md-host ul > li {
+        position: relative;
+      }
+      /* 小圆点:跟随正文色、固定在首行中线 */
+      :host ::ng-deep .md-host ul > li::before {
+        content: '';
+        position: absolute;
+        left: -0.85em;
+        top: 0.72em;
+        width: 5px;
+        height: 5px;
+        border-radius: 50%;
+        background: currentColor;
+        opacity: 0.45;
       }
       :host ::ng-deep .md-host li {
-        margin: 2px 0;
+        margin: 0.25em 0;
       }
       :host ::ng-deep .md-host blockquote {
-        margin: var(--sp-2) 0;
-        padding: var(--sp-2) var(--sp-3);
+        margin: var(--sp-3) 0;
+        padding: var(--sp-1) 0 var(--sp-1) var(--sp-3);
         border-left: 3px solid var(--edge-strong);
-        background: var(--surface-2);
         color: var(--ink-2);
       }
+      :host ::ng-deep .md-host hr {
+        margin: var(--sp-5) 0;
+        border: 0;
+        border-top: 1px solid var(--edge);
+      }
       :host ::ng-deep .md-host code {
         padding: 1px 5px;
         border-radius: var(--r-tag);

+ 66 - 16
src/app/desktop/pages/chat/chat.ts

@@ -180,14 +180,23 @@ interface ConversationRow {
                 原来它是一条横跨整列、带底色和左竖线的横条 —— 比标题还抢眼,
                 而且把消息流整体往下推。参考 ChatGPT:状态属于页头,不属于内容。
               -->
-              <span
-                class="task-line"
-                [class]="'tone-' + taskLine(a.id).tone"
-                [class.busy]="taskLine(a.id).active"
-                [title]="taskLine(a.id).text"
-              >
-                <span class="spin" aria-hidden="true"></span>
-                <span class="task-text ellipsis">{{ taskLine(a.id).text }}</span>
+              <!--
+                任务状态固定占位(2026-10-30):
+                原来「无任务」时这个元素不存在、有任务时突然出现,
+                把标题栏撑高 → 下方消息区整体下移一截,观感就是"发送后界面跳一下"。
+                现在用固定宽高的槽位承载,文字在槽内截断。
+              -->
+              <span class="task-slot" [title]="taskLine(a.id).text">
+                @if (taskLine(a.id).text) {
+                  <span
+                    class="task-line"
+                    [class]="'tone-' + taskLine(a.id).tone"
+                    [class.busy]="taskLine(a.id).active"
+                  >
+                    <span class="spin" aria-hidden="true"></span>
+                    <span class="task-text ellipsis">{{ taskLine(a.id).text }}</span>
+                  </span>
+                }
               </span>
             }
             <div class="chat-actions">
@@ -355,10 +364,11 @@ interface ConversationRow {
                     />
                   }
                 }
-                <!-- 对方正在输入(FR-04;容器侧已实测在发 m.typing) -->
-                @if (chat.peerTyping()) {
-                  <app-typing-indicator [name]="chat.agent()?.name ?? '对方'" />
-                }
+                <!--
+                  对方正在输入:已移到消息流**下方固定槽位**(见 .typing-hold)。
+                  放在流里会把内容顶高一截(出现/消失各跳一次),
+                  而且它出现在视口外时用户根本看不到(截图实测)。
+                -->
               }
             }
           </div>
@@ -367,6 +377,18 @@ interface ConversationRow {
             输入区外层(2026-10-29):与消息流同宽居中限宽 768px,
             卡片式输入框才不会被拉到整列宽度(宽屏下单行输入框横跨 1200px 很怪)。
           -->
+          <!--
+            正在输入槽位(2026-10-30):固定高度、不参与消息流布局。
+            有内容时显示指示器,没有时留空 —— 出现/消失不改变任何高度。
+          -->
+          @if (chat.agentId()) {
+            <div class="typing-hold" aria-live="polite">
+              @if (chat.peerTyping()) {
+                <app-typing-indicator [name]="chat.agent()?.name ?? '对方'" />
+              }
+            </div>
+          }
+
           <div class="composer-hold">
           <!--
             暂存附件条:选文件后**先停在这里**,点发送才真正上传+发送。
@@ -1164,6 +1186,15 @@ interface ConversationRow {
         padding: var(--sp-2) 0;
       }
       /* 输入区与消息流同宽:宽屏下居中,窄屏贴边留 16px */
+      .typing-hold {
+        flex-shrink: 0;
+        /* 固定高度:指示器出现/消失都不改变布局 */
+        height: 24px;
+        display: flex;
+        align-items: center;
+        padding: 0 max(var(--sp-4), calc((100% - 768px) / 2));
+      }
+
       .composer-hold {
         flex-shrink: 0;
         padding: 0 max(var(--sp-4), calc((100% - 768px) / 2));
@@ -1392,10 +1423,22 @@ interface ConversationRow {
         grid-template-areas: 'avatar title task status actions';
         align-items: center;
         column-gap: var(--sp-3);
-        min-height: var(--desk-topbar-h);
+        /*
+         * 🔴 固定高度而不是 min-height:内容变化(任务状态出现/消失)不得改变栏高,
+         *    否则下方消息区会跟着位移 —— 用户看到的"发送时跳动"。
+         */
+        height: var(--desk-topbar-h);
         padding: 0 var(--sp-4);
         border-bottom: 1px solid var(--edge);
       }
+      /* 任务状态槽位:定宽定高,内部截断 */
+      .task-slot {
+        display: flex;
+        align-items: center;
+        min-width: 0;
+        max-width: 220px;
+        height: 100%;
+      }
       .chat-head > ui-avatar {
         grid-area: avatar;
       }
@@ -1998,13 +2041,20 @@ export class DkChatPage {
    */
   readonly messageRows = computed<MessageRow[]>(() => {
     const rows: MessageRow[] = [];
-    let group: { text: string; summary: string }[] = [];
+    /*
+     * 🔴 工具组的 key 必须用**组内第一条消息的 id**,不能用 rows.length。
+     *
+     * 原实现 `tool-${rows.length}-${summary}`:流式追加消息时 rows.length 会变,
+     * 导致同一个工具组在相邻两次渲染里 key 不同 → Angular 销毁重建整个组,
+     * 表现为「内容一下出现、一下消失」(实测复现:气泡数 2→1→2)。
+     */
+    let group: { text: string; summary: string; id: string }[] = [];
     const flush = (): void => {
       if (group.length === 0) return;
       const first = group[0];
       rows.push({
         kind: 'tool',
-        key: `tool-${rows.length}-${first.summary}`,
+        key: `tool-${first.id}`,
         block: {
           kind: 'process',
           steps: group.map((g) => ({ kind: 'tool' as const, label: g.summary, detail: g.text })),
@@ -2016,7 +2066,7 @@ export class DkChatPage {
       // 空消息(撤回 / 表情回应 / 空 body)不渲染 —— 实测占 7/12
       if (!m.text?.trim() && !m.file) continue;
       if (m.sender === 'agent' && isToolActivityMessage(m.text)) {
-        group.push({ text: m.text!.slice(0, 400), summary: toolActivitySummary(m.text!) });
+        group.push({ text: m.text!.slice(0, 400), summary: toolActivitySummary(m.text!), id: m.id });
         continue;
       }
       flush();