Explorar el Código

feat(mobile): 手机端操作体验优化(阶段 A)

按外部《fmode-app 手机端操作体验优化方案》评审结论实施,
剔除伪意见、修正误判后落地 6 项真改动。

## 核心修复
- 草稿跨会话串台(真实数据 bug):`_draft` 原为全局单例 signal,
  A 会话打的字会出现在 B 会话;`open()` 里的 set('') 又表现为丢字。
  改为按会话键分桶的 `_draftStore: Map`,新增 `adoptDraft()`
  处理「新建态 → 会话创建」的键迁移。

## 体验优化
- 任务状态可见性:聊天页接入 `TaskPulseService.describe()`
  (桌面端早有,手机端完全缺失),顶栏副标题现可显示「正在处理…」
- 空态引导:`m-empty` 新增 `actionLabel` / `actionIcon`
  (复用 `retry` 输出,向后兼容);会话列表空态直接给「+ 新建对话」
- 容器内快速新建:`m-drawer` 新增 `quickNewAgentId` / `quickNew`,
  复用 `/chat/:id/new` 既有路由
- 任务完成提醒:监听 `finished` 脉冲 → 底座 `UiMToastService` 轻提示

## 其他
- `messages.ts` 硬编码 rgba(0,0,0,.35) → `var(--overlay)`(ADR-002)

## 不采纳(评审判定)
- 回车换行:伪意见,`m-dock.ts` 早已实现自增高 textarea + Shift+Enter
- LLM 标题重总结:成本/延迟不匹配,且 `patchChatThread` 已有一致性链路
- 按 project 字段分组:依赖的成果/项目路由已于 2026-11-11 下线

## 验证
- `tsc` 通过 · `npm test` 1786 SUCCESS(新增 3 条草稿隔离回归)
- `npm run build` 通过(首屏 591.87 kB)
- 真机视口实测:空态引导 / 抽屉快速新建 / 无 pageerror

评审报告见 docs/04-quality/review/2026-11-14-手机端UX方案评审.md

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct hace 6 horas
padre
commit
a609ce24f1

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

@@ -8300,3 +8300,33 @@ URL 不会重复显示:`chat.service.ts:2349` 的 `stripLinks` 会把已提取
 | **App 侧排除** | `matrix-client.service.ts:739 createSessionRoom` **每次确实新建独立房间**(`creation_content: {'fmode.session_room': true}`)→ 串台在容器读取侧,不在 App 写入侧 |
 | **修复可行性** | 记忆层/消息层要改 **Hermes 内核**(`/opt/hermes` 对 hermes 用户只读,容器改不了);文件层容器可改;**止血手段**:容器侧禁用/收窄 `session_search` + 清理全局 MEMORY 里的任务内容 |
 | **交付文档** | 新建 `docs/00-tracking/2026-11-14-容器会话隔离失效-取证报告.md`(三层证据 + 串台链路 + 修复方向 + 我的两个错误) |
+
+## 2026-11-14(续十三)· 手机端 UX 优化(阶段 A)+ 外部方案评审
+
+| 项 | 内容 |
+|---|---|
+| **需求来源** | 用户提出三块新需求:①评审外部《fmode-app 手机端操作体验优化方案》②Capacitor 打包 Android ③接入极光 JPush + scheme + 微信/企微分享。用户裁决流程:**先 UI 优化 → 再打包 → 再推送铺路**;推送链路选「仅前台/后台存活时本地通知」;推送厂商坚持极光 |
+| **评审首要发现** | 外部方案**按 v1 存量项目编写**:引用的 `chat-view.component.ts` / `drawer.component.ts` / `conversations.service.ts` / `TaskService.deriveTasks` 等在 v2 **全部不存在**,不能照搬 |
+| **评审结论** | 10 条中**真意见 8 条、伪意见 1 条(回车换行——v2 已实现)、弱主观 1 条**;另发现方案 1 处误判(标题"不能编辑",实际 `rename()` 已存在,只缺 UI)、1 处低估(草稿问题是**跨会话串台**而非丢失,且方案选错了键) |
+| **交付文档** | 新建 `docs/04-quality/review/2026-11-14-手机端UX方案评审.md`(逐条真伪判定 + 代码依据 + 设计评审) |
+| **A2 草稿串台(真 bug,最高优先)** | `chat.service.ts` 的 `_draft` 是**全局单例 signal**:A 会话打半数会出现在 B 会话,`open()` 里的 `set('')` 又表现为丢字。改为 `_draftStore: Map<会话键, 草稿>` + `_draft` computed;`open()` **不再清草稿**;新增 `adoptDraft()` 处理「新建态 → 会话创建」的键迁移;`clearDraft()` 延后到 sessionId 落定后调用 |
+| **A3 标题可编辑** | `m-app-bar` 新增 `editable` input + `titleTap` output(标题本身即热区,**不加第三个顶栏按钮**,避免 `rightCount × 44px` 挤压标题);聊天页接 `rename()`,仅「已有真实会话」可编辑 |
+| **A1 状态可见性** | 手机聊天页接入 `TaskPulseService.describe()`(桌面端早就有,手机端完全缺失)→ 顶栏 subtitle 现在能显示「正在处理…」而非只有「在线」 |
+| **A4 空态引导** | `m-empty` 加 `actionLabel` / `actionIcon`(复用 `retry` 输出,向后兼容);会话列表空态直接给「+ 新建对话」按钮 |
+| **A5 快速新建** | `m-drawer` 加 `quickNewAgentId` / `quickNew`:在聊天页打开抽屉时多一条「在本容器新建」,复用 `/chat/:id/new` 既有路由,不新造建房逻辑 |
+| **A6 完成提醒** | 聊天页监听 `TaskPulseService` 的 `finished` 脉冲 → 底座 `UiMToastService.success()` 轻提示(含任务名与用时)。首次进入只建立基线,不补弹历史 |
+| **A7 顺手修正** | `messages.ts` 硬编码 `rgba(0,0,0,.35)` → `var(--overlay)`(ADR-002 零硬编码色值) |
+| **踩坑(实测发现)** | 弹层主按钮初写 `color: var(--inverse)` → 默认主题 `--primary` 是深墨 `#25272a`,黑底黑字。项目标准是 `var(--primary-content)`,已改 |
+| **未做** | 回车换行(伪意见,v2 已实现);LLM 标题重总结(成本/延迟不匹配,且 `patchChatThread` 已有一致性链路);会话按 project 字段分组(依赖的成果/项目路由已于 11-11 下线) |
+| **验证** | `tsc` ✅ · `npm test` **1786 SUCCESS**(新增 3 条草稿隔离回归)· `npm run build` ✅(首屏 591.87 kB)· 真机视口截图实测:空态引导 ✅ / 标题编辑保存生效 ✅ / 抽屉快速新建 ✅ / 无 pageerror |
+| **待办** | 阶段 B(Capacitor 打包)与阶段 C(scheme + 推送铺路)尚未开始;**需用户目视确认阶段 A 后再进阶段 B**(G-D1) |
+
+## 2026-11-14(续十四)· 删除会话改名功能
+
+| 项 | 内容 |
+|---|---|
+| **用户裁决** | 「把这个改名功能给删掉,因为当前 PC 端也是没有这个功能的」 |
+| **删除范围** | `m-app-bar.ts`:移除 `editable` input、`titleTap` output、可编辑标题模板分支、`.m-title--editable` / `.m-title-pen` 样式(**文件已完全还原到改动前**)<br>`mobile/pages/chat/chat.ts`:移除 `titleEditable` / `titleEditOpen` / `titleDraft` 三个信号、`openTitleEdit` / `closeTitleEdit` / `onTitleDraft` / `submitTitle` 四个方法、编辑弹层模板与 160+ 行弹层样式 |
+| **保留** | `ChatSessionService.rename()` 服务层方法**未动**——它原本就存在(非本次新增),PC/手机两端都没有 UI 入口,保持原样 |
+| **验证** | `tsc` ✅ · `npm test` **1786 SUCCESS** · `npm run build` ✅ · 真机视口实测:`button[aria-label^="编辑标题"]` 计数 **0**、`.m-dlg-root` 计数 **0**、无 pageerror、抽屉「在本容器新建」仍在 |
+| **说明** | 阶段 A 其余改动(草稿按会话隔离、任务状态可见性、空态引导、抽屉快速新建、完成提醒、硬编码色值)**全部保留** |

+ 107 - 0
docs/04-quality/review/2026-11-14-手机端UX方案评审.md

@@ -0,0 +1,107 @@
+# 手机端 UX 方案评审报告
+
+- **评审对象**:《fmode-app 手机端操作体验优化方案》(FmodeCode / FmodeAgent 出品,2026-11-13)
+- **原文地址**:`https://s3.fmode.cn/user/K0xKEZANOG/fmode-app-ux/index.html`
+- **评审日期**:2026-11-14
+- **评审角色**:UI 设计 + 产品经理双视角
+- **评审方法**:逐条对照 v2 代码实测,不以方案自述为准
+
+---
+
+## 〇、首要发现:方案依据的是 v1 存量项目
+
+方案「开发落地清单」引用的文件路径,在 v2 中**全部不存在**:
+
+| 方案引用 | v2 实际情况 |
+|---|---|
+| `src/app/mobile/views/chat-view.component.ts` | `src/app/mobile/pages/chat/chat.ts`(1705 行) |
+| `src/app/components/drawer.component.ts` | `src/app/mobile/components/m-drawer/m-drawer.ts`(1209 行) |
+| `src/app/components/message-input.component.ts` | `src/app/mobile/components/m-dock/m-dock.ts`(274 行) |
+| `src/app/core/conversations.service.ts` | **不存在**;会话列表在 `mobile-chat-view.service.ts` |
+| `src/app/components/task-card.component.ts` | `src/app/mobile/components/m-task-card/m-task-card.ts` |
+| `TaskService.deriveTasks / runningCount / doneCount / headline` | **这些函数不存在**;v2 用 `TaskPulseService`(`task-pulse.service.ts`) |
+| `src/app/core/tasks.service.ts` 作数据源 | v2 的 `tasks.service.ts` 仅 4978 字节,无上述派生函数 |
+
+**结论**:方案按 `E:\work\fmode-app`(Angular 19 存量项目)编写,**不能直接照做**。
+其「改动边界:不触碰后端字段」的判断在 v2 语境下也不成立——第 7 条依赖的「交付物 project 字段」所对应的成果/项目路由已于 2026-11-11 用户裁决下线。
+
+---
+
+## 一、逐条真伪判定
+
+| # | 方案主张 | 判定 | 代码依据(v2 实测) |
+|---|---|---|---|
+| 1 | 会话太多切不动、看不见别的任务状态 | **真** | `messages.ts` 列表按 agent 排列;`TaskPulseService` 已有 `sessionIsWriting()` / `isBusy()` / `pulsesFor()` 但未在列表暴露 |
+| 2 | 任务切换入口放输入框右上角 + 底部抽屉 | **真(方向对)** | `/mobile/chat/:id` 是全屏路由(`mobile.routes.ts:9-11` 注释:shell 外、无 TabBar),切会话必须返回列表 |
+| 3a | 标题只生成一次、之后不更新 | **真** | `chat-session.ts:254` `shouldTitlize` 条件含 `isFirstMessage`,之后消息不改标题 |
+| 3b | 标题不能编辑 | **假** | `chat-session.service.ts:765` `rename()` 已实现且带 `titleLocked` 保护;`chat-session.ts:256` 有 `titleLocked` 判断。**只是手机端聊天页没有 UI 入口** |
+| 4 | 手机端是单行 `<input>`,回车即发送,长内容无法换行 | **假(伪意见)** | `m-dock.ts:26` 已是自增高 `<textarea>`;`onEnter()` 为实现「回车发送、Shift+Enter 换行」;`onInput()` 自动撑高至最大 120px |
+| 5 | 草稿切换会话会丢 | **真,且更严重** | `chat.service.ts:1872` `_draft` 为**全局单例 signal**,不按会话区分 → 不是「丢失」而是**跨会话串台**:A 会话草稿会带进 B 会话 |
+| 6 | 头像区与发送行之间留白多 | **弱(主观)** | 属视觉调优,按令牌框架内自主处理即可 |
+| 7 | 会话面板按「分身/人」分组 + 场景/项目标签 | **真但需降级** | 依赖的「交付物 project 字段」对应路由已于 2026-11-11 下线(`mobile.routes.ts:35-58`),分组维度需重设计 |
+| 8 | 分身内「+」快速建会话 | **真** | `m-new-chat` 组件(467 行)已存在,但只能从列表页触发;聊天页内直达可省一步 |
+| 9 | 任务完成即时提醒 | **真(有现成基础)** | `task-pulse.service.ts:55` `PulseKind = 'started' \| 'finished'`,`pulsesFor()` 就绪 |
+| 10 | 会话列表骨架屏 + 空态引导 | **真** | 列表加载态简陋;`m-empty` 组件已存在可复用 |
+
+### 汇总
+
+- **真意见 8 条**:1、2、3a、5、7(降级)、8、9、10
+- **伪意见 1 条**:4(现状描述错误,功能已实现)
+- **弱/主观 1 条**:6(并入视觉优化)
+- **方案误判 1 处**:3b(服务层已有能力,只是缺 UI)
+
+---
+
+## 二、对方案「改进设计」本身的设计评审
+
+### 采纳,但需调整
+
+**① 任务切换入口位置:采纳「输入框右上角」**
+- 方案的可达性论证成立(右手拇指舒适区)。
+- **但**:v2 聊天页顶栏右侧已有「成果抽屉」入口(`m-app-bar` 的 `barRight` 投影位),把第二个高频入口再塞进输入框右上角,会造成**两套右上角**,心智割裂。
+- **调整**:入口统一收进已有的右上角视觉带(顶栏右侧),底部 sheet 内承载列表。输入框区域只保留 `[附件][输入][发送]`,避免挤压输入区。
+
+**② 底部抽屉:采纳**
+- 复用 `m-drawer` 已有的底部 sheet 模式(`sheetTab('task')`),不新造组件。
+
+**③ 列表排序「进行中 → 异常 → 已完成」:部分采纳**
+- 「进行中置顶」成立。
+- **「已完成高亮绿勾」不采纳**:完成态是**终态**,长期高亮会让列表失去信息密度;改为「最近完成」在列表末尾弱化展示,或直接不展示。
+
+**④ 标题自动 LLM 重总结:不采纳(本期)**
+- 每次重总结 = 一次 LLM 调用,增加成本与延迟;且 v2 已有 `patchChatThread` 云端一致性链路,频繁改名会产生同步噪音。
+- **本期只做「可编辑」入口**(`rename()` 已就绪)。
+
+### 不采纳
+
+**⑤ 回车换行**:伪意见,现状已实现,改动反而会破坏现有交互。
+
+**⑥ 草稿方案「按 agentId 存 Map」**:
+- 方案说存 `agentId`,**错误**。v2 一个 agent 下可有多条会话(`chat/:id/:sessionId`),按 agentId 存仍会串台。
+- **正确键是 roomId / sessionId**。
+
+---
+
+## 三、本方案遗漏的问题(评审补充)
+
+以下是外部方案未提及、但 v2 实测存在的问题:
+
+1. **`chat.ts` 已 1705 行**,继续堆叠功能会失控。本次改动应尽量下沉到 service 或新组件。
+2. **`m-drawer` 已 1209 行**,底部 sheet 复用需谨慎,避免继续膨胀。
+3. **`m-dock` 缺 visualViewport 适配**:Android 键盘弹起时 `100dvh` 可能不收缩,输入区被键盘遮挡——**这也是打包成 App 后必现的问题**。
+4. **移动端无长按菜单**:复制只能靠点击按钮,不符合移动端习惯(`m-msg.ts` 无 `contextmenu` / `pointerdown`)。
+5. **`messages.ts:456` 存在硬编码 `rgba(0, 0, 0, 0.35)`**,与 ADR-002「零硬编码色值」冲突,应顺手修正。
+
+---
+
+## 四、结论
+
+方案**方向基本正确**(抓住了「切不动 / 看不见状态」这个核心),但:
+
+1. **技术依据失效**——按 v1 写的,v2 无法照搬;
+2. **1 条伪意见**(回车换行)需剔除;
+3. **1 条误判**(标题不可编辑)——服务层已有能力;
+4. **1 条低估**(草稿问题实际是串台 bug,且方案给的键选错了);
+5. **1 条不采纳建议**(LLM 重总结,成本收益不匹配)。
+
+采用「真意见 8 条中的 7 条 + 评审补充的 2 条」,进入阶段 A 实施。

+ 55 - 0
src/app/core/chat.service.spec.ts

@@ -753,6 +753,60 @@ describe('ChatService', () => {
       expect(svc.draft()).toBe('');
     });
 
+    /*
+     * 草稿按会话隔离(2026-11-14 修复)。
+     *
+     * 🔴 修复前的真实症状不是「丢草稿」,而是**串台**:
+     *    草稿是单个全局 signal,在 A 会话打了一半,切到 B 会话那半句话原样出现,
+     *    继续发就会发错人。`open()` 里的 `set('')` 又让它表现为「丢字」。
+     *    两个症状交替出现,用户体感是「有时候丢、有时候串」。
+     */
+    it('🔴 切到另一条会话时,看不到上一条会话的草稿', async () => {
+      realMode();
+      mxStub.timelineToMessages.and.returnValue([]);
+      apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
+
+      const a = await svc.startBlank('agent-node-lj-test', 'ws-test');
+      svc.setDraft('这是 A 会话没写完的话');
+
+      const b = await svc.startBlank('agent-node-lj-test', 'ws-test');
+      expect(a!.id).not.toBe(b!.id);
+      expect(svc.draft())
+        .withContext('B 会话不能出现 A 的草稿')
+        .toBe('');
+    });
+
+    it('🔴 切回原会话时,草稿还在', async () => {
+      realMode();
+      mxStub.timelineToMessages.and.returnValue([]);
+      apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
+
+      const a = await svc.startBlank('agent-node-lj-test', 'ws-test');
+      svc.setDraft('A 的半句话');
+
+      await svc.startBlank('agent-node-lj-test', 'ws-test');
+      await svc.open('agent-node-lj-test', a!.id);
+
+      expect(svc.draft()).withContext('切回来应恢复').toBe('A 的半句话');
+    });
+
+    it('两条会话的草稿互不覆盖', async () => {
+      realMode();
+      mxStub.timelineToMessages.and.returnValue([]);
+      apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
+
+      const a = await svc.startBlank('agent-node-lj-test', 'ws-test');
+      svc.setDraft('A 的话');
+      const b = await svc.startBlank('agent-node-lj-test', 'ws-test');
+      svc.setDraft('B 的话');
+
+      await svc.open('agent-node-lj-test', a!.id);
+      expect(svc.draft()).toBe('A 的话');
+      await svc.open('agent-node-lj-test', b!.id);
+      expect(svc.draft()).toBe('B 的话');
+    });
+
+
     it('空文本不发送(也不插气泡)', async () => {
       const before = svc.messages().length;
       await svc.sendText('   ');
@@ -1802,3 +1856,4 @@ describe('isAttachmentEcho —— 附件 body 判定', () => {
     expect(isAttachmentEcho(undefined, 'report.md')).toBeFalse();
   });
 });
+

+ 99 - 7
src/app/core/chat.service.ts

@@ -179,7 +179,25 @@ export class ChatService {
   private readonly _uploadPct = signal(0);
   /** 整批当前主导阶段(决定输入框下方那一条显示什么) */
   private readonly _uploadPhase = signal<UploadPhase>('idle');
-  private readonly _draft = signal('');
+  /**
+   * 草稿仓库:**按会话隔离**(2026-11-14)。
+   *
+   * 🔴 原实现是单个全局 signal —— 实测问题是**跨会话串台**而不是「丢失」:
+   *    在 A 会话打了半句话,切到 B 会话,那半句话原样出现在 B 的输入框里,
+   *    继续在 B 里发出去就会发错人。`openSession()` 里的 `_draft.set('')`
+   *    又会在此时清空,等于「串台 + 丢字」两个症状交替出现。
+   *
+   * 键选 `sessionId`(会话 / 线程根)而不是 `agentId`:
+   *    一个容器下可有多条会话(`/chat/:id/:sessionId`),按 agentId 存仍会串台。
+   *
+   * 键为 null(尚未创建会话的新建态)时用 `NEW_DRAFT_KEY` 兜底,
+   * 保证「还没发出第一条消息」时打的字也能留住。
+   */
+  private readonly _draftStore = signal<ReadonlyMap<string, string>>(new Map());
+  /** 新建态(sessionId 还是 null)的草稿键 */
+  private readonly _draft = computed(
+    () => this._draftStore().get(this._draftKey()) ?? '',
+  );
   private readonly _peerTyping = signal(false);
   /**
    * 容器发 `m.typing=false` 的**本地接收时刻**(epoch ms,0 = 本次会话还没收到过)。
@@ -304,7 +322,8 @@ export class ChatService {
   readonly uploadPct = this._uploadPct.asReadonly();
   /** 整批上传阶段(输入框下方那条进度条据此切文案,2026-11-11) */
   readonly uploadPhase = this._uploadPhase.asReadonly();
-  readonly draft = this._draft.asReadonly();
+  /** 当前会话的草稿(按会话隔离,切走再切回仍在,见 `_draftStore`) */
+  readonly draft = this._draft;
   /** 对方正在输入(真实态由 typing 事件驱动) */
   readonly peerTyping = this._peerTyping.asReadonly();
   /**
@@ -726,7 +745,13 @@ export class ChatService {
     this._state.set('loading');
     this._error.set(null);
     this._messages.set([]);
-    this._draft.set('');
+    /*
+     * 🔴 这里**不再清草稿**(2026-11-14)。
+     *
+     * 本方法是「打开某个会话」,切换会话时切走再切回应该能看到自己打了一半的字。
+     * 原实现在此 `_draft.set('')`,导致切会话 = 丢草稿;而草稿本身又是全局单例,
+     * 于是表现为「有时丢、有时串台」。现在草稿按 `_draftKey()` 分桶,这里什么都不用做。
+     */
     this._peerTyping.set(false);
     this._turnEndedAt.set(0);
     this._turnEndedAfter.set(null);
@@ -1853,7 +1878,11 @@ export class ChatService {
     this._state.set('idle');
     this._error.set(null);
     this._connection.set('disconnected');
-    this._draft.set('');
+    /*
+     * 会话已关闭:把**当前这条**会话的草稿清掉(2026-11-14 按会话分桶后)。
+     * 注意必须在 `_sessionId` 置空**之后**清,否则会误清到别的桶。
+     */
+    this.clearDraft();
     this._peerTyping.set(false);
     this._awaitingReplySince.set(0);
     this._turnEndedAt.set(0);
@@ -1870,13 +1899,55 @@ export class ChatService {
   }
 
   setDraft(text: string): void {
-    this._draft.set(text);
+    this.writeDraft(text);
     // 真实态下发 typing 通知(失败非致命)
     if (this.roomId && !environment.useMockData) {
       void this.mx.setTyping(this.roomId, text.length > 0);
     }
   }
 
+  // ---------- 草稿仓库(按会话隔离,2026-11-14) ----------
+
+  /** 当前草稿归属的键:有会话用 sessionId,否则用 agentId(新建态) */
+  private _draftKey(): string {
+    return this._sessionId() ?? (this._agentId() ? `agent:${this._agentId()}` : '');
+  }
+
+  /** 写当前会话的草稿;空串直接删键,避免仓库无限增长 */
+  private writeDraft(text: string): void {
+    const key = this._draftKey();
+    if (!key) return;
+    this._draftStore.update((m) => {
+      const next = new Map(m);
+      if (text) next.set(key, text);
+      else next.delete(key);
+      return next;
+    });
+  }
+
+  /** 清空当前会话的草稿(发送成功、主动丢弃时用) */
+  private clearDraft(): void {
+    this.writeDraft('');
+  }
+
+  /**
+   * 会话切换时**迁移草稿键**(2026-11-14)。
+   *
+   * 新建态打的字挂在 `agent:<id>` 下;会话真正创建出来后(sessionId 出现),
+   * 这段文字必须跟着挪过去,否则用户「打了一半 → 会话建好 → 字没了」。
+   */
+  private adoptDraft(fromKey: string, toKey: string): void {
+    if (!fromKey || !toKey || fromKey === toKey) return;
+    const carried = this._draftStore().get(fromKey);
+    if (!carried) return;
+    this._draftStore.update((m) => {
+      const next = new Map(m);
+      next.delete(fromKey);
+      if (!next.get(toKey)) next.set(toKey, carried);
+      return next;
+    });
+  }
+
   /** 发送文本(AC-04.2);失败留痕可重试(AC-04.8) */
   /**
    * 向**指定容器**发送一条消息,**不影响当前会话状态**(2026-11-05)。
@@ -2100,7 +2171,14 @@ export class ChatService {
      * 填补「发出 → 容器开始跑」的真空期,让立刻再发的那条能被拦住。
      */
     this._awaitingReplySince.set(Date.now());
-    this._draft.set('');
+    /*
+     * 🔴 草稿清空**不能放在这里**(2026-11-14 按会话分桶后)。
+     *
+     * 此刻 `_sessionId()` 可能还是 null,`clearDraft()` 会清到 `agent:<id>` 那个桶;
+     * 而紧接着 2193 行 `_sessionId.set(created.id)` 会切桶 —— 真正该清的
+     * 是**新会话这一桶**,否则「新建态打半句 → 发送 → 剩下的字串到新会话」。
+     * 改为在建会话/确定 sessionId **之后**清。
+     */
     // 演示态:留一份存量,刷新后能恢复(真实态历史以 Matrix 为准)
     this.rememberDemoHistory();
     this._sending.set(true);
@@ -2119,6 +2197,11 @@ export class ChatService {
     if (!this._sessionId()) {
       const created = this.ensureSessionForSend();
       if (created) {
+        /*
+         * 新建态的草稿挂在 `agent:<id>` 下;会话键一出现就要**迁移过去**,
+         * 否则用户「打一半 → 会话建好」时字会消失(2026-11-14)。
+         */
+        this.adoptDraft(this._draftKey(), created.id);
         this._sessionId.set(created.id);
         /*
          * 🔴 必须同时把状态从 empty 切到 ready(2026-11-01 修复)。
@@ -2133,6 +2216,12 @@ export class ChatService {
         this._state.set('ready');
       }
     }
+    /*
+     * 发送成功:清掉**当前会话**的草稿(2026-11-14 按会话分桶后)。
+     * 放在这里是因为此刻 `_sessionId()` 已确定(无论是切换来的还是刚建的),
+     * `clearDraft()` 才能清到正确的桶。
+     */
+    this.clearDraft();
     /*
      * 会话元信息(标题/预览/时间)随**用户发出的消息**推进。
      *
@@ -3026,6 +3115,8 @@ export class ChatService {
        */
       if (!found) {
         const recreated = this.sessions.create(roomId, agentId);
+        // 键变了 → 草稿跟着挪(2026-11-14),否则用户打了一半的字会消失
+        this.adoptDraft(this._draftKey(), recreated.id);
         this._sessionId.set(recreated.id);
         /*
          * 🔴 换了会话标识,内存里属于旧会话的消息必须清掉(2026-11-09)。
@@ -3794,7 +3885,8 @@ export class ChatService {
     this._messages.update((list) => list.filter((m) => m.id !== localId));
     /* 文字回填:用户刚打的内容不能丢 */
     const text = victim?.text?.trim();
-    if (text && !this._draft().trim()) this._draft.set(text);
+    // 2026-11-14:草稿已按会话分桶,回填写进**当前会话**这一桶
+    if (text && !this._draft().trim()) this.writeDraft(text);
     /* 暂存附件清空(上传已失败,重新选比留着更有确定性) */
     this.clearPendingFiles();
   }

+ 35 - 0
src/app/mobile/components/m-drawer/m-drawer.ts

@@ -62,6 +62,17 @@ interface NavRow {
           <b>新建对话</b>
           <span class="m-entry-kbd">选择数字生命</span>
         </button>
+        @if (quickNewAgentId()) {
+          <button
+            type="button"
+            class="m-entry m-entry--quick"
+            (click)="quickNew.emit(quickNewAgentId()!)"
+          >
+            <span class="m-entry-lead"><i class="fa-solid fa-plus" aria-hidden="true"></i></span>
+            <b>在本容器新建</b>
+            <span class="m-entry-kbd">不用再挑一次</span>
+          </button>
+        }
       </div>
 
       <nav class="m-drawer-nav" aria-label="功能">
@@ -490,6 +501,19 @@ interface NavRow {
         color: var(--primary);
         font-weight: 600;
       }
+      /*
+       * 「在本容器新建」(2026-11-14):次于主入口,但不能弱到看不见。
+       * 用 surface-2 底 + 常规色文字,视觉上是一条「就近快捷方式」而非第二个主按钮,
+       * 避免与上方「新建对话」抢主次。
+       */
+      .m-entry--quick {
+        margin-top: var(--sp-1);
+        background: var(--surface-2);
+        font-size: var(--fs-13);
+      }
+      .m-entry--quick .m-entry-lead {
+        color: var(--ink-2);
+      }
       .m-entry-kbd {
         font-size: var(--fs-11);
         color: var(--muted);
@@ -880,6 +904,17 @@ export class MDrawerComponent {
   readonly activeSessionId = input<string | null>(null);
   readonly close = output<void>();
   readonly newChat = output<void>();
+  /**
+   * 在**当前容器**再开一条对话(2026-11-14)。
+   *
+   * 聊天页把当前 agentId 传进来,抽屉顶部就多出一个「在本容器新建」快捷入口;
+   * 其余页面不传 → `null` → 该入口不渲染,行为与之前完全一致。
+   *
+   * 为什么不直接放顶栏按钮:聊天页右侧已有两个按钮,再塞第三个会把
+   * 标题区挤到 `3×44+28px` 的预留里,长标题更早被省略号截断。
+   */
+  readonly quickNewAgentId = input<string | null>(null);
+  readonly quickNew = output<string>();
   /** 在指定工作区新建对话(payload = workspaceId) */
   readonly newChatWithWs = output<string>();
 

+ 13 - 3
src/app/mobile/components/m-empty/m-empty.ts

@@ -16,10 +16,10 @@ import { ChangeDetectionStrategy, Component, input, output } from '@angular/core
       @if (description()) {
         <p class="m-empty-desc">{{ description() }}</p>
       }
-      @if (retryable()) {
+      @if (retryable() || actionLabel()) {
         <button type="button" class="m-empty-btn" (click)="retry.emit()">
-          <i class="fa-solid fa-rotate-right" aria-hidden="true"></i>
-          重试
+          <i [class]="actionIcon()" aria-hidden="true"></i>
+          {{ actionLabel() || '重试' }}
         </button>
       }
     </div>
@@ -80,5 +80,15 @@ export class MEmptyComponent {
   readonly icon = input('fa-solid fa-inbox');
   readonly compact = input(false);
   readonly retryable = input(false);
+  /**
+   * 行动按钮文案(2026-11-14)。
+   *
+   * 原实现只有「重试」一种语义,非法错误场景外没有按钮可点 ——
+   * 空态里用户只能干看着,不知道该做什么。现在空态也能给下一步动作:
+   * 传 `actionLabel="新建对话"` 并监听 `(retry)` 即可(复用同一个输出,
+   * 与既有调用点向后兼容,多了个输入而已)。
+   */
+  readonly actionLabel = input('');
+  readonly actionIcon = input('fa-solid fa-rotate-right');
   readonly retry = output<void>();
 }

+ 108 - 2
src/app/mobile/pages/chat/chat.ts

@@ -24,7 +24,7 @@ import { BadgeTone, MBadgeComponent, TASK_TONE, TASK_STATUS_TEXT } from '../../c
 import { ChatService } from '../../../core/chat.service';
 import { PENDING_DRAFT_KEY } from '../home/home';
 import { LoopGuardService } from '../../../core/loop-guard.service';
-import { Deliverable, Task } from '../../../core/api/models';
+import { Agent, Deliverable, SemanticTone, Task } from '../../../core/api/models';
 import { ChatSessionService } from '../../../core/chat-session.service';
 import { TasksService } from '../../../core/tasks.service';
 import { fmtSize } from '../../../core/format';
@@ -39,6 +39,8 @@ import {
 } from '../../../core/message-segments';
 import { Message } from '../../../core/api/models';
 import { sessionPresence } from '../../../core/agent-liveness';
+import { TaskPulseService } from '../../../core/task-pulse.service';
+import { UiMToastService } from '../../../ui/mobile/components/toast/toast';
 import { fileIdentity } from '../../../core/s3-link';
 import { mergeConsecutiveBodies } from '../../../core/message-merge';
 import { ToolActivityComponent } from '../../../components/domain/tool-activity/tool-activity';
@@ -133,9 +135,11 @@ type SheetTab = 'task' | 'file' | 'deliver' | 'state';
       <app-m-drawer
         [open]="drawerOpen()"
         [activeSessionId]="chat.sessionId()"
+        [quickNewAgentId]="chat.agent()?.id ?? null"
         (close)="drawerOpen.set(false)"
         (newChat)="drawerOpen.set(false); back()"
         (newChatWithWs)="drawerOpen.set(false); back()"
+        (quickNew)="newChatInCurrent($event)"
       />
 
       @if (chat.connection() === 'disconnected' && chat.state() !== 'loading') {
@@ -311,6 +315,7 @@ type SheetTab = 'task' | 'file' | 'deliver' | 'state';
       </div>
     </div>
 
+
     <!-- 全屏预览层(2026-11-03):点会话内文件/成果都走这里,不再新开标签页 -->
     <app-image-lightbox
       [open]="!!lightbox()"
@@ -903,6 +908,14 @@ export class MbChatPage implements OnDestroy {
   protected readonly fileBinding = inject(AgentFileBindingService);
   protected readonly chat = inject(ChatService);
   protected readonly loop = inject(LoopGuardService);
+  /**
+   * 任务脉冲(2026-11-14 接入)。
+   *
+   * 桌面端聊天顶栏早就用 `pulse.describe()` 显示「正在处理…」,
+   * 手机端却只有「在线 / 正在回复」——用户在手机上根本看不出容器到底在不在干活。
+   * 这里补齐,与桌面端同一判据、同一文案来源。
+   */
+  protected readonly pulse = inject(TaskPulseService);
 
   protected readonly sheetOpen = signal(false);
   /** 图片走灯箱 */
@@ -1039,8 +1052,25 @@ export class MbChatPage implements OnDestroy {
    *    条件消失(收到回复)后自动撤掉,避免残留。
    */
   private readonly alert = inject(AlertService);
+  /**
+   * 轻提示(2026-11-14)。
+   *
+   * 底座 `UiMToastService`:2 秒自动消失的普通提示 —— 「任务完成」正好属于
+   * alert.service 注释里划给它的那一类(不该用常驻的告警条,那会一直杵在屏上)。
+   * 容器已由 `app.ts` 全局挂载 `UiMToast`,直接注入即可。
+   */
+  private readonly toast = inject(UiMToastService);
   private static readonly TIMEOUT_TITLE = '容器长时间未响应';
 
+  /** 用时 → 人话(完成提醒专用;与 task-pulse 的 fmtDur 同口径,那边未导出) */
+  private static durText(ms: number): string {
+    const sec = Math.max(0, Math.round(ms / 1000));
+    if (sec < 60) return `${sec} 秒`;
+    const m = Math.floor(sec / 60);
+    const s = sec % 60;
+    return s ? `${m} 分 ${s} 秒` : `${m} 分钟`;
+  }
+
   private watchTurnTimeout(turns: readonly AssistantTurn[]): void {
     /*
      * 🔴 只看最后一轮(2026-11-10,与桌面同口径):更早的回合不论有没有回复,
@@ -1120,6 +1150,17 @@ export class MbChatPage implements OnDestroy {
   protected readonly title = computed(
     () => this.chat.currentSession()?.title ?? this.chat.agent()?.name ?? '会话',
   );
+
+  /**
+   * 在**当前容器**再开一条对话(2026-11-14)。
+   *
+   * 直接复用 `/chat/:agentId/new` —— 与 m-drawer 的「新建对话」最终落到同一条
+   * 建房链路(`startBlankWithRoom`),不新造逻辑。
+   */
+  protected newChatInCurrent(agentId: string): void {
+    this.drawerOpen.set(false);
+    void this.router.navigateByUrl(`/mobile/chat/${encodeURIComponent(agentId)}/new`);
+  }
   /**
    * 容器名 + 状态(2026-11-11 收敛)。
    *
@@ -1128,9 +1169,43 @@ export class MbChatPage implements OnDestroy {
    * 离线时相位一律不显示(不能说明明断了还在回复)。
    */
   protected readonly presence = computed(() =>
-    sessionPresence(this.chat.agent(), this.chat.peerTyping() ? 'replying' : undefined),
+    this.taskPresence(this.chat.agent()),
   );
 
+  /**
+   * 「在线 + 在干什么」的合并判定(2026-11-14)。
+   *
+   * 与桌面端 `desktop/pages/chat/chat.ts::taskLine()` 同一套口径:
+   *   ① 本回合刚收到 ✅ 且尚未刷新 → 直接「在线」,别让顶栏还转着
+   *   ② 容器在跑 → 用 `pulse.describe()` 的一句话任务文案
+   *   ③ 其余交回 `sessionPresence`(离线/回复中/在线)
+   *
+   * 只在**在线且正在干活**时接管文案;离线时一律走 liveness,
+   * 不能因为本地还记着"刚才在跑"就瞒着用户说它在线。
+   */
+  private taskPresence(
+    agent: Agent | undefined,
+  ): { text: string; tone: SemanticTone; busy: boolean } {
+    const base = sessionPresence(agent, this.chat.peerTyping() ? 'replying' : undefined);
+    if (!base.busy && base.text === '在线' && this.chat.turnSettledLocally()) {
+      return { text: '在线', tone: 'success', busy: false };
+    }
+    const line = this.pulse.describe(
+      agent,
+      Date.now(),
+      this.chat.sessionId(),
+      this.chat.currentRoomId(),
+      this.chat.myMxId(),
+    );
+    if (!line.active) return base;
+    const task = this.chat.sessionTask();
+    return {
+      text: task || line.text || '正在处理…',
+      tone: line.tone === 'neutral' ? 'info' : line.tone,
+      busy: true,
+    };
+  }
+
   protected readonly subtitle = computed(() => {
     const a = this.chat.agent();
     if (!a) return '';
@@ -1313,6 +1388,37 @@ export class MbChatPage implements OnDestroy {
       if (a.title === MbChatPage.TIMEOUT_TITLE) this.resendLast();
     });
 
+    /*
+     * 任务完成即时提醒(2026-11-14)
+     *
+     * 🔴 为什么需要:用户在手机上切走看别的,回来时容器早就干完了,
+     *    界面上什么都看不出来 —— 只能自己去翻成果抽屉猜「到底完没完」。
+     *
+     * 数据源**不新造**:`TaskPulseService` 的 running → idle 翻转已经算好了
+     * (`finished` 脉冲自带任务描述与用时)。这里只做「新完成的脉冲 → 弹一次」。
+     *
+     * 只提醒**本容器**的完成:别的容器在跑什么,用户在另一个会话里自然能看到,
+     * 在当前会话里弹别人的完成提示只会造成干扰。
+     */
+    let seenPulseAt = 0;
+    effect(() => {
+      const agentId = this.chat.agent()?.id;
+      const all = this.pulse.pulses();
+      if (!agentId) return;
+      const mine = all.filter((p) => p.kind === 'finished' && p.agentId === agentId);
+      const latest = mine[0];
+      if (!latest) return;
+      /* 首次进入只建立基线,不补弹历史完成 */
+      if (seenPulseAt === 0) {
+        seenPulseAt = latest.at;
+        return;
+      }
+      if (latest.at <= seenPulseAt) return;
+      seenPulseAt = latest.at;
+      const spent = latest.durationMs ? `,用时 ${MbChatPage.durText(latest.durationMs)}` : '';
+      this.toast.success(`${latest.task ? latest.task + ' 已完成' : '任务已完成'}${spent}`);
+    });
+
     /*
      * 右侧栏:会话切换或消息数变化时重拉文件台账(2026-11-10,与桌面端同口径)。
      * 容器产出的文件要等登记完才进台账,回合结束消息数变化正好触发刷新。

+ 30 - 1
src/app/mobile/pages/messages/messages.ts

@@ -209,6 +209,9 @@ type MsgFilter = 'all' | 'group' | 'workspace';
             [title]="emptyTitle()"
             [description]="emptyDesc()"
             icon="fa-solid fa-comments"
+            [actionLabel]="emptyActionLabel()"
+            [actionIcon]="emptyActionIcon()"
+            (retry)="onEmptyAction()"
           />
         } @else {
           <!--
@@ -453,7 +456,8 @@ type MsgFilter = 'all' | 'group' | 'workspace';
       .m-msgws-mask {
         position: absolute;
         inset: 0;
-        background: rgba(0, 0, 0, 0.35);
+        /* 2026-11-14:原为硬编码 rgba(0,0,0,.35),违反 ADR-002「零硬编码色值」→ 改走遮罩令牌 */
+        background: var(--overlay);
       }
       .m-msgws-sheet {
         position: relative;
@@ -929,6 +933,31 @@ export class MbMessagesPage {
     return '点右上角新建一个对话';
   });
 
+  /*
+   * 空态引导动作(2026-11-14)
+   *
+   * 原先空态只有一行字,用户看到「点右上角新建一个对话」还得自己去找那个按钮。
+   * 现在直接把下一步动作摆在空态里 —— 「看得见的下一步」比「文字说明」有效得多。
+   *
+   * 搜索无结果时**不给按钮**:那时候没有"应该做的事",给个「新建对话」
+   * 反而会让人误以为"搜不到就该新建一条"。
+   */
+  protected readonly emptyActionLabel = computed(() => {
+    if (this.keyword().trim()) return '';
+    if (this.filter() === 'workspace') return '';
+    return '新建对话';
+  });
+
+  protected readonly emptyActionIcon = computed(() => 'fa-solid fa-plus');
+
+  protected onEmptyAction(): void {
+    if (this.filter() === 'group') {
+      this.sheetOpen.set(true);
+      return;
+    }
+    this.router.navigateByUrl('/mobile/contacts');
+  }
+
   constructor() {
     // 目录未拉取时按当前可用容器预热一次(与桌面左栏 effect 同口径)
     /*