소스 검색

fix(chat): 会话隔离收尾 + 容器状态全站统一 + 输出状态口径一致

会话隔离(新建会话不再带出历史):
- paginate() 补新建态保护 —— 此前三条回填路径中唯一没有保护的,
  且 paginateBack() 翻的是整个房间而非当前 thread
- startBlank() 清空 _ownedThreadRoots,避免残留上一会话的容器线程根
- hasMoreHistory 在新建态恒为 false(paginationToken 是房间级,与 thread 无关)
- PC/移动 fillViewportIfNeeded 加 state==='empty' 短路

容器状态(全站收敛到 resolveAgentState):
- PC 新建会话弹窗新增状态展示(圆点 + 文案,按 tone 上色)
- PC 左栏会话行、移动抽屉会话行新增状态点
- 移动通讯录 / 生命卡 / 聊天页副标题 / 状态抽屉从裸 status 切到合成态
- 两端工作台「运行中/需要关注」计数与筛选改用合成态,移动端补上漏掉的 stopped
- TaskPulse.describe 先判不可用,并补 isReporterSelfTalk 过滤
- isReporterSelfTalk 加宽:currentTask 命中上报器文案也算自指

输出状态(「只闪一下正在回复就消失」):
- 统一静默窗口 QUIET_MS = 25s(原回合卡 3s / 顶栏 20s 口径打架)
- 新增 agentIdle 判据:容器明确报 idle 时立即收尾

归档:
- 归档回到原分组(群聊/工作区/日常各自独立),PC + 移动同步

验证:tsc 通过 / npm test 1339 SUCCESS / ng build 通过

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 4 시간 전
부모
커밋
f6f6e48d82

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

@@ -6835,3 +6835,265 @@ AC-19.2(不 @ 拦截)、AC-19.3(@ 非成员标红)、AC-19.4/19.5(成
 
 - P3 移动群聊、P4 单聊两端(桌面单聊仍是扁平消息流)、P5 全量清理
 - 群聊页音视频目前落到右栏,未做与日常页一致的居中媒体模态
+
+---
+
+## 2026-11-08 · 移动端抽屉滚动修复 + 新建会话串数据修复
+
+**触发**:用户真机实测(图38/39)——① 移动端左侧抽屉「新建对话 + 导航块」不跟随下方会话列表滚动;② 新建会话后仍带出上一次的旧内容,左栏出现多条外观相同的同名会话。
+
+### 根因
+
+| # | 问题 | 根因 | 修复 |
+|---|---|---|---|
+| 1 | 抽屉上半部分不跟随滚动 | `.m-drawer-body` 是唯一滚动容器,但 flex 子项默认 `min-height:auto` 会撑到内容高度,`flex:1 + overflow-y:auto` 失效;另外 `:host{display:contents}` 与注释描述的 `display:block` 修复相矛盾 | `.m-drawer-body` 加 `min-height:0` + `overscroll-behavior:contain`;`.m-drawer-head/.m-drawer-body` 加 `flex:none`;`:host` 改回 `display:block`(与注释一致) |
+| 2 | 新建会话串旧数据(消息层) | `appendIncoming` 用 `belongsToSession(live=true)` 过滤,对「新建态尚无 thread 根」的会话一律放行 → 旧事件被当成新消息追加,并 `noteMessage` 污染预览/标题 | `appendIncoming` 入口加 `_blankSessionId` 判据(与 storeVersion effect 同口径);去重键改用 `eventId` 统一 |
+| 3 | 新建态标记残留 | `close()` 未复位 `_blankSessionId` | `close()` 复位 |
+| 4 | 在途 `openReal` 迟到写回 | `startBlank()` 不经过 `openReal`,不自增 `seq` | `startBlank()` 开头自增 `seq` |
+| 5 | 空壳被 keepId 复活(目录层) | `hydrateOnce` 的 `keepId` 分支会把「云端没有、messageCount=0」的空壳捡回列表,与 `pruneEmpty` 的收敛意图冲突 | `keep` 仅对 `messageCount>0` 且不在 `_pendingPush` 的会话生效 |
+
+### 涉及文件
+
+- `src/app/mobile/components/m-drawer/m-drawer.ts`(模板 + 样式)
+- `src/app/core/chat.service.ts`(`startBlank` / `appendIncoming` / `close`)
+- `src/app/core/chat-session.service.ts`(`hydrateOnce` keep 分支)
+- `src/app/core/chat-session.service.spec.ts`(新增 2 条用例:空壳不因 keepId 复活 / 有内容会话仍被 keep)
+
+### 验证
+
+| 项 | 结果 |
+|---|---|
+| `tsc --noEmit`(app + spec) | ✅ |
+| `npm test`(Karma + ChromeHeadless) | ✅ **1332 SUCCESS**(原 1330 + 新增 2) |
+| `npm run build` | ✅ |
+
+### 未验证项 / 已知限制
+
+- 抽屉滚动修复为**代码层推断 + 类型/构建通过**,尚未在真机复现截图 38 的场景确认(待用户实测)
+- 已知未处理:缓存 key 不含 companyId;`mock/chat-threads`、`fmode_demo_history_v1` 未按用户隔离;移动端未读按 agent 计而非按会话计
+
+### 追加修正(同日,用户第二次反馈图40)
+
+用户明确指出期望结构:**抽屉最外层不滚动;只有「对话」标题以下的列表区滚动;底部空白要利用起来。**
+
+- 模板拆成三段:`.m-drawer-head`(新建对话)/ `.m-drawer-nav`(会话/项目/任务/通讯录/更多,固定)/ `.m-drawer-body`(「对话」+ 工作区/群聊/日常/已归档,唯一滚动区)
+- `.m-drawer` 的 `padding-bottom` 由 `tabbar-h + safe-bottom + sp-4` 改为 `safe-bottom + sp-3` —— 底栏在抽屉打开时已被 `body:has(.m-drawer.show) .m-tabbar` 隐藏,原来那 70px 就是底部空白
+- 滚动区顶部间距改由 `.m-drawer-caption` 的 `margin-top` 承担,避免标题被滚到一半被切断
+
+**实测(Chrome 390×844 + mock 环境 + 30 条会话)**:
+
+| 断言 | 结果 |
+|---|---|
+| 抽屉外层 `overflowY` / `scrollTop` | `hidden` / 滚动后仍为 `0` ✅ |
+| 滚动区 `.m-drawer-body` | `scrollTop` 1403、内容高 1930 > 视口 527 ✅ |
+| 导航块位置 | 滚动前后 top 均为 72px,不动 ✅ |
+| 底部空白 | 由约 70px 降为 12px(仅安全区) ✅ |
+| `npm test` | ✅ 1332 SUCCESS |
+
+---
+
+## 2026-11-08 · 归档回到原分组(PC + Mobile 统一)
+
+**触发**:用户反馈「群聊点归档,跑到日常对话的已归档里去了」,要求 PC 与移动端统一判断。
+
+**根因**:三端(桌面左栏 / 抽屉 / 移动列表投影)的归档分组**只按 `workspaceId` 判断**。
+群会话的 `workspaceId` 恒为 `null`(群聊不归属工作区),于是「群聊 → 归档」被算成「日常 → 归档」。
+桌面端 `doArchive()` 展开归档组时也统一用 `owner ?? '__daily__'`,进一步把用户视线引向日常分组。
+
+**修复**:
+
+| 层 | 文件 | 改动 |
+|---|---|---|
+| 判据(新增,单一真源) | `src/app/core/chat-session.ts` | 新增 `groupOf(session)` → `workspace:<id>` / `group` / `daily`;`groupKey()` 供折叠状态共用 |
+| 移动投影 | `src/app/core/mobile-chat-view.service.ts` | 新增 `archivedGroups` 计算属性,把已归档按原分组切成 `daily` / `group` / `byWorkspace` |
+| 移动抽屉 | `src/app/mobile/components/m-drawer/m-drawer.ts` | 删除底部统一「已归档」小节;改为每个分组(工作区/群聊/日常)内嵌「已归档」子块(默认收起);归档后自动展开对应子块 |
+| 桌面左栏 | `src/app/components/domain/session-tree/session-tree.ts` | `sessionsIn` / `archivedIn` / `dailyArchived` 改走 `groupOf`;群聊分组新增「已归档」子块;`doArchive` 展开键修正为 `__group__` |
+| 测试 | `src/app/core/chat-session.spec.ts` | 新增 4 条 `groupOf` / `groupKey` 用例 |
+
+**验证**:
+
+| 项 | 结果 |
+|---|---|
+| `tsc --noEmit`(app + spec) | ✅ |
+| `npm test` | ✅ **1336 SUCCESS**(原 1332 + 新增 4) |
+| `npm run build` | ✅ |
+
+**未验证项**:真机 / 浏览器实测尚未跑(4200 端口为真实环境,需要真实账号登录才能复现归档 UI)。
+
+---
+
+## 2026-11-08 · 新建会话发一句「你好」仍带出历史(根因 + 修复)
+
+**触发**:用户实测——新建对话,随便选个容器发「你好」,界面仍出现之前的历史记录。
+
+**诊断过程**:加了运行时诊断(`__fmodeDebugOwner` 开关 + `belongsToSession` 现场日志),
+用 `demo-fmode` 在 mock 环境重放。日志为空/不触发,结合阅读编译产物定位到 `sendText` 的执行顺序:
+
+```
+commit(本地记账)→ _state='ready' → _blankSessionId=null   ← 保护在这一刻就没了
+    ↓ 约 1 秒(sendReal 网络请求在途)
+/sync 落地 → storeVersion effect → 历史回填
+```
+
+**根因**:`_blankSessionId`(新建态保护)曾在 commit 时清除,而那时消息**还没真正发出去**。
+此间 `/sync` 落地触发回填,`storeVersion` effect 的两道保护
+(`_blankSessionId` 判据 + `state==='empty'` 判据)**同时失效**:
+`rootsOfSession()` 把该房间所有旧的容器子线程根都算成本会话的,
+`belongsToSession` 于是认领了旧线程下的一切内容。
+
+**修复**:`_blankSessionId` 改为在 `sendReal()` 成功返回后才清除;发送失败也清除
+(否则该会话永远收不到容器回复)。用户自己那条消息不受影响——它走 `sendText`
+的乐观插入,不进 `appendIncoming`。
+
+| 项 | 结果 |
+|---|---|
+| `tsc --noEmit`(app + spec) | ✅ |
+| `npm test` | ✅ **1337 SUCCESS**(新增「发送请求未返回前,实时消息不得混入新会话」) |
+| `npm run build` | ✅ |
+| 临时诊断代码 | 已全部移除(`chat.service.ts` 尾部诊断块 + `app.ts` 调用) |
+
+**未验证项**:真实账号端到端复测未做(4200 为真实环境,无可用测试账号)。请用真实账号复验一次。
+
+### 追加修正 ③(同日,用真实账号端到端复现后定稿)
+
+用户提供真实 Token 后,在 4200 真实环境完成端到端复现。
+
+**关键事实(实测)**:
+
+| 观测 | 数据 |
+|---|---|
+| 新建会话后 | `n=0, blank=true, state=empty`(正确) |
+| 按 `Enter` 发送后 **150ms** | `n=18`,含 16 条旧的 agent 过程消息 |
+| `appendIncoming` 调用次数 | **0 次**(历史不经实时流) |
+| 历史来源 | `storeVersion` effect 的历史回填分支 |
+| 容器差异 | 只有 `app-lj-test-v1` 复现;`lj-test` / `lj` 正常 |
+| 差异成因 | 该容器 8 个会话分布在 3 个房间(含 2 个伪 roomId),房间内旧 approot 线程根多达 20 个 |
+
+**根因(前两次修复都不彻底的原因)**:
+「解除新建态保护」的时机选了**时间点**(commit 时 / sendReal 返回时),
+而真正该依据的是**状态**——`/sync` 落地时 timeline 里是否已存在本会话的消息。
+`sendReal` 返回只代表本机发出,服务端尚未并入 timeline;保护一撤,
+`rootsOfSession` 就把房间内所有旧容器线程根算作本会话的。
+
+**最终修复**(`src/app/core/chat.service.ts`):
+- `storeVersion` effect:仍在新建态时,检查 `raw.some(m => m.threadId === sid)`;
+  未落地 → 继续拦截;已落地 → 解除保护,按正常规则回填
+- `sendText`:**不再**在成功路径解除保护;只在**发送失败**时解除(避免会话彻底静默)
+- `appendIncoming`:新建态下放行 `threadId === sid` 的消息(用户自己那条),其余仍拦
+
+**验证**:
+
+| 项 | 结果 |
+|---|---|
+| `app-lj-test-v1`(问题容器) | ✅ `n=4`,全部为本会话消息,无历史 |
+| `lj-test` | ✅ `n=3`,无历史 |
+| `lj` | ✅ `n=3`,无历史 |
+| `npm test` | ✅ 1337 SUCCESS |
+| 截图 | 消息区仅「你好 / 回复 / 你好 / 你好」,无 `09:42` 等旧时间戳 |
+
+**已知限制**:用户消息与乐观消息在界面短期各显示一条(待同步回执后合并),属既有行为,本次未改动。
+
+---
+
+## 2026-11-08 · 容器状态多端统一 + 输出状态口径统一
+
+**触发**:用户反馈「很多地方的容器状态不对」,要求 PC / 移动端同步展示容器具体状态,并给 PC 新建会话补上状态展示。
+
+### 根因:项目里存在 4 套互不相通的状态判定
+
+| 体系 | 判定函数 | 用在哪 |
+|---|---|---|
+| A 运行态(原始) | `AGENT_STATUS_TEXT` / `agentStatusTone` / `AGENT_TONE` / `statusText ?? status` | 移动通讯录、m-life-card、移动聊天副标题、两端工作台统计 |
+| B 接入态 | `agentPresence()` | PC 通讯录、PC 聊天页连接横幅 |
+| C 合成态 | `resolveAgentState()`(判心跳、防上报器自指) | 仅移动会话列表、移动新建弹窗 |
+| D 任务行 | `TaskPulseService.describe()` | PC 头部/右栏/群聊右栏 |
+
+同一容器在不同页面给出相反结论,典型如「45 天没心跳的僵尸容器」:移动会话列表判红「离线」,PC 左栏/工作台/通讯录却显示绿色「正在执行」。
+
+### 修复:全部收敛到 `resolveAgentState`(唯一真相源)
+
+| 位置 | 改动 |
+|---|---|
+| `desktop/.../new-session-modal.ts` | **新增状态展示**(用户明确要求):容器名右侧「圆点 + 文案」,按 tone 上色;接入 `resolveAgentState` |
+| `components/domain/session-tree/session-tree.ts` | PC 左栏会话行新增状态点(`agentStateOf`),复活原先的 `toneOf` 死代码槽位 |
+| `mobile/components/m-drawer/m-drawer.ts` | 移动抽屉会话行新增状态点 |
+| `mobile/pages/chat/chat.ts` | 副标题、状态抽屉改用 `resolveAgentState`;心跳从 ISO 原文改为「X 分钟前」 |
+| `mobile/pages/contacts/contacts.ts` | 从体系 A 切到 C(原先与 PC 通讯录结论相反) |
+| `mobile/components/m-life-card/m-life-card.ts` | 同上,去掉自造的「运行中」文案 |
+| `desktop/pages/chat/chat.ts` | 不可用徽标改判合成态(原先不判心跳) |
+| `desktop|mobile/pages/workbench/workbench.ts` | 「运行中 / 需要关注」计数与筛选用合成态;移动端补上原先漏掉的 `stopped` |
+| `core/task-pulse.service.ts` | `describe()` 先走 `resolveAgentState` 判不可用,并补 `isReporterSelfTalk` 过滤(原先会把 `reporter heartbeat @ …` 当成任务名显示) |
+
+### 同批修复:输出状态口径不统一(用户反馈「只闪一下『正在回复』就消失」)
+
+| 问题 | 修复 |
+|---|---|
+| 回合卡片静默窗口 3 秒、顶栏 20 秒 → 两处结论打架 | 统一为 `QUIET_MS = 25s`,`task-pulse` 直接引用同一常量 |
+| 真的结束后还多挂 25 秒 | 新增 `agentIdle` 判据:容器明确报 idle 时立即收尾 |
+| PC / 移动调用 `turnsFromDm` 都吃默认值 3 秒 | 两端显式传 `QUIET_MS` + `chat.agentIdle()` |
+
+**验证**:
+
+| 项 | 结果 |
+|---|---|
+| `tsc --noEmit` | ✅ |
+| `npm test` | ✅ **1339 SUCCESS**(新增 2 条:僵尸容器报离线、上报器自指不显示为任务) |
+| `npm run build` | ✅ |
+| PC 实测(真实账号) | ✅ 新建弹窗显示:`正在执行(绿) / 正在执行(绿) / 离线(红)`;左栏会话行同步出现状态点 |
+
+---
+
+## 2026-11-08 · 会话隔离收尾 + 容器配置诊断(离开前提交)
+
+### 本轮修完的(前端)
+
+| # | 问题 | 修复 |
+|---|---|---|
+| 1 | 新建会话仍带历史 | `paginate()` 补新建态保护(此前是三条回填路径里唯一没保护的);`startBlank()` 清空 `_ownedThreadRoots`;`hasMoreHistory` 在保护期内恒为 false |
+| 2 | 「加载更早的消息」出现在新建会话 | 同上;`paginationToken()` 是**房间级** token,与 thread 无关,新建会话照样能拿到非 null |
+| 3 | 两端首屏自动补齐会翻出旧历史 | `fillViewportIfNeeded()` 加 `state === 'empty'` 短路(PC + 移动) |
+| 4 | 容器状态「正在执行」不实 | `isReporterSelfTalk()` 加宽:`currentTask` 命中上报器特征也算自指(实测 `currentTask = "periodic status reporter (xlosi3m46q)"` 原先绕过了过滤) |
+
+> 注:`running` 且 task/action 全空**不降级**为 idle —— 现有用例 `agent-state.spec.ts:97` 明确要求真报 running 就显示「正在执行」,空 task 也可能是真在跑。前端不替容器否认状态。
+
+### 直接问容器的实测结论(lj-test)
+
+用客户端自造 thread 根 `$diag_...:fmode.cn` 发问,容器回复**全部挂在同一 thread 根下** →
+**thread 隔离机制本身是通的**,协议层没有问题。
+
+### 容器侧诊断(已改文件,未能重启)
+
+| 项 | 结果 |
+|---|---|
+| `MATRIX_REQUIRE_MENTION` | 已由 `true` 改为 `false`(`/opt/data/.env:550`,容器回读确认) |
+| `MATRIX_IGNORE_USER_PATTERNS` | `@agent-node-lj-test:fmode\.cn`,不含 `@xhvrjtmwqh:fmode.cn`,正常 |
+| 值的来源 | `os.environ`,`adapter.__init__` 读一次(`adapter.py:861`);**无 s6 envdir、无 docker -e** |
+| 能否热生效 | ❌ 改文件不热生效,必须重启 gateway 进程 |
+
+**🔴 需要宿主执行(容器内无 docker socket,无法自助重启)**:
+
+```bash
+docker restart e14260fdce46
+# 或更轻量:
+docker exec -it e14260fdce46 s6-svc -r /run/service/gateway-default
+# 回读验证:
+tr '\0' '\n' < /proc/$(pgrep -f 'hermes gateway run')/environ | grep REQUIRE_MENTION
+```
+
+**这是「发消息容器不回」的根因**:`docs/03-interface/容器绑定App…20261103.md:169` 明写「私聊必须 false」,速查表 :235 也列着这一条。
+
+### 未完成 / 待办
+
+- [ ] **宿主重启 lj-test 容器**,让 `MATRIX_REQUIRE_MENTION=false` 生效(需用户在宿主执行)
+- [ ] `app-lj-test-v1` / `lj` 两个容器未做同样检查,需逐一确认 `REQUIRE_MENTION` 值
+- [ ] 容器 reporter 把 `status=running` 硬编码(操作追踪.md:909-914)—— 前端已做兜底,根治需容器整改
+- [ ] LoopGuard 误报:`repeated` 规则不要求连续、`dismissedFor` 按 agentId 而非 sessionId(本次未处理)
+- [ ] `belongsToSession()` 里有一段重复代码(`:1670-1693` 与 `:1716-1739`),第二段永远进不去,待清理
+
+### 验证
+
+| 项 | 结果 |
+|---|---|
+| `tsc --noEmit`(app + spec) | ✅ |
+| `npm test` | ✅ **1339 SUCCESS** |
+| 容器 thread 隔离实测 | ✅ 回复挂同一 thread 根 |

+ 0 - 1
src/app/app.ts

@@ -20,7 +20,6 @@ import { UiMToast } from './ui/mobile/components/toast/toast';
 })
 export class App {
   private readonly palette = inject(PaletteService);
-
   constructor() {
     // 在首帧前把已保存的配色写到 <html data-palette>,避免闪白/闪色
     this.palette.init();

+ 41 - 10
src/app/components/domain/assistant-turn/assistant-turn.ts

@@ -529,27 +529,50 @@ export class AssistantTurnComponent implements OnDestroy {
     const cur = this.shownMap();
 
     if (this.reduceMotion || !live) {
+      // 历史回合直接完整显示,不逐字重放
       this.shownMap.set({ ...targets });
       return;
     }
 
-    // 首次出现(还没有任何进度)→ 直接显示全部,历史不该逐字重放
+    /*
+     * 🔴 实时回合的**首次**求值:从 0 开始逐字推进(2026-11-08 修复)。
+     *
+     * 原来的写法是「首次出现 → 直接设为目标长度」,初衷是历史不该重放,
+     * 但它分不清「历史回放」和「刚到达的新消息」——
+     * 组件是新建的,`shownMap` 恒为空,于是**每一条实时消息都被整段直出**,
+     * 流式效果从未生效(用户实测反馈)。
+     *
+     * 现在:非 live(历史)上面已经直接到位;走到这里的都是 live,
+     * 于是从 0 开始推进,让用户看到逐段生成。
+     */
     if (Object.keys(cur).length === 0) {
-      this.shownMap.set({ ...targets });
-      return;
+      const zero: Record<string, number> = {};
+      for (const k of Object.keys(targets)) zero[k] = 0;
+      this.shownMap.set(zero);
     }
 
-    // 目标小于当前(重新生成 / 切换)→ 直接回落到目标
-    const reset: Record<string, number> = { ...cur };
+    const base = this.shownMap();
+
+    /*
+     * 目标变化:增长要动画;变短**不收缩**(2026-11-08)。
+     *
+     * 「变短」通常意味着数据层暂时丢了条目(Matrix 重建 / 分页),
+     * 而不是用户想看重新生成。收缩会让内容在眼前消失 ——
+     * 正是「后续内容覆盖了之前的输出」的观感来源。
+     * 宁可暂时多显示,等下一轮数据回来再对齐。
+     */
+    const reset: Record<string, number> = { ...base };
     let anyGrow = false;
     for (const [k, target] of Object.entries(targets)) {
       const now = reset[k];
-      if (now === undefined || now === 0) {
-        reset[k] = target; // 新出现的块:一次到位
+      if (now === undefined) {
+        // 新出现的块:从 0 开始(同样要流式,不直出)
+        reset[k] = 0;
+        if (target > 0) anyGrow = true;
         continue;
       }
-      if (target <= now) reset[k] = target; // 变短:不倒退地跳转
-      else anyGrow = true;
+      if (target > now) anyGrow = true;
+      // 注意:target < now 时**保持 now**,不收缩
     }
     this.shownMap.set(reset);
     if (!anyGrow) return;
@@ -619,7 +642,15 @@ export class AssistantTurnComponent implements OnDestroy {
       let message: Message | null = null;
       let streaming = false;
       if (src) {
-        const n = shown[b.key] ?? src.text.length;
+        /*
+         * 🔴 不要再回退到 `src.text.length`(2026-11-08)。
+         *
+         * 原来的 `shown[b.key] ?? src.text.length`:只要 key 对不上(例如
+         * targetsOf 与 segments 的 key 空间不一致、或首帧还没建立进度),
+         * 就当成「已经显示完」直接渲染全文 —— 流式因此失效。
+         * 取不到进度时按 0 处理,让 rAF 一帧帧补上来。
+         */
+        const n = shown[b.key] ?? 0;
         streaming = b.live && n < src.text.length;
         message = streaming ? { ...src.message, text: src.text.slice(0, n) } : src.message;
       }

+ 53 - 0
src/app/components/domain/new-session-modal/new-session-modal.ts

@@ -7,6 +7,7 @@ import { NewSessionModalService } from './new-session-modal.service';
 import { WorkspaceService } from '../../../core/workspace.service';
 import { GroupChatService } from '../../../core/group-chat.service';
 import { Agent } from '../../../core/api/models';
+import { resolveAgentState, AgentStateView } from '../../../core/agent-state';
 
 /**
  * 「新建对话」模态框(全局单例,挂在外壳上)
@@ -69,6 +70,14 @@ import { Agent } from '../../../core/api/models';
               <span class="pick-name ellipsis">{{ a.name }}</span>
               <span class="pick-sub ellipsis">{{ a.specialty || '数字生命' }}</span>
             </span>
+            <!--
+              容器状态(2026-11-08 用户要求「PC 新建会话也像移动端一样显示状态」)。
+              与全站同一口径 resolveAgentState(判心跳、防上报器自指、判可用性)。
+              颜色按真实语义 —— 不一律涂红,否则「未接入」和「异常」分不清。
+            -->
+            <span class="pick-state" [attr.data-tone]="stateOf(a).tone">
+              <i class="dot" aria-hidden="true"></i>{{ stateOf(a).text }}
+            </span>
             <i class="fa-solid fa-chevron-right pick-arrow" aria-hidden="true"></i>
           </button>
         } @empty {
@@ -214,6 +223,40 @@ import { Agent } from '../../../core/api/models';
         font-size: var(--fs-11);
         color: var(--faint);
       }
+      /*
+       * 容器状态(2026-11-08):圆点 + 文案,颜色走语义 token。
+       * 只用颜色不够(WCAG):始终带文字,颜色只是加强。
+       */
+      .pick-state {
+        flex-shrink: 0;
+        display: inline-flex;
+        align-items: center;
+        gap: var(--sp-1);
+        font-size: var(--fs-11);
+        color: var(--muted);
+        white-space: nowrap;
+      }
+      .pick-state .dot {
+        width: 6px;
+        height: 6px;
+        border-radius: 50%;
+        background: currentColor;
+      }
+      .pick-state[data-tone='success'] {
+        color: var(--success);
+      }
+      .pick-state[data-tone='warning'] {
+        color: var(--warning);
+      }
+      .pick-state[data-tone='danger'] {
+        color: var(--danger);
+      }
+      .pick-state[data-tone='info'] {
+        color: var(--primary);
+      }
+      .pick-state[data-tone='neutral'] {
+        color: var(--muted);
+      }
       .pick-empty {
         margin: 0;
         padding: var(--sp-4);
@@ -271,6 +314,16 @@ export class NewSessionModalComponent {
       .sort((x, y) => x.name.localeCompare(y.name, 'zh') || x.id.localeCompare(y.id)),
   );
 
+  /**
+   * 容器状态(2026-11-08):全站唯一口径 `resolveAgentState`。
+   *
+   * 不自己拼 `statusText ?? status` —— 那会把 45 天没心跳的僵尸容器显示成「正在执行」,
+   * 也会把英文 `running` 原样露给用户。
+   */
+  protected stateOf(a: Agent): AgentStateView {
+    return resolveAgentState({ agent: a });
+  }
+
   protected createIn(agentId: string): void {
     const target = this.service.workspaceId();
     this.close();

+ 117 - 11
src/app/components/domain/session-tree/session-tree.ts

@@ -4,7 +4,7 @@ import { NavigationEnd, Router, RouterLink } from '@angular/router';
 import { effect } from '@angular/core';
 import { UiAvatar, UiButton, UiEmpty, UiInput, UiModal, UiSkeleton, UiSwitch, UiTooltip } from '../../../ui';
 import { navItemActive } from '../../../desktop/shell/dk-top-nav/dk-top-nav';
-import { ChatSession } from '../../../core/chat-session';
+import { ChatSession, groupOf } from '../../../core/chat-session';
 import { ChatSessionService } from '../../../core/chat-session.service';
 import { Workspace, WorkspaceService } from '../../../core/workspace.service';
 import { PromptModalService } from '../prompt-modal/prompt-modal.service';
@@ -22,6 +22,7 @@ import { ThemeService } from '../../../ui/theme';
 import { UnreadService } from '../../../core/unread.service';
 import { ChatService } from '../../../core/chat.service';
 import { agentStatusTone } from '../../../core/api/models';
+import { AgentStateView, resolveAgentState } from '../../../core/agent-state';
 
 /** 工作区(用户手动新建的文件夹) */
 /** 一次「选择容器并新建对话」的请求(由页面弹模态框处理) */
@@ -348,6 +349,34 @@ export interface NewSessionRequest {
                 *ngTemplateOutlet="row; context: { $implicit: s, owner: null }"
               />
             }
+
+            <!--
+              群聊的「已归档」(2026-11-08 用户反馈):归档是「收起来」,不是「搬走」。
+              原实现把群聊归档塞进「日常对话 → 已归档」,用户找不到。
+            -->
+            @if (groupArchived().length > 0) {
+              <button
+                type="button"
+                class="arch-toggle"
+                [attr.aria-expanded]="isArchOpen('__group__')"
+                (click)="toggleArchiveGroup('__group__')"
+              >
+                <i
+                  class="fa-solid"
+                  [class.fa-chevron-right]="!isArchOpen('__group__')"
+                  [class.fa-chevron-down]="isArchOpen('__group__')"
+                  aria-hidden="true"
+                ></i>
+                已归档 <span class="arch-count">{{ groupArchived().length }}</span>
+              </button>
+              @if (isArchOpen('__group__')) {
+                @for (s of groupArchived(); track s.id) {
+                  <ng-container
+                    *ngTemplateOutlet="row; context: { $implicit: s, owner: null }"
+                  />
+                }
+              }
+            }
           </div>
         </section>
 
@@ -642,6 +671,21 @@ export interface NewSessionRequest {
             群聊没有单一容器,名字串由群标题表达,这里换成成员数(2026-11-06)。
           -->
           <span class="sess-agent" [title]="s.kind === 'group' ? groupSubline(s) : agentNameOf(s)">
+            <!--
+              容器状态点(2026-11-08 用户要求):
+              与移动端会话列表、新建弹窗同一口径 resolveAgentState ——
+              判心跳、防上报器自指。原先左栏完全不显示状态(toneOf 是死代码)。
+              群聊不挂单容器,不显示点。
+            -->
+            @if (s.kind !== 'group') {
+              <i
+                class="sess-dot"
+                [attr.data-tone]="agentStateOf(s).tone"
+                [attr.title]="agentStateOf(s).text"
+                [attr.aria-label]="agentStateOf(s).text"
+                aria-hidden="true"
+              ></i>
+            }
             {{ s.kind === 'group' ? groupSubline(s) : agentNameOf(s) }}
           </span>
         </span>
@@ -1338,6 +1382,31 @@ export interface NewSessionRequest {
         font-size: var(--fs-11);
         line-height: 1.4;
       }
+      /*
+       * 容器状态点(2026-11-08):6px,跟在容器名前面。
+       * 颜色只是加强 —— 文案在 title / aria-label 里,悬停可读。
+       */
+      .sess-dot {
+        display: inline-block;
+        width: 6px;
+        height: 6px;
+        margin-right: 4px;
+        border-radius: 50%;
+        vertical-align: middle;
+        background: var(--faint);
+      }
+      .sess-dot[data-tone='success'] {
+        background: var(--success);
+      }
+      .sess-dot[data-tone='warning'] {
+        background: var(--warning);
+      }
+      .sess-dot[data-tone='danger'] {
+        background: var(--danger);
+      }
+      .sess-dot[data-tone='info'] {
+        background: var(--primary);
+      }
       .sess-title-wrap {
         flex: 1;
         min-width: 0;
@@ -1965,14 +2034,38 @@ export class SessionTreeComponent {
     );
   }
 
-  protected readonly dailyArchived = computed(() => this.filter(this.archivedList(null)));
+  protected readonly dailyArchived = computed(() =>
+    this.filter(
+      this.sessionsSvc.all().filter((s) => s.archived && groupOf(s).kind === 'daily'),
+    ),
+  );
+
+  /** 群聊的已归档(2026-11-08:归档回到群聊分组,不再掉进「日常对话」) */
+  protected readonly groupArchived = computed(() =>
+    this.filter(
+      this.sessionsSvc.all().filter((s) => s.archived && groupOf(s).kind === 'group'),
+    ),
+  );
 
+  /**
+   * 某工作区的活跃会话。
+   * 🔴 必须排除群聊:群聊恒不归属工作区,但历史/异常数据里 `workspaceId` 可能被写上,
+   *    不排除会出现「同一群聊同时在群聊和工作区两边」。
+   */
   protected sessionsIn(wsId: string): ChatSession[] {
-    return this.filter(this.activeList(wsId));
+    return this.filter(
+      this.sessionsSvc
+        .all()
+        .filter((s) => !s.archived && groupOf(s).kind === 'workspace' && s.workspaceId === wsId),
+    );
   }
 
   protected archivedIn(wsId: string): ChatSession[] {
-    return this.filter(this.archivedList(wsId));
+    return this.filter(
+      this.sessionsSvc
+        .all()
+        .filter((s) => s.archived && groupOf(s).kind === 'workspace' && s.workspaceId === wsId),
+    );
   }
 
   protected sessionTime(ms: number): string {
@@ -2178,9 +2271,15 @@ export class SessionTreeComponent {
     this.archiving.set(null);
     if (!s) return;
     this.sessionsSvc.archive(s.id, true);
-    // 归档后自动展开该分组的「已归档」——让用户看到对话去了哪里
-    const owner = this.ownerOf(s);
-    this.archOpen.update((set) => new Set(set).add(owner ?? '__daily__'));
+    /*
+     * 归档后自动展开**该会话原分组**的「已归档」——让用户看到对话去了哪里。
+     * 🔴 键必须与模板一致(2026-11-08):群聊用 `__group__`,工作区用工作区 id,
+     *    日常用 `__daily__`。原实现统一用 `owner ?? '__daily__'`,
+     *    群聊(workspaceId 为 null)于是展开了日常分组 —— 用户看到归档跑到日常里。
+     */
+    const g = groupOf(s);
+    const key = g.kind === 'workspace' ? g.id : g.kind === 'group' ? '__group__' : '__daily__';
+    this.archOpen.update((set) => new Set(set).add(key));
   }
 
   protected isArchOpen(owner: string): boolean {
@@ -2251,10 +2350,6 @@ export class SessionTreeComponent {
     return this.sessionsSvc.all().filter((s) => !s.archived && this.ownerOf(s) === owner);
   }
 
-  private archivedList(owner: string | null): ChatSession[] {
-    return this.sessionsSvc.all().filter((s) => s.archived && this.ownerOf(s) === owner);
-  }
-
   /**
    * 群聊分组头的「+」:打开「新建对话」并直接停在群聊标签(2026-11-06 用户裁决)。
    * 群聊不归属工作区,目标归属传 null。
@@ -2279,6 +2374,17 @@ export class SessionTreeComponent {
     return agentStatusTone(this.liveSvc.agentById(agentId)?.status as never);
   }
 
+  /**
+   * 会话所属容器的状态(2026-11-08)。
+   *
+   * 用 `resolveAgentState` 而不是裸 `status`:后者不看心跳,
+   * 45 天没心跳的僵尸容器会被涂成绿色「正在执行」。
+   * 与移动端会话列表、新建弹窗同一口径。
+   */
+  protected agentStateOf(s: ChatSession): AgentStateView {
+    return resolveAgentState({ agent: this.liveSvc.agentById(s.agentId) });
+  }
+
   protected unreadOf(agentId: string): number {
     return this.unreadSvc.countOf(agentId);
   }

+ 25 - 4
src/app/core/agent-state.ts

@@ -145,12 +145,33 @@ const REPORTER_SELF_TALK = /reporter heartbeat|periodic status reporter|status-r
 /**
  * 这条 running 是否是「上报器在说自己的事」。
  *
- * 判据:`currentAction` 命中上报器特征,**且** `currentTask` 为空
- * (有真实任务时不降级,避免误伤正在干活的容器)。
+ * 🔴 2026-11-08 加宽判定。原实现要求 `currentTask` **为空**才继续看 action,
+ *    于是实测量到的这条被漏掉:
+ *      `currentTask = "periodic status reporter (xlosi3m46q)"`
+ *    —— `currentTask` 非空 → 直接 return false → 界面显示绿色「正在执行」,
+ *    而容器其实什么业务都没在做(用户反馈「没问它东西,它也显示正在执行」)。
+ *
+ * 现在:`currentTask` 命中上报器特征 **也算自指**,不再要求它为空。
  */
 export function isReporterSelfTalk(agent: Agent): boolean {
-  if (agent.currentTask?.trim()) return false;
-  return REPORTER_SELF_TALK.test(agent.currentAction ?? '');
+  const task = agent.currentTask?.trim() ?? '';
+  const action = agent.currentAction?.trim() ?? '';
+  if (task && REPORTER_SELF_TALK.test(task)) return true;
+  // 有真实任务描述 → 是业务动作,不降级(避免误伤正在干活的容器)
+  if (task) return false;
+  return REPORTER_SELF_TALK.test(action);
+}
+
+/**
+ * `running` 但完全没有任何任务线索。
+ *
+ * 注意:**不用于 `resolveAgentState` 的降级**。
+ * 现有设计(`agent-state.spec.ts:97`)明确:容器真报 running 就显示「正在执行」——
+ * 空 task 也可能是真的在跑但没填描述,前端不该替它否认。
+ * 本函数只供 `TaskPulseService` 判断「有没有具体任务可展示」。
+ */
+export function runningWithoutTask(agent: Agent): boolean {
+  return !agent.currentTask?.trim() && !agent.currentAction?.trim();
 }
 
 /**

+ 27 - 3
src/app/core/assistant-turn.ts

@@ -308,6 +308,18 @@ function proseOf(text: string): boolean {
 /* 块 → 视图                                                           */
 /* ------------------------------------------------------------------ */
 
+/**
+ * 默认「还在输出」的静默窗口 —— 与 `turn-aggregator.QUIET_MS` 同源。
+ *
+ * 🔴 为什么不能用一个短窗口(2026-11-08 用户反馈「只闪一下『正在回复』就消失」):
+ *    容器跑工具时,两段正文之间常有 5–20 秒空档。窗口短于一拍,
+ *    回合就被判成「已结束」,界面的「正在思考…」随之消失 ——
+ *    而容器其实还在干活,用户无从得知。
+ *
+ * 调用方应优先传 `QUIET_MS`;这里的字面量只是兜底,两处必须同步修改。
+ */
+const DEFAULT_QUIET_MS = 25_000;
+
 function toBlockView(src: BlockSource, now: number, quietMs: number): TurnBlockView {
   const { steps, body } = splitIntoParts(src.messages);
   const files = body
@@ -358,7 +370,7 @@ function phaseOf(blocks: TurnBlockView[], closed: boolean): TurnPhase {
 export function turnsFromGroup(
   turns: GroupTurn[],
   now: number,
-  quietMs = 3000,
+  quietMs = DEFAULT_QUIET_MS,
   nameOf?: (agentId: string) => string,
 ): AssistantTurn[] {
   return turns.map((t) => {
@@ -400,8 +412,19 @@ export function turnsFromGroup(
 
 /**
  * 单聊:`buildTurns` 的结果 → 回合视图(固定 1 块)。
+ *
+ * @param typing   容器是否在 typing(容器大多不发,通常为 false)
+ * @param quietMs  「还在输出」的静默窗口,调用方传 `QUIET_MS`
+ * @param agentIdle 容器**明确上报**的空闲态。为 true 时忽略静默窗口直接收尾 ——
+ *                  只有容器能宣布「我干完了」,比任何时间窗都准。
  */
-export function turnsFromDm(turns: Turn[], now: number, typing: boolean, quietMs = 3000): AssistantTurn[] {
+export function turnsFromDm(
+  turns: Turn[],
+  now: number,
+  typing: boolean,
+  quietMs = DEFAULT_QUIET_MS,
+  agentIdle = false,
+): AssistantTurn[] {
   return turns.map((t) => {
     const block = toBlockView(
       {
@@ -413,7 +436,8 @@ export function turnsFromDm(turns: Turn[], now: number, typing: boolean, quietMs
         closed: t.closed,
       },
       now,
-      quietMs,
+      /* 容器明确空闲 → 不必再等静默窗口,立刻收尾 */
+      agentIdle ? 0 : quietMs,
     );
     const live = block.live;
 

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

@@ -509,6 +509,61 @@ describe('ChatSessionService', () => {
     });
   });
 
+  /*
+   * 🔴 2026-11-08 用户反馈(图39):新建会话后左栏出现多条一模一样的旧对话。
+   *
+   * 其中一条来源:`hydrateOnce` 的 `keepId` 分支会把「正在打开、但云端列表里
+   * 没有、且一条消息都没发过」的空壳捡回来 —— 而 `pruneEmpty()` 的职责正是
+   * 把这类空壳收敛成最多一条。两者互相抵消,空壳就一直在列表里堆积。
+   */
+  describe('空壳不因 keepId 被复活(2026-11-08)', () => {
+    const ROOM = '!room1:fmode.cn';
+    const AGENT = 'agent-a';
+
+    beforeEach(() => localStorage.clear());
+
+    it('🔴 未发过消息的空会话,云端没有时不被 keep 保留', async () => {
+      /*
+       * 只让第一轮成功:并发去重会把第二次 hydrate 直接复用第一轮结果,
+       * 第二轮要真的重拉云端才能观察 keep 分支。
+       */
+      let calls = 0;
+      const svc2 = withApiStub({
+        fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => {
+          if (!o?.includeDeleted) calls++;
+          return [];
+        },
+        upsertChatThreads: () => new Promise<void>(() => undefined),
+      });
+      /*
+       * `withApiStub` 已调过 `load()`;`hydrateOnce` 内部再调是幂等短路,
+       * 这里不重复调用。
+       */
+      await svc2.hydrateFromCloud(AGENT);
+      const blank = svc2.create(ROOM, AGENT);
+      // 模拟「云端确实没有它」:草稿只有发消息时才落库,这里不算已同步
+      (svc2 as unknown as { _pendingPush: Set<string> })._pendingPush.delete(blank.id);
+      await svc2.hydrateFromCloud(AGENT, blank.id);
+
+      expect(calls).withContext('第二轮 hydrate 没有真的重拉').toBe(2);
+      expect(svc2.byId(blank.id)).withContext('空壳被 keepId 复活').toBeUndefined();
+    });
+
+    it('发过消息的会话,云端暂时没返回时仍被 keep 保留', async () => {
+      const svc2 = withApiStub({
+        fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []),
+        upsertChatThreads: () => new Promise<void>(() => undefined),
+      });
+      await svc2.hydrateFromCloud(AGENT);
+      const s = svc2.create(ROOM, AGENT);
+      svc2.noteMessage(s.id, { text: '你好', ts: Date.now(), fromMe: true });
+
+      await svc2.hydrateFromCloud(AGENT, s.id);
+
+      expect(svc2.byId(s.id)).withContext('有内容的会话被云端拉取抹掉').toBeDefined();
+    });
+  });
+
   describe('云端会话目录(2026-11-01 单向同步)', () => {
     const ROOM = '!room1:fmode.cn';
     const AGENT = 'agent-a';

+ 34 - 2
src/app/core/chat-session.service.ts

@@ -339,12 +339,44 @@ export class ChatSessionService {
      *     `open()` 里 `_sessionId` 悬空 → 页面渲染成「还没有对话」,输入区整块消失。
      * 范围极小:只是「调用方明确正在打开」的那一条,不是「本地有的都保留」。
      */
+    /*
+     * 🔴 `keep` 必须**排除已经在 `pending` 里的条目**(2026-11-08)。
+     *
+     * 刚新建、还没推上云的会话会同时满足两个条件:
+     *   · 在 `_pendingPush` 里  → 被 `pending` 收一次
+     *   · 正是 `keepId`(正在打开)→ 被 `keep` 再收一次
+     * 两个数组拼在一起 → 同一个 id 在 `_all` 里存**两份**,
+     * 左栏就出现两条一模一样的会话(用户实测)。
+     *
+     * 判据加 `!this._pendingPush.has(keepId)`,让每条只进一次。
+     */
+    /*
+     * 🔴 「正在打开」的空会话**不保留**(2026-11-08,「新建会话带出旧数据」图39)。
+     *
+     * 云端列表里没有它时,原逻辑会把它捡回来。但如果它一条消息都没发过
+     * (`messageCount` 为 0),它只是一个「新建对话」空壳 ——
+     * 而 `pruneEmpty()` 的职责正是把这类空壳收敛成**最多一条**。
+     * 捡回来等于把刚清掉的空壳重新塞进列表,用户看到的就是
+     * 「新建了一次,列表里却多出好几条一模一样的旧对话」。
+     *
+     * 非空会话(发过消息、只是在等云端回写)仍然保留,这一点不变。
+     */
     const keep =
-      keepId && !next.some((n) => n.id === keepId) && !this._removed().has(keepId)
+      keepId &&
+      !next.some((n) => n.id === keepId) &&
+      !this._removed().has(keepId) &&
+      !this._pendingPush.has(keepId) &&
+      (this.byId(keepId)?.messageCount ?? 0) > 0 && !this._pendingPush.has(keepId)
         ? this._all().filter((s) => s.id === keepId && s.agentId === agentId)
         : [];
     const others = this._all().filter((s) => s.agentId !== agentId);
-    this._all.set(sortSessions([...others, ...next, ...pending, ...keep]));
+    /*
+     * 最后再按 id 去重一次(兜底):任何合并路径都不该产生重复 id。
+     * 两个数组若有交集,后面的会覆盖前面的 —— 保留先出现的即可。
+     */
+    const merged = new Map<string, (typeof next)[number]>();
+    for (const s of [...others, ...next, ...pending, ...keep]) merged.set(s.id, s);
+    this._all.set(sortSessions([...merged.values()]));
     this.persist();
     this._ready.set(true);
     this._error.set(null);

+ 34 - 0
src/app/core/chat-session.spec.ts

@@ -3,6 +3,8 @@ import {
   createSession,
   deriveTitle,
   deriveTaskTitle,
+  groupKey,
+  groupOf,
   isAppSessionId,
   newSessionId,
   sessionsOf,
@@ -257,3 +259,35 @@ describe('deriveTaskTitle(会话名提炼)', () => {
     expect(Array.from(t).length).toBeLessThanOrEqual(25);
   });
 });
+
+/*
+ * 会话分组归属(2026-11-08 用户反馈)。
+ *
+ * 问题:群聊归档后掉进「日常对话 → 已归档」,而不是留在「群聊 → 已归档」。
+ * 根因:归属只看 `workspaceId`,而群聊的 `workspaceId` 恒为 null。
+ */
+describe('groupOf / groupKey(归档回到原分组)', () => {
+  it('群聊归群聊分组,即使 workspaceId 有值', () => {
+    expect(groupOf({ kind: 'group', workspaceId: null })).toEqual({ kind: 'group' });
+    expect(groupOf({ kind: 'group', workspaceId: 'ws1' })).withContext('群聊不归工作区').toEqual({
+      kind: 'group',
+    });
+  });
+
+  it('有工作区归属的单聊归该工作区', () => {
+    expect(groupOf({ kind: undefined, workspaceId: 'ws1' })).toEqual({
+      kind: 'workspace',
+      id: 'ws1',
+    });
+  });
+
+  it('无归属的单聊归日常对话', () => {
+    expect(groupOf({ kind: undefined, workspaceId: null })).toEqual({ kind: 'daily' });
+  });
+
+  it('分组键稳定且互不冲突', () => {
+    expect(groupKey({ kind: 'group' })).toBe('group');
+    expect(groupKey({ kind: 'daily' })).toBe('daily');
+    expect(groupKey({ kind: 'workspace', id: 'ws1' })).toBe('workspace:ws1');
+  });
+});

+ 31 - 0
src/app/core/chat-session.ts

@@ -282,3 +282,34 @@ export function sessionsOf(
     list.filter((s) => s.agentId === agentId && (opts.includeArchived || !s.archived)),
   );
 }
+
+/**
+ * 会话在左栏(桌面)与抽屉(移动)里的**分组归属**。
+ *
+ * 🔴 归档必须回到**它原来所属的分组**(2026-11-08 用户反馈)。
+ *
+ * 原实现只按 `workspaceId` 分组:群会话的 `workspaceId` 恒为 `null`
+ * (群聊不归属工作区),于是「群聊 → 归档」被塞进「日常对话 → 已归档」,
+ * 与用户的预期(归档在哪个分组,就停在哪个分组)相违。
+ *
+ * 现在归属唯一由这里决定,桌面、移动都从这里取 —— 避免两端各写一套判据再次分叉。
+ *   · `workspace:<id>` — 归属某工作区
+ *   · `group`          — 群聊(不论 `workspaceId`,群聊不归属工作区)
+ *   · `daily`          — 其余(日常对话 / 已失效工作区的孤儿)
+ */
+export type SessionGroup =
+  | { kind: 'workspace'; id: string }
+  | { kind: 'group' }
+  | { kind: 'daily' };
+
+export function groupOf(s: Pick<ChatSession, 'kind' | 'workspaceId'>): SessionGroup {
+  if (s.kind === 'group') return { kind: 'group' };
+  if (s.workspaceId) return { kind: 'workspace', id: s.workspaceId };
+  return { kind: 'daily' };
+}
+
+/** 分组 → 折叠状态的稳定键(桌面 `archOpen` / 移动 `expanded` 共用) */
+export function groupKey(g: SessionGroup): string {
+  return g.kind === 'workspace' ? `workspace:${g.id}` : g.kind;
+}
+

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

@@ -919,3 +919,102 @@ describe('ChatService', () => {
     });
   });
 });
+
+/*
+ * 新建会话的「空白保护」窗口(2026-11-08 用户实测:发一句「你好」仍带出历史)。
+ *
+ * 根因是**清除时机**:`_blankSessionId` 曾在 commit(本地记账)时就清掉,
+ * 而那时 `sendReal()` 的网络请求还在路上(约 1 秒)。此间 `/sync` 落地触发
+ * 历史回填,两道保护(新建态标记 + state==='empty')同时失效,
+ * `rootsOfSession()` 把该房间所有旧容器子线程根都算成「本会话的」,
+ * 于是旧对话被灌进新会话。
+ *
+ * 这里断言的是**行为契约**:发送期间(网络请求未返回)绝不能解除新建态保护。
+ */
+describe('ChatService · 新建会话期间不得回填历史(2026-11-08)', () => {
+  let svc: ChatService;
+
+  const apiStub = {
+    provisionMatrix: jasmine.createSpy('provisionMatrix').and.returnValue(
+      Promise.resolve({ accessToken: 'tok', userMxId: '@me:fmode.cn', deviceId: 'd1', agentMxId: '@agent:fmode.cn', roomId: '!room1:fmode.cn' }),
+    ),
+    fetchChatThreads: jasmine.createSpy('fetchChatThreads').and.returnValue(Promise.resolve([])),
+    upsertChatThreads: jasmine.createSpy('upsertChatThreads').and.returnValue(Promise.resolve()),
+    patchChatThread: jasmine.createSpy('patchChatThread').and.returnValue(Promise.resolve()),
+    removeChatThread: jasmine.createSpy('removeChatThread').and.returnValue(Promise.resolve()),
+    validateMatrix: jasmine.createSpy('validateMatrix').and.returnValue(Promise.resolve(true)),
+    fetchAgents: jasmine.createSpy('fetchAgents').and.returnValue(Promise.resolve([])),
+  };
+
+  const AGENT: Agent = {
+    id: 'agent-node-lj-test',
+    name: 'lj-test',
+    status: 'online' as Agent['status'],
+    matrixReady: true,
+    unread: 0,
+  };
+
+  const liveStub = {
+    ensureLoaded: () => Promise.resolve(),
+    agentById: () => AGENT,
+    agents: () => [AGENT],
+    state: () => 'ready',
+    error: () => null,
+  };
+
+  /** 可以手动放行的 sendText —— 模拟「网络请求还没返回」 */
+  let releaseSend: () => void = () => undefined;
+  const mxStub = {
+    connectSession: () => Promise.resolve(),
+    disconnect: () => undefined,
+    ensureRoom: jasmine.createSpy('ensureRoom').and.returnValue(Promise.resolve('!room1:fmode.cn')),
+    syncState: () => 'connected',
+    hasInitialSync: true,
+    paginationToken: () => null,
+    timelineToMessages: jasmine.createSpy('timelineToMessages').and.returnValue([] as MxMessage[]),
+    onMessage: jasmine.createSpy('onMessage').and.returnValue(() => undefined),
+    onAnyMessage: jasmine.createSpy('onAnyMessage').and.returnValue(() => undefined),
+    onTyping: jasmine.createSpy('onTyping').and.returnValue(() => undefined),
+    setTyping: jasmine.createSpy('setTyping').and.returnValue(Promise.resolve()),
+    sendText: jasmine.createSpy('sendText').and.callFake(
+      () => new Promise<{ event_id?: string }>((res) => { releaseSend = () => res({ event_id: '$new' }); }),
+    ),
+    storeVersion: () => 0,
+    timelineIndex: () => new Map<string, number>(),
+  };
+
+  beforeEach(() => {
+    TestBed.resetTestingModule();
+    localStorage.clear();
+    releaseSend = () => undefined;
+    (environment as { useMockData: boolean }).useMockData = false;
+    TestBed.configureTestingModule({
+      providers: [
+        ChatService,
+        { provide: FmodeApi, useValue: apiStub },
+        { provide: LiveService, useValue: liveStub },
+        { provide: MatrixClientService, useValue: mxStub },
+        { provide: SessionService, useValue: { me: () => ({ id: 'u1' }), token: () => 'tok' } },
+      ],
+    });
+    svc = TestBed.inject(ChatService);
+  });
+
+  it('🔴 发送请求未返回前,实时消息不得混入新会话', async () => {
+    await svc.open('agent-node-lj-test');
+    const created = await svc.startBlank('agent-node-lj-test');
+    expect(created).withContext('startBlank 应建出会话,实际为 null').toBeTruthy();
+    const sid = created!.id;
+
+    // 开始发送,但请求挂起(网络还没返回)
+    const pending = svc.sendText('你好');
+    await Promise.resolve();
+
+    // 此刻新会话里应该只有用户自己那条乐观消息
+    const during = svc.messages();
+    expect(during.length).withContext('发送期间只应有本地乐观消息').toBe(1);
+
+    releaseSend();
+    await pending;
+  });
+});

+ 282 - 17
src/app/core/chat.service.ts

@@ -1,7 +1,7 @@
 /** 首屏加载条数:只渲染最近一屏,其余上滚再取(用户要求,避免一次性加载全部) */
 const INITIAL_PAGE = 30;
 
-import { computed, effect, inject, Injectable, signal } from '@angular/core';
+import { computed, effect, inject, Injectable, signal, untracked } from '@angular/core';
 import { FmodeApi } from './api/fmode-api';
 import {
   Agent,
@@ -164,6 +164,18 @@ export class ChatService {
   readonly draft = this._draft.asReadonly();
   /** 对方正在输入(真实态由 typing 事件驱动) */
   readonly peerTyping = this._peerTyping.asReadonly();
+  /**
+   * 容器**明确上报**自己闲下来了(没有在跑任务)。
+   *
+   * 供回合卡片判断「还在输出」用(2026-11-08):
+   * 容器说空闲 → 立刻收尾;容器在跑或状态未知 → 依静默窗口判定。
+   * 只有容器能宣布「干完了」,前端不替它下结论。
+   */
+  readonly agentIdle = computed(() => {
+    const id = this._agentId();
+    if (!id) return false;
+    return this.live.agentById(id)?.status === 'idle';
+  });
   /** 正在加载更早历史 */
   readonly paginating = this._paginating.asReadonly();
   /** 是否可能还有更早历史(真实态:上次分页仍有返回) */
@@ -181,6 +193,20 @@ export class ChatService {
    * `null` = 日常对话。只在 `startBlank()` 到首次发消息之间有效。
    */
   private _blankWorkspace: string | null = null;
+
+  /**
+   * 「新建态」会话 id(2026-11-08)。
+   *
+   * 🔴 这是**比 `_state === 'empty'` 更可靠**的新建态判据。
+   *
+   * 原保护失效的原因:`sendText` 在 `commit()` 异步完成之前就执行了
+   * `_state.set('ready')`,等 `storeVersion` effect 触发时状态已是 ready,
+   * 于是旧历史被整表回填进新会话(用户实测:新建后上方出现昨天的对话)。
+   *
+   * 这里单独记一个 id:只要当前会话仍是它,effect 一律不回填;
+   * 直到 `sendText` 的 `commit` 成功(云端确实有了这条会话)才清除。
+   */
+  private _blankSessionId: string | null = null;
   private provision: MatrixProvision | null = null;
   /** 真实态下的当前房间 id */
   private roomId: string | null = null;
@@ -272,31 +298,79 @@ export class ChatService {
       if (!sid) return;
 
       /*
-       * 🔴 新建态(`startBlank()` 刚建、还没发过消息)**一律不回填**(2026-11-07)。
+       * 🔴 新建态保护(2026-11-08 第三次修正,最终版)。
        *
-       * 上面已经把 `_messages` 清空、订阅也解除了,但 `/sync` 落地时这个 effect
-       * 仍会跑:只要新会话的 id 恰好被 `belongsToSession` 认领(比如房间级消息的
-       * `messageCount > 0` 兜底分支命中了旧值),历史就会被重新灌进来 ——
-       * 表现为「新建对话里冒出几天前的旧内容」。
+       * 前两次都错在「用一个时间点解除保护」:
+       *   ① 在 `commit()` 时清 —— 本地记账,网络请求还在路上;
+       *   ② 在 `sendReal()` 返回后清 —— 只是「我这边发出去了」,
+       *      服务端还没把这条件纳入房间 timeline,容器也还没回应。
+       * 两次都留下同一个空窗:保护一撤,`/sync` 落地就把该房间
+       * **所有旧容器线程根**算成本会话的(`rootsOfSession` 认领全部历史)。
+       * 实测:解除保护后 60ms 内就灌入 17 条旧消息。
        *
-       * `_blankWorkspace !== null` 或 `_state === 'empty'` 表示这条会话还没有任何
-       * 本会话消息;此时以「空」为准,等用户真正发出第一条消息由 appendIncoming 接管。
+       * 正确的判据不是「发出了」,而是「**本会话确实有消息落在 timeline 里**」:
+       * 用户那条消息一旦出现在 timeline(`threadId === sid`),
+       * `rootsOfSession` 就只会认领由它派生出的容器子线程,旧历史天然被排除。
+       *
+       * 所以在保护期内,先看 timeline 里有没有本会话自己的消息:
+       *   · 没有 → 还没落地,继续挡(旧历史一律不进)
+       *   · 有   → 解除保护,本次及之后的回填都按正常规则走
        */
+      if (this._blankSessionId && sid === this._blankSessionId) {
+        const landed = raw.some((m) => m.threadId === sid);
+        if (!landed) return;
+        this._blankSessionId = null;
+      }
       if (this._state() === 'empty') return;
 
+      /*
+       * 🔴 只认「本会话自己产生过」的容器线程根(2026-11-08)。
+       *
+       * 这里直接计算,不依赖 `_ownedThreadRoots`(那是 `openReal` 在
+       * 打开会话那一刻建的,新建会话时还没建)。判据:该线程根下存在
+       * 本会话的用户消息 —— 用户在这个会话里发过东西,容器才会为它开子线程。
+       */
+      const ownedRoots = this.rootsOfSession(raw, sid, myUserId);
       const next = this.mapTimeline(
-        raw.filter((m) => this.belongsToSession(m, sid)),
+        raw.filter((m) => this.belongsToSession(m, sid, false, ownedRoots)),
         myUserId,
       );
       if (next.length === 0) return;
-      this._messages.set(next);
+
+      /*
+       * 🔴 合并而不是整表覆盖(2026-11-08)。
+       *
+       * `_messages.set(next)` 会把本地的乐观消息(刚发出、云端还没回读到的)
+       * 一起抹掉,再排成「旧历史 → 新消息」的顺序 —— 用户看到新建对话里
+       * 凭空出现昨天的内容,且自己的提问被挤到下面。
+       *
+       * 现在:历史里有的以历史为准,历史里没有的本地消息**保留**并排在后面。
+       *
+       * 🔴 读 `_messages()` 必须走 `untracked`:本 effect 正在往里写,
+       *    直接读会形成自依赖 → 无限重算(实测会导致页面卡死、按钮点不动)。
+       */
+      const local = untracked(() => this._messages());
+      const byId = new Map(next.map((m) => [m.id, m]));
+      for (const m of local) {
+        if (!byId.has(m.id)) byId.set(m.id, m);
+      }
+      this._messages.set([...byId.values()]);
       /*
        * 🔴 这里**不能**标记「正在回复」:这是历史重读(每次 sync 都会重新灌一遍),
        *    刷新页面时尾部 id 必然变 → 一打开就显示「正在回复」(用户实测)。
        *    真正的实时信号在 appendIncoming()。
        */
       if (this._state() === 'empty') this._state.set('ready');
-      this._hasMoreHistory.set(this.mx.paginationToken(rid) != null);
+      /*
+       * 🔴 只在**保护期结束后**才按房间 token 决定能否翻页(2026-11-08)。
+       *
+       * `paginationToken()` 取的是**整个房间**的 backward token,与当前 thread 无关。
+       * 新建会话照样能拿到非 null → 页面出现「加载更早的消息」→ 一点就翻出旧内容。
+       * 保护期内恒为 false:这条会话确实没有「更早的消息」。
+       */
+      this._hasMoreHistory.set(
+        this._blankSessionId !== sid && this.mx.paginationToken(rid) != null,
+      );
     });
   }
 
@@ -450,6 +524,14 @@ export class ChatService {
    * @returns 创建出的会话;容器未就绪时返回 null
    */
   async startBlank(agentId: string, workspaceId: string | null = null): Promise<ChatSession | null> {
+    /*
+     * 🔴 先自增 `seq`:`startBlank` 不经过 `openReal()`,不会自带版本号,
+     *    而此时可能正好有一次 `openReal()` 在途(切换容器/前一次进入的异步尾段)。
+     *    它落地时只校验 `my !== this.seq`,版本号不变就会把它**当成当前**继续写
+     *    `_sessionId` / 历史回填 —— 典型表现是「新建后瞬间又被旧会话内容占满」。
+     *    这里自增一次,让所有在途流程作废,再由下面的 `open()` 重新建立连接。
+     */
+    this.seq++;
     /*
      * 🔴 不调用 `open()`(2026-11-01 修正)。
      *
@@ -499,12 +581,24 @@ export class ChatService {
      */
     this.teardownSubscriptions();
 
+    /*
+     * 🔴 清空「已认领的容器线程根」(2026-11-08)。
+     *
+     * 该集合只在 `openReal()` 里重建(`clear()` + 重新计算),而 `startBlank()`
+     * **不经过 `openReal`** → 集合里残留着**上一个会话**的根。
+     * `paginate()` 恰好走这个集合(`belongsToSession(m, sid)` 无 ownedRoots 参数),
+     * 于是一翻页就把旧会话的容器子线程认领进新会话。
+     */
+    this._ownedThreadRoots.clear();
+
     this._messages.set([]);
     this._state.set('empty');
     this._error.set(null);
     this._hasMoreHistory.set(false);
     this._peerTyping.set(false);
     this._blankWorkspace = workspaceId;
+    // 新建态标记:storeVersion effect 据此拒绝回填(见字段注释)
+    this._blankSessionId = s.id;
     return this.sessions.byId(s.id) ?? s;
   }
 
@@ -734,6 +828,35 @@ export class ChatService {
       });
     }
 
+    /*
+     * 重建「本会话的容器线程根」集合(2026-11-08)。
+     *
+     * 🔴 必须在过滤**之前**建好,否则第一轮过滤时集合为空,
+     *    容器子线程的内容会被全部挡掉(比「串入旧会话」更糟)。
+     *
+     * 判据:这条容器线程根下,**存在属于本会话的用户消息**。
+     * 也就是「用户在这个会话里发过东西,容器才为它开了子线程」。
+     */
+    this._ownedThreadRoots.clear();
+    {
+      // 先按 turnId/thread 关系把「本会话的用户消息」找出来
+      const mine = new Set<string>();
+      for (const m of rawTimeline) {
+        if (m.sender !== myUserId) continue;
+        if (m.threadId === sid || m.sessionId === sid) {
+          mine.add(m.eventId);
+          if (m.threadId) this._ownedThreadRoots.add(m.threadId);
+        }
+      }
+      // 容器线程根:出现在本会话用户消息的 dispatch/turnId 关联里才算归属
+      for (const m of rawTimeline) {
+        const root = m.threadId;
+        if (!root || isAppSessionId(root)) continue;
+        // 该根下有任何消息的 turnId/sessionId 指向本会话 → 认领
+        if (m.sessionId === sid || mine.has(m.eventId)) this._ownedThreadRoots.add(root);
+      }
+    }
+
     const all = this.mapTimeline(
       rawTimeline.filter((m) => this.belongsToSession(m, sid)),
       myUserId,
@@ -872,6 +995,12 @@ export class ChatService {
     this._paginating.set(false);
     this._hasMoreHistory.set(false);
     this._blankWorkspace = null;
+    /*
+     * 🔴 新建态标记必须复位(2026-11-08):只在 `sendText` commit 后清除时,
+     *    用户「新建对话 → 不发消息 → 离开页面」会把标记留在内存里,
+     *    下次万一撞上同一条会话,实时消息会被无声丢弃。
+     */
+    this._blankSessionId = null;
   }
 
   setDraft(text: string): void {
@@ -1083,7 +1212,7 @@ export class ChatService {
        * 必须在 `noteMessage` **之前** commit:否则推上去的还是「新建对话 / 0 条」,
        * 标题要等下一次推送才能更正(用户实测:历史会话名一直不对)。
        */
-        this.sessions.noteMessage(curSid, {
+      this.sessions.noteMessage(curSid, {
         text: body,
         ts: Date.parse(msg.ts) || Date.now(),
         fromMe: true,
@@ -1145,8 +1274,25 @@ export class ChatService {
         if (!relayed) await this.sendReal(body);
       }
       this.markSent(msg.id);
+      /*
+       * 🔴 这里**不解除**新建态保护(2026-11-08 第三次修正)。
+       *
+       * `sendReal()` 返回只代表「本机把事件发出去了」,服务端还没把它并入
+       * 房间 timeline。此刻解除保护,紧接着落地的 `/sync` 就会把该房间
+       * 所有旧容器线程根算成本会话的(实测 60ms 内灌入 17 条旧消息)。
+       *
+       * 解除点统一在 `storeVersion` effect 里:等 timeline 里真的出现
+       * `threadId === 本会话` 的消息,说明本会话已在服务端成立,
+       * 那时 `rootsOfSession` 只会认领由这条消息派生的子线程,旧历史天然排除。
+       */
     } catch (e) {
       this.markFailed(msg.id, msgOf(e));
+      /*
+       * 发送失败**也要解除**:事件没能落到服务端,timeline 永远不会出现本会话的
+       * 消息,再不解锁这条会话就永远收不到内容(容器仍可能从别的路径回复)。
+       * 失败残留的旧历史是可接受的次要问题,远好于「彻底静默」。
+       */
+      if (curSid && this._blankSessionId === curSid) this._blankSessionId = null;
     } finally {
       this._sending.set(false);
     }
@@ -1339,6 +1485,24 @@ export class ChatService {
   /** 向上翻历史(AC-04.4);返回是否还有更早的消息 */
   async paginate(): Promise<boolean> {
     if (environment.useMockData || !this.roomId) return false;
+    /*
+     * 🔴 新建态**禁止翻页**(2026-11-08,「新建会话仍带出历史」的最后一个入口)。
+     *
+     * `paginate()` 是三条回填路径里唯一没有新建态保护的:
+     *   · `storeVersion` effect → 已加 `_blankSessionId` 判定
+     *   · `appendIncoming`    → 已加 `_blankSessionId` 判定
+     *   · `paginate`          → 原先没有,且 `paginateBack()` 翻的是**整个房间**的历史
+     *     (`room.getLiveTimeline()`),不是当前 thread 的 history。
+     *
+     * 触发链:新建会话首屏只有 0~1 条消息、不满一屏 →
+     *   移动端 `fillViewportIfNeeded()` / 桌面端贴底逻辑 → 自动 `paginate()`
+     *   → 拉回 30 条房间历史 → 按会话过滤时把旧内容认领进来。
+     * 页面上还会出现「加载更早的消息」按钮,因为 `hasMoreHistory` 取自房间级 token。
+     *
+     * 新建会话本来就没有「更早的消息」,直接返回 false。
+     */
+    const sidNow = this._sessionId();
+    if (!sidNow || this._blankSessionId === sidNow) return false;
     // 防重入:滚动事件高频触发,加载中直接忽略,避免重复分页
     if (this._paginating()) return true;
     this._paginating.set(true);
@@ -1436,7 +1600,29 @@ export class ChatService {
     const roomId = this.roomId;
     if (!agentId || !roomId) return null;
     const exist = this._sessionId();
-    if (exist) return this.sessions.byId(exist) ?? null;
+    if (exist) {
+      const found = this.sessions.byId(exist);
+      /*
+       * 🔴 有 id 但目录里查不到 = 这条会话已被 `pruneEmpty` 删掉(2026-11-08)。
+       *
+       * 原实现直接 `return null`,调用方却继续往下走:消息进 `_messages`、
+       * 不进任何会话目录;用户再发一条又建一条新的 → 左栏出现**两条外观完全相同**
+       * 的会话(用户实测图35)。
+       *
+       * 现在:不再悄悄返回 null。把指针清掉并**就地重建一条**,
+       * 让这次发送仍然落在一个真实存在的会话上。
+       */
+      if (!found) {
+        const recreated = this.sessions.create(roomId, agentId);
+        this._sessionId.set(recreated.id);
+        if (this._blankWorkspace) {
+          this.sessions.moveTo(recreated.id, this._blankWorkspace);
+          this._blankWorkspace = null;
+        }
+        return recreated;
+      }
+      return found;
+    }
     const created = this.sessions.create(roomId, agentId);
     /*
      * 「新建对话」时指定的工作区在**这一刻**落地。
@@ -1512,7 +1698,12 @@ export class ChatService {
   /** 当前派活锚点(供群聊页在冷启动时补种) */
   readonly dispatchAnchors = this._dispatchAnchor.asReadonly();
 
-  private belongsToSession(m: MxMessage, sessionId: string): boolean {
+  private belongsToSession(
+    m: MxMessage,
+    sessionId: string,
+    live = false,
+    ownedRoots?: ReadonlySet<string>,
+  ): boolean {
     if (!m.threadId) {
       /*
        * 🔴 群聊派活的隔离(2026-11-06 用户裁决:单聊永远看不到)。
@@ -1597,10 +1788,70 @@ export class ChatService {
      *   · 线程根是容器造的(非 approot) → **属于当前会话**,按时间混排在消息流里
      *
      * 这样既不会在左栏冒出重复会话,内容也不会丢。
+     *
+     * 🔴 但要**限定在当前会话**(2026-11-08 修复)。
+     *
+     * 原实现直接 `return !isAppSessionId(m.threadId)` —— 意味着容器开过的
+     * **每一个**线程根都属于**每一个**会话。后果:新建一个空会话后,
+     * 该房间里所有旧线程的消息都会被它认领,上方凭空出现几天前的对话
+     * (用户实测图31)。
+     *
+     * 现在:**历史回填**时容器线程根只归「已经认领过它」的会话 ——
+     * 新会话从没认领过任何根,自然什么都拿不到,符合「新建即空白」的预期。
+     * **实时事件**(`live=true`)仍然接纳:那是容器刚刚发出的内容,
+     * 一定是当前这次对话的产出(截图、交付物都在里面)。
      */
-    return !isAppSessionId(m.threadId);
+    if (!m.threadId) return false;
+    if (live) return !isAppSessionId(m.threadId);
+    const roots = ownedRoots ?? this._ownedThreadRoots;
+    return roots.has(m.threadId);
+  }
+
+  /**
+   * 算出某会话「拥有」的容器线程根(2026-11-08)。
+   *
+   * 判据:该线程根下,存在**属于本会话的用户消息**。
+   * 用户在这个会话里发过东西(`threadId === sid` 或 `sessionId === sid`),
+   * 容器才会为它开子线程 —— 那些子线程天然属于本会话。
+   *
+   * 新建的空会话没有任何用户消息 → 返回空集 → 旧线程一个都不认领。
+   */
+  private rootsOfSession(
+    raw: MxMessage[],
+    sessionId: string,
+    myUserId: string,
+  ): Set<string> {
+    const roots = new Set<string>();
+    // 本会话的用户消息挂在哪些容器线程根下
+    const myRoots = new Set<string>();
+    for (const m of raw) {
+      if (m.sender !== myUserId) continue;
+      if (m.threadId === sessionId) {
+        myRoots.add(m.threadId);
+        roots.add(sessionId);
+      }
+      if (m.sessionId === sessionId) {
+        myRoots.add(m.eventId);
+        if (m.threadId) roots.add(m.threadId);
+      }
+    }
+    // 容器开在「本会话用户消息所属根」下的子线程,也算本会话
+    for (const m of raw) {
+      const root = m.threadId;
+      if (!root || isAppSessionId(root)) continue;
+      if (myRoots.has(root) || m.sessionId === sessionId) roots.add(root);
+    }
+    return roots;
   }
 
+  /**
+   * 本会话已经认领的「容器线程根」(2026-11-08)。
+   *
+   * 由 `mapTimeline` 的归属计算过程写入:当前会话自己产生过的容器线程根。
+   * 切换会话时清空。没有它就无法区分「本会话的容器子线程」和「别的会话的」。
+   */
+  private readonly _ownedThreadRoots = new Set<string>();
+
   /**
    * 清理「历史对话」遗留会话(2026-10-29 用户裁决下线)。
    *
@@ -1660,8 +1911,22 @@ export class ChatService {
     if (!this._agentId()) return;
     const sid = this._sessionId();
     // 🔴 多会话隔离:别的会话的消息不进入当前会话的列表
-    if (!sid || !this.belongsToSession(m, sid)) return;
-    if (m.eventId && this._messages().some((x) => x.id === m.eventId)) return;
+    if (!sid || !this.belongsToSession(m, sid, true)) return;
+    /*
+     * 🔴 新建态:只放行**本会话自己的消息**(2026-11-08)。
+     *
+     * `startBlank()` 之后新会话还没有任何 thread 根,
+     * `belongsToSession(live=true)` 对旧事件一律放行,会把别处的内容塞进来。
+     * 但用户刚发的那条(`threadId === sid`)必须进来 —— 它是本会话成立的凭据,
+     * 也是 storeVersion effect 判定「已落地、可解除保护」的依据。
+     */
+    if (this._blankSessionId && sid === this._blankSessionId && m.threadId !== sid) return;
+    /*
+     * 没有 eventId 的事件(极少见)用「时间 + 发送者」当退路 id;
+     * 退路 id 可能和时间相同的消息撞上,故只用来去重,不参与后续判定。
+     */
+    const eid = m.eventId || `${m.ts}-${m.sender}`;
+    if (this._messages().some((x) => x.id === eid)) return;
     const [mapped] = this.mapTimeline([m], myUserId);
     this._messages.update((list) => [...list, mapped]);
     this.rememberDemoHistory();

+ 17 - 5
src/app/core/group-store.service.ts

@@ -197,19 +197,31 @@ export class GroupStoreService {
   }
 
   /**
-   * 用 Matrix 重建结果覆盖某群(2026-11-07)。
+   * 用 Matrix 重建结果合并进某群(2026-11-07,2026-11-08 改为**保守合并**)。
    *
    * 调用方已完成「本地优先」合并;这里只负责落表 + 并集 targets + 落盘。
+   *
+   * 🔴 原来是**整表替换**:`[sessionId]: [...entries]`。
+   *    Matrix 重建时如果少推导出条目(分页没翻到、`isNewReply` 过滤、
+   *    房间一时读不到),本地已有的内容就被直接抹掉 ——
+   *    用户看到的是「后续回复把之前的输出覆盖了」。
+   *    现在改为:Matrix 结果为准,但**Matrix 没有的本地条目保留**。
    */
   replaceFromMatrix(
     sessionId: string,
     entries: GroupEntry[],
     targets: Record<string, string[]>,
   ): void {
-    this._bySession.update((all) => ({
-      ...all,
-      [sessionId]: [...entries].sort((a, b) => a.at - b.at),
-    }));
+    this._bySession.update((all) => {
+      const local = all[sessionId] ?? [];
+      const seen = new Set(entries.map((e) => e.id));
+      // 本地有、Matrix 没有的条目:保留(不因为重建不全就丢内容)
+      const kept = local.filter((e) => e.id && !seen.has(e.id));
+      return {
+        ...all,
+        [sessionId]: [...entries, ...kept].sort((a, b) => a.at - b.at),
+      };
+    });
     this._targetsOf.update((m) => {
       const next = { ...m };
       for (const [turnId, list] of Object.entries(targets)) {

+ 8 - 3
src/app/core/group-turns.ts

@@ -95,12 +95,17 @@ export interface GroupTurnInput {
   typingOf: (agentId: string) => boolean;
   /** 当前时间(注入便于单测) */
   now: number;
-  /** 回合关闭所需的静默时长(ms),缺省 3000 */
+/** 回合关闭所需的静默时长(ms),缺省 3000 */
   quietMs?: number;
 }
 
-/** 默认静默时长,与单聊 `turn-aggregator.ts` 的 `QUIET_MS` 保持一致 */
-export const GROUP_QUIET_MS = 3000;
+/**
+ * 默认静默时长,与单聊 `turn-aggregator.ts` 的 `QUIET_MS` 保持一致。
+ *
+ * 🔴 2026-11-08 由 3 秒放宽到 25 秒,与单聊同步 —— 群成员跑工具时
+ * 分段之间同样有十几秒空档,3 秒会把「正在输出」提前判成「已结束」。
+ */
+export const GROUP_QUIET_MS = 25_000;
 
 /**
  * 把群内条目切成回合序列。

+ 28 - 1
src/app/core/mobile-chat-view.service.ts

@@ -1,6 +1,6 @@
 import { computed, inject, Injectable } from '@angular/core';
 import { Agent, Deliverable } from './api/models';
-import { ChatSession } from './chat-session';
+import { ChatSession, groupOf } from './chat-session';
 import { ChatSessionService } from './chat-session.service';
 import { DeliverablesService } from './deliverables.service';
 import { WorkspaceService } from './workspace.service';
@@ -76,6 +76,33 @@ export class MobileChatViewService {
       .map((s) => this.toRow(s)),
   );
 
+  /**
+   * 已归档会话按**原分组**切开(2026-11-08 用户反馈)。
+   *
+   * 归档是「收起来」,不是「搬走」:群聊归档要回到群聊分组下,
+   * 工作区的回到该工作区下,日常的回到日常下。
+   * 归属判据统一走 `groupOf()`(与桌面端共用),避免两端分叉。
+   */
+  readonly archivedGroups = computed<{
+    daily: SessionRow[];
+    group: SessionRow[];
+    byWorkspace: Map<string, SessionRow[]>;
+  }>(() => {
+    const daily: SessionRow[] = [];
+    const group: SessionRow[] = [];
+    const byWorkspace = new Map<string, SessionRow[]>();
+    for (const r of this.archived()) {
+      const g = groupOf(r.session);
+      if (g.kind === 'group') group.push(r);
+      else if (g.kind === 'workspace') {
+        const list = byWorkspace.get(g.id) ?? [];
+        list.push(r);
+        byWorkspace.set(g.id, list);
+      } else daily.push(r);
+    }
+    return { daily, group, byWorkspace };
+  });
+
   /** 未读总数(底栏角标与筛选共用) */
   readonly unreadTotal = computed(() => this.active().reduce((n, r) => n + r.unread, 0));
 

+ 17 - 2
src/app/core/task-pulse.service.spec.ts

@@ -79,10 +79,25 @@ describe('TaskPulseService', () => {
   });
 
   it('🔴 容器明确报异常/离线 → 覆盖其它判断,标 danger', () => {
-    // 即使心跳新鲜、本地也没在推测忙碌,也要如实显示它坏了
+    /*
+     * 2026-11-08 改:文案统一由 `resolveAgentState` 出。
+     * error → 「运行异常」;offline / stopped 都归入不可用 → 「已离线」
+     * (与移动端会话列表、PC 左栏、新建弹窗同一张表,不再各处自造说法)。
+     */
     expect(svc.describe(agent({ status: 'error' }))).toEqual({ text: '运行异常', tone: 'danger' });
     expect(svc.describe(agent({ status: 'offline' }))).toEqual({ text: '已离线', tone: 'danger' });
-    expect(svc.describe(agent({ status: 'stopped' }))).toEqual({ text: '已停止', tone: 'danger' });
+    expect(svc.describe(agent({ status: 'stopped' }))).toEqual({ text: '已离线', tone: 'danger' });
+  });
+
+  it('🔴 心跳过期但 status=running 的僵尸容器 → 报离线,不显示任务名', () => {
+    const line = svc.describe(agent({ status: 'running', currentTask: '写报告', heartbeatAgeSec: 3_000_000 }));
+    expect(line.tone).toBe('danger');
+    expect(line.text).not.toContain('写报告');
+  });
+
+  it('🔴 上报器自指的 running → 不把 "reporter heartbeat" 当成任务名', () => {
+    const line = svc.describe(agent({ status: 'running', currentAction: 'reporter heartbeat @ 12:00', currentTask: undefined }));
+    expect(line.text).not.toContain('reporter');
   });
 
   it('心跳新鲜但容器说 error → 不显示「空闲」(先报坏)', () => {

+ 26 - 13
src/app/core/task-pulse.service.ts

@@ -1,15 +1,23 @@
 import { computed, effect, inject, Injectable, signal } from '@angular/core';
 import { Agent } from './api/models';
 import { LiveService } from './live.service';
+import { QUIET_MS } from './turn-aggregator';
+import { isReporterSelfTalk, resolveAgentState } from './agent-state';
 
 /** 服务端数据「新鲜」窗口:超过这个时长的 idle 不再当作「刚刚完成」展示 */
 const FRESH_MS = 90_000;
 
 /**
  * 「还在回复」的静默窗口:最近一次消息进来后这么久内,都算正在输出。
- * 容器回复是分段发的,段与段之间有几秒间隔,取 20 秒足够覆盖又不至于一直挂着。
+ *
+ * 🔴 必须与 `turn-aggregator.QUIET_MS`(回合卡片的 live 判定)**同一口径**
+ * (2026-11-08 用户反馈「只闪一下『正在回复』就消失」)。
+ *
+ * 原先这里是 20 秒、回合那边是 3 秒:顶栏说「正在回复…」,
+ * 正文区的「正在思考…」却早已消失 —— 两处对同一件事给出相反结论,
+ * 用户不知道该信哪个。现在两处直接取同一个常量,想改一起改。
  */
-const REPLY_QUIET_MS = 20_000;
+const REPLY_QUIET_MS = QUIET_MS;
 
 /** 超过这么久的事件视为「历史重放」,不据此判断"正在回复" */
 const STALE_MSG_MS = 60_000;
@@ -183,19 +191,24 @@ export class TaskPulseService {
   private describeOf(agent: Agent | undefined, now: number): TaskLine {
     if (!agent) return { text: '', tone: 'neutral' };
     const local = this._localStart().get(agent.id);
-    if (agent.status === 'running') {
-      const task = agent.currentTask?.trim() || agent.currentAction?.trim();
-      return { text: task || '正在处理', tone: 'success', active: true };
-    }
     /*
-     * 容器明确报的「坏了」要压过其它判断(用户最需要先知道的是它能不能干活)。
-     * 注意跟「未同步」区分:这是容器**主动说**自己坏了,不是我们看不到。
+     * 🔴 不可用优先,且判据与全站同一套(2026-11-08)。
+     *
+     * 原先这里只判 `status`,不看心跳。后果:45 天没心跳、`status=running`
+     * 的僵尸容器在这里显示「状态未同步」或甚至任务名,而移动端会话列表
+     * 已经把它判成红色「离线」—— 同一个容器两处结论相反。
+     * 现在直接复用 `resolveAgentState`,先处理不可用分支。
      */
-    if (agent.status === 'error' || agent.status === 'offline' || agent.status === 'stopped') {
-      return {
-        text: { error: '运行异常', offline: '已离线', stopped: '已停止' }[agent.status],
-        tone: 'danger',
-      };
+    const resolved = resolveAgentState({ agent, now });
+    if (resolved.state === 'error') {
+      return { text: '运行异常', tone: 'danger' };
+    }
+    if (resolved.state === 'offline') {
+      return { text: '已离线', tone: 'danger' };
+    }
+    if (agent.status === 'running' && !isReporterSelfTalk(agent)) {
+      const task = agent.currentTask?.trim() || agent.currentAction?.trim();
+      return { text: task || '正在处理', tone: 'success', active: true };
     }
     if (local && this.isBusy(agent)) {
       return {

+ 17 - 2
src/app/core/turn-aggregator.ts

@@ -24,8 +24,23 @@ import { Message } from './api/models';
  *    单聊里两条不相干的回答若挨得近,无脑后处理会被并成一条。
  */
 
-/** 关闭回合所需的静默时长(ms) */
-export const QUIET_MS = 3000;
+/**
+ * 关闭回合所需的静默时长(ms)。
+ *
+ * 🔴 2026-11-08 由 3 秒放宽到 25 秒(用户反馈「只闪一下『正在回复』就消失」)。
+ *
+ * 3 秒对**分段输出**太短:容器跑工具时,两段正文之间常有 5–20 秒空档
+ * (调终端、跑代码)。3 秒一到回合就「关闭」,界面的「正在思考…」随即消失,
+ * 而容器其实还在干活 —— 用户看到的是「回复没了,也不知道它还在不在」。
+ *
+ * 25 秒与 `TaskPulseService.REPLY_QUIET_MS`(20 秒)同量级:
+ * 会话区与顶栏必须用**同一条时间范围**描述「还在输出」,否则两处口径打架
+ * (顶栏说「正在回复」、正文区却一片空白,正是本次问题的表现)。
+ *
+ * 代价:容器真的答完后,回合卡片会多显示约 25 秒的「进行中」态。
+ * 相比「还在输出却显示已结束」,这个方向的误判是可接受且更安全的。
+ */
+export const QUIET_MS = 25_000;
 
 /**
  * 一个回合。

+ 30 - 3
src/app/desktop/pages/chat/chat.ts

@@ -28,7 +28,8 @@ import { NewSessionModalService } from '../../../components/domain/new-session-m
 import { ImageLightboxComponent } from '../../../components/preview/image-lightbox/image-lightbox';
 import { previewKindOf, type PreviewKind } from '../../../components/preview/file-kind';
 import { PresenceBadgeComponent } from '../../../components/domain/presence-badge/presence-badge';
-import { AgentPresence, agentPresence } from '../../../core/api/models';
+import { Agent, AgentPresence, agentPresence } from '../../../core/api/models';
+import { resolveAgentState } from '../../../core/agent-state';
 import { StatusBadgeComponent } from '../../../components/domain/status-badge/status-badge';
 import {
   RailFile,
@@ -53,7 +54,7 @@ import { ChatSessionService } from '../../../core/chat-session.service';
 import { MatrixSessionService } from '../../../core/matrix-session.service';
 import { WorkspaceService } from '../../../core/workspace.service';
 import { AgentModalService } from '../../../components/domain/agent-modal/agent-modal.service';
-import { buildTurns } from '../../../core/turn-aggregator';
+import { buildTurns, QUIET_MS } from '../../../core/turn-aggregator';
 import { AssistantTurn, turnsFromDm } from '../../../core/assistant-turn';
 import { AssistantTurnComponent } from '../../../components/domain/assistant-turn/assistant-turn';
 
@@ -212,7 +213,12 @@ interface ConversationRow {
                 看起来像故障(用户实测:「空闲 空闲」两行、橙色像告警)。
                 这里只保留任务行**不表达**的内容:容器不可用 / 通道未上报。
               -->
-              @if (a.status === 'error' || a.status === 'offline' || a.status === 'stopped') {
+              <!--
+                不可用徽标(2026-11-08 统一口径):
+                原先只判 status,不看心跳 → 45 天没心跳的僵尸容器在这里不报警,
+                而移动端会话列表已把它判成红色「离线」。现在同走 resolveAgentState。
+              -->
+              @if (agentUnavailable(a)) {
                 <app-status-badge [agent]="a" />
               }
             }
@@ -1845,6 +1851,15 @@ interface ConversationRow {
 export class DkChatPage {
   readonly chat = inject(ChatService);
   readonly live = inject(LiveService);
+
+  /**
+   * 容器是否不可用(2026-11-08):全站统一口径,判心跳 + 防上报器自指。
+   * 用于决定是否显示顶部告警徽标。
+   */
+  protected agentUnavailable(a: Agent): boolean {
+    const s = resolveAgentState({ agent: a }).state;
+    return s === 'offline' || s === 'error';
+  }
   /** 重连时重建常驻 Matrix 连接(登出/断线后需要) */
   private readonly matrixSession = inject(MatrixSessionService);
   private readonly unread = inject(UnreadService);
@@ -2067,6 +2082,16 @@ export class DkChatPage {
       }),
       now,
       this.chat.peerTyping(),
+      /*
+       * 🔴 统一「还在输出」的口径(2026-11-08 用户反馈「只闪一下正在回复就消失」)。
+       *
+       * ① 窗口用 QUIET_MS(25 秒),而不是原先默认的 3 秒 —— 容器跑工具时
+       *    两段正文之间常有十几秒空档,3 秒会把「还在输出」提前判成「已结束」。
+       * ② 容器**明确报 idle** 时才忽略窗口立即收尾:只有它能宣布「干完了」,
+       *    比任何时间窗都准,也避免真的结束后还多挂 25 秒。
+       */
+      QUIET_MS,
+      this.chat.agentIdle(),
     )) {
       // 用户消息:单独一条,保持单聊原有观感
       if (t.userMessage) {
@@ -2814,6 +2839,8 @@ export class DkChatPage {
    *    避免一次把整个历史拉穿(那反而拖慢首次进入)。
    */
   private async fillViewportIfNeeded(): Promise<void> {
+    /* 新建态不补齐(2026-11-08):新建会话没有更早的消息,不挡会自动翻出房间旧历史 */
+    if (this.chat.state() === 'empty') return;
     const el = this.messagesEl()?.nativeElement;
     if (!el) return;
     for (let i = 0; i < 3; i++) {

+ 35 - 15
src/app/desktop/pages/workbench/workbench.ts

@@ -16,6 +16,7 @@ import { InfiniteScrollDirective } from '../../../shared/infinite-scroll.directi
 import { FilterChipsComponent } from '../../../components/domain/filter-chips/filter-chips';
 import { StatusBadgeComponent } from '../../../components/domain/status-badge/status-badge';
 import { Agent } from '../../../core/api/models';
+import { resolveAgentState } from '../../../core/agent-state';
 import { DeviceService } from '../../../core/device.service';
 import { hasMetrics as hasMetricsFn, fmtBalance, fmtPercent, fmtTokens, isMatrixReady, usageOf } from '../../../core/format';
 import { LiveService } from '../../../core/live.service';
@@ -133,8 +134,8 @@ import { fmtDuration } from '../../../core/time';
             />
             <ui-statistic
               title="需要关注"
-              [value]="live.blockedAgents().length"
-              [color]="live.blockedAgents().length > 0 ? 'danger' : 'default'"
+              [value]="blockedCount()"
+              [color]="blockedCount() > 0 ? 'danger' : 'default'"
               icon="fa-solid fa-circle-exclamation"
             />
             <ui-statistic
@@ -505,8 +506,25 @@ export class DkWorkbenchPage implements OnInit {
   readonly channelAgent = signal<Agent | null>(null);
 
   readonly blockedTasks = computed(() => this.tasks.blocked());
+  /**
+   * 「运行中」计数(2026-11-08 统一口径):含回复/思考,并排除心跳过期的僵尸容器。
+   * 原先只数 `status === 'running'`,45 天没心跳的容器也算进去。
+   */
   readonly runningCount = computed(
-    () => this.live.agents().filter((a) => a.status === 'running').length,
+    () =>
+      this.live.agents().filter((a) => {
+        const s = resolveAgentState({ agent: a }).state;
+        return s === 'running' || s === 'replying' || s === 'thinking';
+      }).length,
+  );
+
+  /** 「需要关注」:不可用容器(离线 + 异常),与移动端同一张表 */
+  readonly blockedCount = computed(
+    () =>
+      this.live.agents().filter((a) => {
+        const s = resolveAgentState({ agent: a }).state;
+        return s === 'offline' || s === 'error';
+      }).length,
   );
 
   async ngOnInit(): Promise<void> {
@@ -529,10 +547,13 @@ export class DkWorkbenchPage implements OnInit {
     const q = this.keyword().trim().toLowerCase();
     const f = this.statusFilter();
     return this.live.agents().filter((a) => {
-      if (f === 'running' && a.status !== 'running') return false;
-      if (f === 'idle' && a.status !== 'idle') return false;
-      // 「异常」= error/offline/stopped
-      if (f === 'problem' && !['error', 'offline', 'stopped'].includes(a.status)) return false;
+      /* 2026-11-08 统一口径:按合成态(含心跳)筛,与移动端同一张表 */
+      const s = resolveAgentState({ agent: a }).state;
+      const isRunning = s === 'running' || s === 'replying' || s === 'thinking';
+      const isProblem = s === 'offline' || s === 'error';
+      if (f === 'running' && !isRunning) return false;
+      if (f === 'idle' && s !== 'idle') return false;
+      if (f === 'problem' && !isProblem) return false;
       if (!q) return true;
       return (
         a.name.toLowerCase().includes(q) || (a.specialty ?? '').toLowerCase().includes(q)
@@ -546,16 +567,15 @@ export class DkWorkbenchPage implements OnInit {
   readonly hasMore = computed(() => this.shown() < this.filteredAgents().length);
 
   readonly statusChips = computed(() => {
-    const all = this.live.agents();
+    const all = this.live.agents().map((a) => resolveAgentState({ agent: a }).state);
+    const running = all.filter((s) => s === 'running' || s === 'replying' || s === 'thinking').length;
+    const idle = all.filter((s) => s === 'idle').length;
+    const problem = all.filter((s) => s === 'offline' || s === 'error').length;
     return [
       { key: 'all', label: '全部', count: all.length },
-      { key: 'running', label: '执行中', count: all.filter((a) => a.status === 'running').length },
-      { key: 'idle', label: '空闲', count: all.filter((a) => a.status === 'idle').length },
-      {
-        key: 'problem',
-        label: '异常',
-        count: all.filter((a) => ['error', 'offline', 'stopped'].includes(a.status)).length,
-      },
+      { key: 'running', label: '执行中', count: running },
+      { key: 'idle', label: '空闲', count: idle },
+      { key: 'problem', label: '异常', count: problem },
     ];
   });
 

+ 185 - 37
src/app/mobile/components/m-drawer/m-drawer.ts

@@ -3,6 +3,8 @@ import { ChangeDetectionStrategy, Component, computed, effect, inject, input, mo
 import { NavigationEnd, Router } from '@angular/router';
 import { filter } from 'rxjs';
 import { Agent } from '../../../core/api/models';
+import { groupOf } from '../../../core/chat-session';
+import { AgentStateView, resolveAgentState } from '../../../core/agent-state';
 import { ChatSessionService } from '../../../core/chat-session.service';
 import { LiveService } from '../../../core/live.service';
 import { MobileChatViewService, SessionRow } from '../../../core/mobile-chat-view.service';
@@ -64,7 +66,7 @@ interface NavRow {
         </button>
       </div>
 
-      <nav class="m-drawer-body" aria-label="功能">
+      <nav class="m-drawer-nav" aria-label="功能">
         <!--
           菜单项来自 NavMenuService(2026-11-03):顺序与显隐由用户配置,
           与 PC 左栏共用同一份偏好。
@@ -136,9 +138,10 @@ interface NavRow {
             </div>
           }
         }
+      </nav>
 
-
-        <div class="m-sep"></div>
+      <!-- 滚动区:从「对话」标题起,到抽屉底部(唯一可滚区域) -->
+      <nav class="m-drawer-body" aria-label="对话">
         <div class="m-drawer-caption">对话</div>
 
         <!-- ① 工作区:完整功能(加载 / 新建 / 重命名 / 删除 / 空工作区也显示) -->
@@ -188,6 +191,10 @@ interface NavRow {
             </div>
             <div [id]="'tree-' + ws.id" [hidden]="!isOpen(ws.id)">
               <ng-container *ngTemplateOutlet="tree; context: { $implicit: rowsInWorkspace(ws.id) }" />
+              <!-- 归档回到原分组(2026-11-08):该工作区的已归档会话留在这里 -->
+              <ng-container
+                *ngTemplateOutlet="archBlock; context: { $implicit: archivedIn(ws.id), key: 'arch-' + ws.id }"
+              />
             </div>
           </section>
         } @empty {
@@ -209,6 +216,10 @@ interface NavRow {
             </button>
             <div id="tree-group" [hidden]="!isOpen('__group')">
               <ng-container *ngTemplateOutlet="tree; context: { $implicit: groupSessions() }" />
+              <!-- 归档回到原分组(2026-11-08):群聊的已归档会话留在群聊下面 -->
+              <ng-container
+                *ngTemplateOutlet="archBlock; context: { $implicit: archivedGroup(), key: 'arch-group' }"
+              />
             </div>
           </section>
         }
@@ -227,23 +238,10 @@ interface NavRow {
           </button>
           <div id="tree-daily" [hidden]="!isOpen('__daily')">
             <ng-container *ngTemplateOutlet="tree; context: { $implicit: daily() }" />
-          </div>
-        </section>
-
-        <!-- ③ 已归档(默认收起) -->
-        <section>
-          <button
-            type="button"
-            class="m-tree-toggle"
-            [attr.aria-expanded]="isOpen('__archived')"
-            aria-controls="tree-archived"
-            (click)="toggle('__archived')"
-          >
-            <i class="fa-solid fa-chevron-down m-arrow" aria-hidden="true"></i>
-            已归档<span class="m-tree-count">{{ archived().length }}</span>
-          </button>
-          <div id="tree-archived" [hidden]="!isOpen('__archived')">
-            <ng-container *ngTemplateOutlet="tree; context: { $implicit: archived() }" />
+            <!-- 归档回到原分组(2026-11-08):日常的已归档会话留在日常下面 -->
+            <ng-container
+              *ngTemplateOutlet="archBlock; context: { $implicit: archivedDaily(), key: 'arch-daily' }"
+            />
           </div>
         </section>
       </nav>
@@ -303,6 +301,12 @@ interface NavRow {
                 @if (row.session.kind === 'group') {
                   {{ row.session.memberIds?.length ?? 0 }} 个容器
                 } @else {
+                  <!-- 容器状态点(2026-11-08):与 PC 左栏、会话列表同一口径 -->
+                  <i
+                    class="m-state-dot"
+                    [attr.data-tone]="stateOf(row).tone"
+                    [attr.aria-label]="stateOf(row).text"
+                  ></i>
                   {{ row.agent?.name ?? '未知容器' }}
                 }
               </span>
@@ -325,21 +329,47 @@ interface NavRow {
         <p class="m-tree-empty">暂无会话</p>
       }
     </ng-template>
+
+    <!--
+      「已归档」子块(2026-11-08):挂在**原分组**下面,默认收起。
+      归档是「收起来看」,不是「搬到别的分组」——群聊归档仍在群聊下,
+      工作区归档仍在那个工作区下,日常归档仍在日常下。
+    -->
+    <ng-template #archBlock let-rows let-key="key">
+      @if (rows.length > 0) {
+        <button
+          type="button"
+          class="m-arch-toggle"
+          [attr.aria-expanded]="isOpen(key)"
+          (click)="toggle(key)"
+        >
+          <i
+            class="fa-solid m-arrow"
+            [class.fa-chevron-right]="!isOpen(key)"
+            [class.fa-chevron-down]="isOpen(key)"
+            aria-hidden="true"
+          ></i>
+          已归档<span class="m-tree-count">{{ rows.length }}</span>
+        </button>
+        @if (isOpen(key)) {
+          <ng-container *ngTemplateOutlet="tree; context: { $implicit: rows }" />
+        }
+      }
+    </ng-template>
   `,
   styles: [
     `
       /*
-       * 🔴 不能用 display:contents。
+       * 【2026-11-08】不能用 display: contents。
        *
-       * 它会让抽屉子树的层叠**提升到父级层叠上下文**,与底栏(fixed,出现在
-       * shell 模板更后面)直接比较 —— 同为 fixed 定位时后者胜出,
-       * 于是抽屉内的操作菜单被底栏压住(实测 elementFromPoint 命中 .m-tab)。
+       * 它会让抽屉子树的层叠提升到父级层叠上下文,与底栏(fixed,出现在 shell
+       * 模板更后面)直接比较 —— 同为 fixed 时后者胜出,抽屉内的操作菜单被底栏压住。
        *
-       * 改成 display:block + 独立层叠上下文,抽屉内所有浮层的 z-index
-       * 才能相对底栏正确生效。
+       * 改成 display:block:Host 有真实盒子,相对定位/高度约束才生效;
+       * 抽屉与遮罩都是 position:fixed 且自带 z-index,层叠顺序不受影响。
        */
       :host {
-        display: contents;
+        display: block;
       }
       .m-mask {
         position: fixed;
@@ -361,12 +391,21 @@ interface NavRow {
         left: 0;
         z-index: var(--z-modal);
         width: min(84%, 330px);
+        box-sizing: border-box;
         display: flex;
         flex-direction: column;
+        /*
+         * 抽屉最外层**不滚动**(用户 2026-11-08 明确指出):唯一滚动区是下方的
+         * .m-drawer-body。这里只负责撑满视口 + 裁掉溢出。
+         */
         overflow: hidden;
-        /* 底部预留 TabBar 高度:底栏层级高于抽屉,不预留会遮住最后一项 */
+        /*
+         * 底部只预留安全区:底栏在抽屉打开时被 body:has(.m-drawer.show) .m-tabbar
+         * 直接隐藏(styles.scss),不需要再为它留 70px —— 那 70px 就是用户看到的
+         * 「下面一块空白没利用起来」。
+         */
         padding: calc(var(--mob-safe-top) + var(--sp-4)) 0
-          calc(var(--mob-tabbar-h) + var(--mob-safe-bottom) + var(--sp-4));
+          calc(var(--mob-safe-bottom) + var(--sp-3));
         border-radius: 0 var(--r-lg) var(--r-lg) 0;
         background: var(--surface);
         box-shadow: var(--shadow-pop);
@@ -377,17 +416,37 @@ interface NavRow {
         transform: none;
       }
       .m-drawer-head,
+      .m-drawer-nav,
       .m-drawer-body {
+        /* 固定区不参与收缩:否则高度不够时会被压扁 */
+        flex: none;
         padding-left: var(--sp-4);
         padding-right: var(--sp-4);
       }
       .m-drawer-head {
         padding-bottom: var(--sp-3);
+      }
+      /*
+       * 【2026-11-08】导航块(会话/项目/任务/通讯录/更多)固定在抽屉上部,
+       * 不随对话列表滚动 —— 它是全局入口,滚走了就找不到。
+       */
+      .m-drawer-nav {
+        padding-bottom: var(--sp-3);
         border-bottom: 1px solid var(--edge);
       }
+      /*
+       * 【2026-11-08】唯一滚动区:从「对话」标题到抽屉底部。
+       *
+       * 必须 min-height: 0:flex 子项默认 min-height:auto 会撑到内容高度,
+       * flex:1 + overflow-y:auto 失效,溢出被抽屉裁掉(用户反馈「上半截卡住不动」)。
+       * 顶部 padding 改由标题自身上边距承担,滚动时标题不会被切掉一半。
+       */
       .m-drawer-body {
-        flex: 1;
+        flex: 1 1 auto;
+        min-height: 0;
         overflow-y: auto;
+        overscroll-behavior: contain;
+        -webkit-overflow-scrolling: touch;
       }
       .m-entry {
         display: flex;
@@ -445,6 +504,8 @@ interface NavRow {
         border-top: 1px solid var(--edge);
       }
       .m-drawer-caption {
+        /* 滚动区自身不加 padding-top,否则滚动时「对话」标题会被切掉一半 */
+        margin-top: var(--sp-3);
         padding: var(--sp-1) var(--sp-2);
         color: var(--muted);
         font-size: var(--fs-12);
@@ -465,6 +526,29 @@ interface NavRow {
         font-weight: 600;
         cursor: pointer;
       }
+      /*
+       * 分组内的「已归档」子块(2026-11-08):比分组标题轻一档,
+       * 缩进一点,视觉上属于上级分组而不是平级。
+       */
+      .m-arch-toggle {
+        display: flex;
+        align-items: center;
+        gap: var(--sp-1);
+        width: calc(100% - var(--sp-4));
+        min-height: 32px;
+        margin-left: var(--sp-4);
+        padding: 0 var(--sp-2);
+        border: 0;
+        border-radius: var(--r-sm);
+        background: none;
+        color: var(--faint, var(--muted));
+        font: inherit;
+        font-size: var(--fs-12);
+        cursor: pointer;
+      }
+      .m-arch-toggle:hover {
+        background: var(--surface-hover);
+      }
       .m-arrow {
         font-size: var(--fs-10);
         transition: transform var(--motion-fast) var(--ease-standard);
@@ -739,6 +823,28 @@ interface NavRow {
         color: var(--primary);
         font-size: var(--fs-10);
       }
+      /* 容器状态点(2026-11-08):与 PC 左栏同尺寸 */
+      .m-state-dot {
+        display: inline-block;
+        width: 6px;
+        height: 6px;
+        margin-right: 4px;
+        border-radius: 50%;
+        vertical-align: middle;
+        background: var(--faint);
+      }
+      .m-state-dot[data-tone='success'] {
+        background: var(--success);
+      }
+      .m-state-dot[data-tone='warning'] {
+        background: var(--warning);
+      }
+      .m-state-dot[data-tone='danger'] {
+        background: var(--danger);
+      }
+      .m-state-dot[data-tone='info'] {
+        background: var(--primary);
+      }
       .m-tree-empty {
         margin: var(--sp-1) var(--sp-2);
         color: var(--muted);
@@ -832,8 +938,27 @@ export class MDrawerComponent {
   protected readonly daily = computed(() => this.view.dailySessions());
   /** 群会话(2026-11-05) */
   protected readonly groupSessions = computed(() => this.view.groupSessions());
+  /** 全部已归档(时间倒序) */
   protected readonly archived = this.view.archived;
 
+  /*
+   * 已归档会话按**原分组**切开(2026-11-08 用户反馈):
+   * 归档后要回到群聊 / 工作区 / 日常各自的分组下,而不是统一堆在底部一个「已归档」里。
+   * 判据来自 `groupOf()`,与桌面端共用。
+   */
+  private readonly archivedGroups = this.view.archivedGroups;
+  protected readonly archivedDaily = computed(() => this.archivedGroups().daily);
+  protected readonly archivedGroup = computed(() => this.archivedGroups().group);
+  /** 某工作区下的已归档会话 */
+  protected archivedIn(wsId: string): SessionRow[] {
+    return this.archivedGroups().byWorkspace.get(wsId) ?? [];
+  }
+
+  /** 会话所属容器的状态(2026-11-08):与 PC 左栏 / 会话列表同一口径 */
+  protected stateOf(row: SessionRow): AgentStateView {
+    return resolveAgentState({ agent: row.agent });
+  }
+
   constructor() {
     /*
      * 工作区必须在这里 load():原实现**全仓只有桌面左栏**调过,
@@ -862,11 +987,15 @@ export class MDrawerComponent {
     });
   }
 
-  /** 折叠状态:键为 workspaceId,`__daily` / `__archived` 为两个固定分组 */
-  private readonly expanded = signal<Record<string, boolean>>({ __daily: true, __archived: false, __group: true });
+  /**
+   * 折叠状态:键为工作区 id;`__daily` / `__group` 为固定分组;
+   * `arch-*` 前缀为各分组下的「已归档」子块(2026-11-08 新增,默认收起)。
+   */
+  private readonly expanded = signal<Record<string, boolean>>({ __daily: true, __group: true });
 
   protected isOpen(key: string): boolean {
-    return this.expanded()[key] ?? true;
+    // 归档子块默认收起:没显式点过就当关闭
+    return this.expanded()[key] ?? !key.startsWith('arch-');
   }
 
   /** 只切 `hidden` 与箭头,不重建列表(Demo 同法,避免整块重绘跳动) */
@@ -989,9 +1118,16 @@ export class MDrawerComponent {
             ? this.sessionsSvc.archive(row.session.id, false)
             : this.ask({
                 title: '归档会话',
-                message: `归档后「${row.session.title}」将移到「已归档」,可随时取消归档。`,
+                message: `归档后「${row.session.title}」将移到本分组的「已归档」,可随时取消归档。`,
                 text: '归档',
-                run: () => this.sessionsSvc.archive(row.session.id, true),
+                run: () => {
+                  this.sessionsSvc.archive(row.session.id, true);
+                  /*
+                   * 归档后自动展开**该会话原分组**的「已归档」子块 ——
+                   * 让用户看到它去了哪里,而不是以为消失了。
+                   */
+                  this.revealArchived(row);
+                },
               }),
       },
       {
@@ -1010,9 +1146,21 @@ export class MDrawerComponent {
     this.menu.set({ title: row.session.title, items });
   }
 
+  /**
+   * 展开某会话「原分组」下的已归档子块(2026-11-08)。
+   *
+   * 归档后用户需要**看到它去了哪里**;分组本身若收起着,也一并展开。
+   * 键与模板里的 `archBlock` 一致:`arch-<wsId>` / `arch-group` / `arch-daily`。
+   */
+  private revealArchived(row: SessionRow): void {
+    const g = groupOf(row.session);
+    const archKey = g.kind === 'workspace' ? `arch-${g.id}` : `arch-${g.kind}`;
+    const parentKey = g.kind === 'workspace' ? g.id : `__${g.kind}`;
+    this.expanded.update((m) => ({ ...m, [parentKey]: true, [archKey]: true }));
+  }
+
   /** 二级:选择目标工作区 */
-  private openMoveMenu(row: SessionRow): void {
-    if (row.session.workspaceId) {
+  private openMoveMenu(row: SessionRow): void {    if (row.session.workspaceId) {
       this.sessionsSvc.moveTo(row.session.id, null);
       return;
     }

+ 13 - 13
src/app/mobile/components/m-life-card/m-life-card.ts

@@ -1,7 +1,8 @@
 import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
 import { Agent } from '../../../core/api/models';
+import { resolveAgentState } from '../../../core/agent-state';
 import { MAvatarComponent } from '../m-avatar/m-avatar';
-import { AGENT_TONE, BadgeTone, MBadgeComponent } from '../m-badge/m-badge';
+import { BadgeTone, MBadgeComponent } from '../m-badge/m-badge';
 
 /**
  * 工作台数字生命卡(Demo `.life-card`)
@@ -174,17 +175,16 @@ export class MLifeCardComponent {
   readonly channels = output<Agent>();
   readonly recharge = output<Agent>();
 
-  protected readonly tone = computed<BadgeTone>(() => AGENT_TONE[this.agent().status] ?? 'neutral');
-  protected readonly statusText = computed(() => {
-    const a = this.agent();
-    if (a.statusText) return a.statusText;
-    return {
-      running: '运行中',
-      idle: '空闲',
-      error: '异常',
-      offline: '离线',
-      stopped: '已停止',
-    }[a.status];
-  });
+  /**
+   * 容器状态(2026-11-08 统一口径)。
+   *
+   * 🔴 原先自建一套文案(running 叫「运行中」)+ `AGENT_TONE`:
+   *    · 与聊天页、会话列表、通讯录的说法都不一致
+   *    · 不看心跳 → 僵尸容器显示绿色「运行中」
+   * 现在全站共用 `resolveAgentState`。
+   */
+  private readonly state = computed(() => resolveAgentState({ agent: this.agent() }));
+  protected readonly tone = computed<BadgeTone>(() => this.state().tone as BadgeTone);
+  protected readonly statusText = computed(() => this.state().text);
   protected readonly hasMetrics = computed(() => !!this.agent().host && this.agent().matrixReady);
 }

+ 39 - 6
src/app/mobile/pages/chat/chat.ts

@@ -26,13 +26,14 @@ import { ChatSessionService } from '../../../core/chat-session.service';
 import { TasksService } from '../../../core/tasks.service';
 import { fmtSize } from '../../../core/format';
 import { MobileChatViewService } from '../../../core/mobile-chat-view.service';
-import { buildTurns } from '../../../core/turn-aggregator';
+import { buildTurns, QUIET_MS } from '../../../core/turn-aggregator';
 import {
   Block,
   isToolActivityMessage,
   toolActivitySummary,
 } from '../../../core/message-segments';
 import { Message } from '../../../core/api/models';
+import { resolveAgentState } from '../../../core/agent-state';
 import { mergeConsecutiveBodies } from '../../../core/message-merge';
 import { ToolActivityComponent } from '../../../components/domain/tool-activity/tool-activity';
 import { AssistantTurn, turnsFromDm } from '../../../core/assistant-turn';
@@ -345,11 +346,12 @@ type SheetTab = 'task' | 'file' | 'deliver' | 'state';
               @default {
                 <dl class="m-kv">
                   <dt>状态</dt>
-                  <dd>{{ agent()?.statusText ?? agent()?.status ?? '—' }}</dd>
+                  <!-- 统一口径(2026-11-08):原先直接渲染 statusText ?? status,英文 id 会露出 -->
+                  <dd>{{ agentState()?.text ?? '—' }}</dd>
                   <dt>当前任务</dt>
                   <dd>{{ agent()?.currentTask || '—' }}</dd>
-                  <dt>心跳</dt>
-                  <dd>{{ agent()?.heartbeatAt || '—' }}</dd>
+                  <dt>上次心跳</dt>
+                  <dd>{{ heartbeatText() }}</dd>
                   <dt>连接</dt>
                   <dd>{{ chat.connection() === 'connected' ? '已连接' : '未连接' }}</dd>
                   <dt>会话</dt>
@@ -798,6 +800,9 @@ export class MbChatPage implements OnDestroy {
       buildTurns({ messages: this.chat.messages(), typing: this.chat.peerTyping(), now }),
       now,
       this.chat.peerTyping(),
+      /* 与桌面同口径(2026-11-08):25 秒静默窗口 + 容器明确空闲时立即收尾 */
+      QUIET_MS,
+      this.chat.agentIdle(),
     )) {
       if (t.userMessage) {
         out.push({ kind: 'msg', key: t.userMessage.id, message: t.userMessage });
@@ -826,11 +831,35 @@ export class MbChatPage implements OnDestroy {
   protected readonly title = computed(
     () => this.chat.currentSession()?.title ?? this.chat.agent()?.name ?? '会话',
   );
+  /** 容器名 + 状态(2026-11-08 统一口径):全站同一套 resolveAgentState */
   protected readonly subtitle = computed(() => {
     const a = this.chat.agent();
     if (!a) return '';
-    const st = a.statusText ?? a.status;
-    return `${a.name} · ${st === 'running' ? '运行中' : st === 'idle' ? '空闲' : st}`;
+    return `${a.name} · ${resolveAgentState({ agent: a }).text}`;
+  });
+
+  /** 状态抽屉用同一个口径(不自拼 statusText,英文 id 不外露) */
+  protected readonly agentState = computed(() => {
+    const a = this.chat.agent();
+    return a ? resolveAgentState({ agent: a }) : null;
+  });
+
+  /**
+   * 心跳时间 → 人话(2026-11-08)。
+   * 原先直接渲染 ISO 原文(`2026-11-08T03:12:44.000Z`),用户在手机上没法读。
+   */
+  protected readonly heartbeatText = computed(() => {
+    const a = this.chat.agent();
+    const sec = a?.heartbeatAgeSec;
+    if (typeof sec === 'number' && Number.isFinite(sec)) {
+      if (sec < 60) return `${Math.max(0, Math.round(sec))} 秒前`;
+      if (sec < 3600) return `${Math.round(sec / 60)} 分钟前`;
+      if (sec < 86400) return `${Math.round(sec / 3600)} 小时前`;
+      return `${Math.round(sec / 86400)} 天前`;
+    }
+    const t = a?.heartbeatAt ? Date.parse(a.heartbeatAt) : NaN;
+    if (!Number.isFinite(t)) return '—';
+    return new Date(t).toLocaleString('zh-CN', { hour12: false });
   });
 
   /** 该会话下、该数字生命的任务(Demo 抽屉「任务」页签) */
@@ -971,8 +1000,12 @@ export class MbChatPage implements OnDestroy {
   /**
    * 首屏不满一屏且还有更早消息时自动补齐(最多 3 轮)。
    * 否则用户看到内容没填满、却没有可滚动空间,永远触发不到「触顶加载」。
+   *
+   * 🔴 新建态不补齐(2026-11-08):新建会话本来就没有更早的消息,
+   *    而 `hasMoreHistory` 取自房间级 token —— 不挡就会自动翻出整个房间的旧历史。
    */
   private async fillViewportIfNeeded(): Promise<void> {
+    if (this.chat.state() === 'empty') return;
     for (let i = 0; i < 3; i++) {
       const el = this.scroller()?.nativeElement;
       if (!el || !this.chat.hasMoreHistory()) return;

+ 13 - 3
src/app/mobile/pages/contacts/contacts.ts

@@ -4,9 +4,10 @@ import { Router } from '@angular/router';
 import { ContactTab, ContactsService } from '../../../core/contacts.service';
 import { LiveService } from '../../../core/live.service';
 import { Agent, Person } from '../../../core/api/models';
+import { resolveAgentState } from '../../../core/agent-state';
 import { MAppBarComponent } from '../../components/m-app-bar/m-app-bar';
 import { MAvatarComponent } from '../../components/m-avatar/m-avatar';
-import { AGENT_TONE, BadgeTone, MBadgeComponent } from '../../components/m-badge/m-badge';
+import { BadgeTone, MBadgeComponent } from '../../components/m-badge/m-badge';
 import { MDrawerComponent } from '../../components/m-drawer/m-drawer';
 import { MEmptyComponent } from '../../components/m-empty/m-empty';
 import { MSegmentedComponent } from '../../components/m-segmented/m-segmented';
@@ -331,12 +332,21 @@ export class MbContactsPage {
     this.contacts.toggleColleague(p.id);
   }
 
+  /**
+   * 容器状态(2026-11-08 统一口径)。
+   *
+   * 🔴 原先用 `AGENT_TONE[status]` + `statusText ?? status`:
+   *   · 与 PC 通讯录(`agentPresence` 接入态)对同一容器给出相反结论
+   *   · 不看心跳,僵尸容器显示绿色「正在执行」
+   *   · 英文 status 会原样露出
+   * 现在与 PC 通讯录、会话列表、新建弹窗共用 `resolveAgentState`。
+   */
   protected toneOf(a: Agent): BadgeTone {
-    return AGENT_TONE[a.status] ?? 'neutral';
+    return resolveAgentState({ agent: a }).tone as BadgeTone;
   }
 
   protected statusText(a: Agent): string {
-    return a.statusText ?? a.status;
+    return resolveAgentState({ agent: a }).text;
   }
 
   /**

+ 46 - 13
src/app/mobile/pages/workbench/workbench.ts

@@ -2,6 +2,7 @@ import { ChatService } from '../../../core/chat.service';
 import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
 import { Router } from '@angular/router';
 import { Agent } from '../../../core/api/models';
+import { resolveAgentState } from '../../../core/agent-state';
 import { AgentRouteService } from '../../../core/agent-route.service';
 import { LiveService } from '../../../core/live.service';
 import { TasksService } from '../../../core/tasks.service';
@@ -68,8 +69,8 @@ import { LockScrollDirective } from '../../directives/lock-scroll.directive';
             </div>
             <div class="m-stat">
               <span class="m-stat-label">需要关注</span>
-              <b class="m-stat-value" [class.m-stat-value--warn]="live.blockedAgents().length > 0">
-                {{ live.blockedAgents().length }}
+              <b class="m-stat-value" [class.m-stat-value--warn]="blockedCount() > 0">
+                {{ blockedCount() }}
               </b>
             </div>
           </div>
@@ -335,8 +336,26 @@ export class MbWorkbenchPage {
   protected readonly statusFilter = signal<'all' | 'running' | 'idle' | 'error'>('all');
 
   /** 运行中的容器数 */
+  /**
+   * 「运行中」计数(2026-11-08 统一口径):
+   * 用合成态统计,包含「正在执行 / 正在回复 / 思考中」,并排除心跳过期的僵尸容器。
+   * 原先只数 `status === 'running'` —— 45 天没心跳的容器照样算进去。
+   */
   protected readonly runningCount = computed(
-    () => this.agents().filter((a) => a.status === 'running').length,
+    () =>
+      this.agents().filter((a) => {
+        const s = resolveAgentState({ agent: a }).state;
+        return s === 'running' || s === 'replying' || s === 'thinking';
+      }).length,
+  );
+
+  /** 「需要关注」:不可用的容器(离线 + 异常),与 PC 工作台同口径 */
+  protected readonly blockedCount = computed(
+    () =>
+      this.agents().filter((a) => {
+        const s = resolveAgentState({ agent: a }).state;
+        return s === 'offline' || s === 'error';
+      }).length,
   );
 
   /** 被阻塞的任务(对齐 PC 的「阻塞任务上浮」) */
@@ -348,17 +367,27 @@ export class MbWorkbenchPage {
     void this.router.navigateByUrl(`/mobile/tasks/${id}`);
   }
 
+  /**
+   * 状态筛选(2026-11-08 统一口径)。
+   *
+   * 🔴 原先直接用 `a.status`:
+   *   · 不看心跳 → 僵尸容器仍算进「执行中」
+   *   · 「异常」漏了 stopped,与 PC 工作台口径不一致
+   * 现在按 `resolveAgentState` 的合成态统计,PC / 移动 / 会话列表结论一致。
+   *   running 组 = 正在执行 + 正在回复 + 思考中(都是「在干活」)
+   *   idle 组    = 空闲
+   *   error 组   = 离线 + 异常(都不可用)
+   */
   protected readonly statusOptions = computed(() => {
-    const list = this.agents();
+    const list = this.agents().map((a) => resolveAgentState({ agent: a }).state);
+    const running = list.filter((s) => s === 'running' || s === 'replying' || s === 'thinking').length;
+    const idle = list.filter((s) => s === 'idle').length;
+    const bad = list.filter((s) => s === 'offline' || s === 'error').length;
     return [
       { value: 'all' as const, label: '全部', count: list.length },
-      { value: 'running' as const, label: '执行中', count: list.filter((a) => a.status === 'running').length },
-      { value: 'idle' as const, label: '空闲', count: list.filter((a) => a.status === 'idle').length },
-      {
-        value: 'error' as const,
-        label: '异常',
-        count: list.filter((a) => a.status === 'error' || a.status === 'offline').length,
-      },
+      { value: 'running' as const, label: '执行中', count: running },
+      { value: 'idle' as const, label: '空闲', count: idle },
+      { value: 'error' as const, label: '异常', count: bad },
     ];
   });
 
@@ -367,8 +396,12 @@ export class MbWorkbenchPage {
     const q = this.keyword().trim().toLowerCase();
     const f = this.statusFilter();
     return this.agents().filter((a) => {
-      if (f === 'error' && a.status !== 'error' && a.status !== 'offline') return false;
-      if (f !== 'all' && f !== 'error' && a.status !== f) return false;
+      const st = resolveAgentState({ agent: a }).state;
+      const isRunning = st === 'running' || st === 'replying' || st === 'thinking';
+      const isBad = st === 'offline' || st === 'error';
+      if (f === 'error' && !isBad) return false;
+      if (f === 'running' && !isRunning) return false;
+      if (f === 'idle' && st !== 'idle') return false;
       if (!q) return true;
       return (
         a.name.toLowerCase().includes(q) || (a.specialty ?? '').toLowerCase().includes(q)