Jelajahi Sumber

fix: 修复 NG0600 —— 模板渲染期写 signal 导致消息区不重绘

## 现象(用户实测)
新建会话后立刻发消息:文字卡在输入框 3.8 秒才消失,消息区长时间空白。
反复几轮都以为是「信号没收到」,实际是**渲染被异常中断**。

## 根因
```
NG0600: Writing to signals is not allowed while Angular renders the template
```
`taskLine()` / `sessionRunningOf()` 都被**模板调用**(处于渲染阶段),
但内部调了 `clearSessionLocalStart()` 去写 signal → Angular 抛异常 → 整个渲染中断。

## 修复
- 不在渲染期清 signal:改由 `sessionLocalStartAt()` 的 60 秒 TTL 自然失效
- 顺带去掉了订阅回调里的全局 `seq` 校验(新建会话后 seq 会变,
  导致订阅全部失效、收不到 ✅ reaction)

## 实证(scripts/repro-new-session-send.mjs)
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 输入框清空 | 3814ms | 35ms |
| 消息出现 | 22ms | 35ms |
| 页面错误 | NG0600 × N | 无 |

## 教训
单元测试测的是纯函数,**测不到「模板渲染期写 signal」这类框架级错误**;
以后这类问题必须先在真实浏览器里看控制台,不要只读代码推断。

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 14 jam lalu
induk
melakukan
3cd76848d1

+ 101 - 0
scripts/repro-new-session-send.mjs

@@ -0,0 +1,101 @@
+#!/usr/bin/env node
+/**
+ * 复现「新建会话后立刻发消息,消息不显示」(2026-11-11)
+ *
+ * 不读代码推断,在真实浏览器里按用户操作顺序跑:
+ *   1. 打开聊天页 → 点「新建对话」→ 弹窗里选容器
+ *   2. **立刻**在输入框里输入并回车
+ *   3. 每 200ms 采样消息区,看消息何时出现、输入框何时清空
+ *
+ * 用法:node scripts/repro-new-session-send.mjs
+ */
+import { chromium } from 'playwright';
+import { readFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const login = JSON.parse(readFileSync(resolve(here, '../.claude/dev-login.json'), 'utf8'));
+const BASE = 'http://localhost:4200';
+const AGENT = process.argv[2] ?? 'lj-test';
+
+const b = await chromium.launch({ channel: 'chrome' });
+const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
+const page = await ctx.newPage();
+await page.addInitScript(
+  ([token, me]) => {
+    localStorage.setItem('fmode_session_token', token);
+    localStorage.setItem('fmode_me', me);
+  },
+  [login.token, JSON.stringify(login.me)],
+);
+
+const errors = [];
+page.on('pageerror', (e) => errors.push(String(e).slice(0, 200)));
+page.on('console', (m) => {
+  if (m.type() === 'error') errors.push('console: ' + m.text().slice(0, 200));
+});
+
+console.log('=== 1. 打开聊天页 ===');
+await page.goto(`${BASE}/desktop/chat`, { waitUntil: 'domcontentloaded', timeout: 60_000 });
+await page.waitForTimeout(6000);
+
+console.log('=== 2. 点「新建对话」 ===');
+await page.locator('text=新建对话').first().click();
+await page.waitForTimeout(2000);
+
+console.log(`=== 3. 选容器 ${AGENT} ===`);
+const opt = page.locator(`text=${AGENT}`).last();
+await opt.click();
+await page.waitForTimeout(4000);
+console.log('    URL:', page.url());
+
+/* 确认输入框就绪 */
+const ta = page.locator('textarea').first();
+await ta.waitFor({ state: 'visible', timeout: 20_000 });
+
+console.log('\n=== 4. 立刻输入并回车(模拟用户"新建完就发")===');
+const TEXT = '测试' + Date.now().toString().slice(-4);
+await ta.fill(TEXT);
+const t0 = Date.now();
+await ta.press('Enter');
+
+console.log('\n=== 5. 采样(每 200ms,最多 20 秒)===');
+let firstSeenAt = null;
+let inputClearedAt = null;
+for (let i = 0; i < 100; i++) {
+  const snap = await page.evaluate(() => {
+    const ta = document.querySelector('textarea');
+    return {
+      inputVal: ta ? ta.value : '(无)',
+      bodyText: (document.body.innerText || '').slice(0, 6000),
+    };
+  });
+  const ms = Date.now() - t0;
+  if (snap.inputVal === '' && inputClearedAt === null) {
+    inputClearedAt = ms;
+    console.log(`   ${ms}ms 输入框已清空`);
+  }
+  /* 消息区出现该文本(排除输入框里的那份) */
+  if (snap.bodyText.includes(TEXT) && firstSeenAt === null) {
+    firstSeenAt = ms;
+    console.log(`   ${ms}ms ✅ 消息出现在页面上`);
+  }
+  if (i % 10 === 0 && firstSeenAt === null) {
+    console.log(`   ${ms}ms 还没出现 | 输入框="${snap.inputVal.slice(0, 20)}"`);
+  }
+  if (firstSeenAt !== null && inputClearedAt !== null) break;
+  await page.waitForTimeout(200);
+}
+
+console.log('\n=== 结果 ===');
+console.log(`文本: "${TEXT}"`);
+console.log(`输入框清空: ${inputClearedAt === null ? '❌ 从未清空' : inputClearedAt + 'ms'}`);
+console.log(`消息出现:   ${firstSeenAt === null ? '❌ 20 秒内从未出现' : firstSeenAt + 'ms'}`);
+if (firstSeenAt !== null && firstSeenAt > 1500) {
+  console.log('⚠️ 超过 1.5 秒 —— 用户能感知到卡顿');
+}
+console.log('页面错误:', errors.length ? errors.slice(0, 5) : '无');
+
+await page.screenshot({ path: 'test-results/repro-new-session.png' });
+await b.close();

+ 8 - 2
src/app/components/domain/session-tree/session-tree.ts

@@ -2531,8 +2531,14 @@ export class SessionTreeComponent implements OnDestroy {
       this.myMxIdOf(),
     );
     if (containerRunning === false) {
-      /* 容器明确空闲 → 顺便清掉可能残留的本地标记 */
-      this.pulseSvc.clearSessionLocalStart(s.id);
+      /*
+       * 🔴 不在这里清本地标记(2026-11-11,NG0600)。
+       *
+       * 本方法由模板调用,处于渲染阶段 —— 写 signal 会抛
+       * `NG0600: Writing to signals is not allowed while Angular renders`,
+       * 直接中断渲染(这是「新建会话发消息卡住」的元凶之一)。
+       * 标记自带 60 秒 TTL,不需要主动清除。
+       */
       return false;
     }
     if (containerRunning === true) return true;

+ 109 - 80
src/app/core/chat.service.ts

@@ -790,9 +790,22 @@ export class ChatService {
      * 只把 `_messages` 置空是不够的:订阅还挂在房间上,
      * 后续任何回填 / 重放事件都会把**上一个会话的消息塞回新会话里**。
      *
-     * 解除后由「首次发消息」那一步重新建立订阅(`open()` 在真正打开会话时再订)。
+     * 解除后必须**在这里重新订阅**(2026-11-11 修正)。
+     *
+     * 原注释说「由首次发消息那一步重新建立订阅」,但那条路径**从未实现** ——
+     * 新建的会话因此收不到任何实时事件(包括 ✅ reaction),
+     * 表现为「卡片已收尾,右上角和左侧还挂着处理中」。
      */
     this.teardownSubscriptions();
+    /*
+     * 新建会话仍属**同一个房间**(一个容器一个 DM 房间),
+     * 所以直接用当前 roomId 重新订阅即可。版本号取自增后的 `this.seq`:
+     * 之后任何 `open()` 都会再自增,本订阅的回调自然作废。
+     */
+    if (roomId) {
+      const myUserId = this.provision?.userMxId ?? '';
+      this.bindRoomSubscriptions(roomId, this.seq, myUserId);
+    }
 
     /*
      * 🔴 清空「已认领的容器线程根」(2026-11-08)。
@@ -817,6 +830,99 @@ export class ChatService {
     return this.sessions.byId(s.id) ?? s;
   }
 
+  /**
+   * 订阅当前房间的实时事件(消息 / typing / ✅ reaction)。
+   *
+   * 🔴 抽成独立方法(2026-11-11 用户实测「新建会话状态不同步」)。
+   *
+   * 原来这三段订阅**只写在 `openReal()` 里**。而 `startBlank()`(新建对话)
+   * 会先 `teardownSubscriptions()` 解除全部订阅,然后走自己的路 ——
+   * **从不重新订阅**。结果:
+   *
+   *   · 新建的会话收不到 ✅ reaction → `turnSettledLocally` 永远是 false
+   *   · 也收不到实时消息 → 只能靠 15 秒轮询补齐
+   *
+   * 表现就是「卡片已按静默窗口收尾,右上角和左侧还挂着处理中」。
+   *
+   * @param rid      房间 id
+   * @param my       `open()` 的版本号;回调里据此丢弃过期回包
+   * @param myUserId 自己的 mxid(用于排除自己发出的 typing)
+   */
+  private bindRoomSubscriptions(rid: string, my: number, myUserId: string): void {
+    /*
+     * 🔴 回调里**不校验 `seq`**(2026-11-11 用户实测「新建会话状态不同步」)。
+     *
+     * 原来三个回调都以 `if (my !== this.seq) return;` 开头,本意是丢弃过期回包。
+     * 但 `seq` 是**全局版本号**,任何一次 `open()` / `close()` 都会自增 ——
+     * 新建会话后紧接着 URL 跳转触发 `onRouteChange → open()`,
+     * `seq` 一变,**新建时建立的订阅就全部失效**:
+     *
+     *   · 收不到 ✅ reaction → `turnSettledLocally` 恒 false(右侧一直「处理中」)
+     *   · 收不到实时消息 → 只能靠 15 秒轮询
+     *
+     * 而现在这个订阅**只绑定在一个确定的房间**上(`rid` 由闭包捕获),
+     * 房间不会因为切会话而变(一个容器一个 DM 房间)。
+     * 真正要防的是「房间变了还往里写」—— 那由 `teardownSubscriptions()`
+     * 在每次切换时解绑来处理,不需要再拿全局版本号挡。
+     */
+    this.unbindMessage = this.mx.onMessage(rid, (m) => {
+      this.appendIncoming(m, myUserId);
+    });
+
+    this.unbindTyping = this.mx.onTyping(rid, (typing, who) => {
+      /*
+       * 🔴 必须排除自己:`m.typing` 会广播**房间内所有成员**的输入状态(含自己)。
+       * 不过滤 → 用户自己打字时界面显示「对方正在输入」,方向完全相反。
+       */
+      if (myUserId && who === myUserId) return;
+      this._peerTyping.set(typing);
+      /*
+       * 容器从「正在输入」转成「没在输入」= 本轮结束的辅助信号。
+       */
+      if (!typing) {
+        this._turnEndedAt.set(Date.now());
+        this._turnEndedAfter.set(this.lastAgentMessageId());
+      }
+    });
+
+    /*
+     * 容器的 ✅ / ❌ reaction = **本轮真的干完了**。
+     *
+     * 这是比 typing 更硬的信号:实测容器每轮处理结束都会把 👀 换成 ✅,
+     * 由 Hermes 内建行为发出,不依赖插件改造。
+     */
+    this.unbindTurnReaction =
+      this.mx.onTurnReaction?.(rid, (_ok, sender) => {
+        /* 同 `bindRoomSubscriptions` 的说明:这里不校验全局 `seq` */
+        /*
+         * 必须校验 sender:群聊里 @ 了多个容器时,每个容器各打一个 ✅、
+         * 打在同一个 event_id 上。不看 sender 会把 A 的收工当成 B 也收工。
+         * 单聊只有本容器一个 agent,这条恒为真、不影响原有行为。
+         */
+        const agentMxId = this._agentMxId();
+        if (agentMxId && sender && sender !== agentMxId) return;
+        /*
+         * 稍等一拍再取「末条 agent 消息 id」:reaction 与它标注的消息是
+         * 两个独立事件,到达顺序不保证。让出一拍能避免取到上一条。
+         */
+        setTimeout(() => {
+          this._turnEndedAt.set(Date.now());
+          this._turnEndedAfter.set(this.lastAgentMessageId());
+          /* 立刻刷新容器状态,不等 15 秒轮询 */
+          void this.live.load(true);
+        }, 300);
+      }) ?? null;
+  }
+
+  /**
+   * 当前会话所属容器的 Matrix mxid(用于 ✅ reaction 的 sender 校验)。
+   *
+   * 单聊恒为一个 agent;群聊时需要按块区分,这里取当前容器即可。
+   */
+  private _agentMxId(): string {
+    return this.provision?.agentMxId ?? '';
+  }
+
   /**
    * 真实态开启流程。
    *
@@ -874,86 +980,9 @@ export class ChatService {
      * 🔴 「历史对话」承接机制已于 2026-10-29 下线(用户裁决:这个条目不要、不能改名)。
      *    无 thread 的旧消息不再单独成会话,也不再在列表中露出 —— 见 `dropLegacySessions`。
      */
-    // ④ 订阅实时事件(先订阅再读 timeline,避免中间漏事件)
     const myUserId = prov.userMxId ?? '';
-    this.unbindMessage = this.mx.onMessage(rid, (m) => {
-      if (my !== this.seq) return;
-      this.appendIncoming(m, myUserId);
-    });
-    this.unbindTyping = this.mx.onTyping(rid, (typing, who) => {
-      if (my !== this.seq) return;
-      /*
-       * 🔴 必须排除自己:`m.typing` 会广播**房间内所有成员**的输入状态(含自己)。
-       * 不过滤 → 用户自己打字时界面显示「对方正在输入」,方向完全相反。
-       */
-      if (myUserId && who === myUserId) return;
-      this._peerTyping.set(typing);
-      /*
-       * 容器从「正在输入」转成「没在输入」= **本轮结束信号**(2026-11-11)。
-       *
-       * 容器侧插件在 `post_llm_call` 发 `m.typing=false`,这是它在 Matrix 里
-       * 唯一能可靠发出的「我干完了」。此前容器整轮一条 typing 都不发,
-       * App 只能等 25/60 秒静默窗口,20 秒的活显示到 50 多秒还在「输出中」。
-       *
-       * 🔴 记的是「此刻最后一条 agent 消息的 id」,不是时刻 —— 见
-       *    `_turnEndedAfter` 的注释:跨时钟比较会被服务端/本地时差打死。
-       */
-      if (!typing) {
-        this._turnEndedAt.set(Date.now());
-        this._turnEndedAfter.set(this.lastAgentMessageId());
-      }
-    });
-    /*
-     * 容器的 ✅ / ❌ reaction = **本轮真的干完了**(2026-11-11)。
-     *
-     * 这是比 typing 更硬的信号:实测容器每轮处理结束都会把 👀 换成 ✅,
-     * 由 Hermes 内建行为发出,不依赖插件改造。
-     *
-     * 用户痛点:「先显示已完成,过很久正文才出来」——容器分条发送时,
-     * 第一条消息到达时静默窗口倒计时就开始了,而真结果还在后面。
-     * 用 reaction 认领后,只有容器真正收尾才判结束。
-     */
-    this.unbindTurnReaction = this.mx.onTurnReaction?.(rid, (_ok, sender) => {
-      if (my !== this.seq) return;
-      /*
-       * 🔴 必须校验 sender(2026-11-11,容器侧代码取证指出)。
-       *
-       * 群聊里 @ 了多个容器时,**每个容器各打一个 ✅、打在同一个 event_id 上**。
-       * 若不看 sender,A 容器收工会被当成 B 容器也收工 —— 界面提前收尾、
-       * 用户以为 B 挂了。
-       *
-       * 单聊只有本容器一个 agent,这条校验恒为真、不影响原有行为。
-       */
-      if (agentMxId && sender && sender !== agentMxId) return;
-      /*
-       * 🔴 稍等一拍再取「末条 agent 消息 id」(2026-11-11)。
-       *
-       * reaction 与它标注的消息是两个独立事件,到达顺序**不保证**:
-       * 实测 +15.3s 出正文、+15.4s 出 ✅,但也有网络乱序时 ✅ 先到的情形。
-       * 若在那一刻就取 id,可能取到上一条,导致本轮永远等不到结束信号。
-       *
-       * 让出一拍(`setTimeout 0` 不够,消息要过一遍 signal 更新)再取,
-       * 代价是收尾晚几十毫秒,用户完全无感。
-       */
-      const stamp = () => {
-        if (my !== this.seq) return;
-        this._turnEndedAt.set(Date.now());
-        this._turnEndedAfter.set(this.lastAgentMessageId());
-        /*
-         * 🔴 立刻拉一次容器状态,不等 15 秒轮询(2026-11-11 用户反馈)。
-         *
-         * 用户实测:「容器已经回复完了,右上角还显示『正在处理』」。
-         * 根因:`LiveService` 每 15 秒轮询一次容器状态,
-         * 而 ✅ 到达时容器**刚写完** idle —— 最坏要等 15 秒才同步到界面。
-         *
-         * ✅ 是「这一轮真的结束了」的确定信号,此刻刷新一次最准。
-         * `load(true)` 是静默刷新(不改 loading 态、不变引用则不动 UI),
-         * 不会造成闪烁;一轮只多一次请求,开销可忽略。
-         */
-        void this.live.load(true);
-      };
-      setTimeout(stamp, 300);
-    }) ?? null;
+    /* ④ 订阅实时事件(先订阅再读 timeline,避免中间漏事件) */
+    this.bindRoomSubscriptions(rid, my, myUserId);
 
     /*
      * ⑤ 读当前 timeline —— 先确定会话,再按会话(thread)过滤,**只取尾部一屏**。

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

@@ -311,7 +311,19 @@ export class TaskPulseService {
       ? sessionRunning(agent.currentSessions, sessionId, roomId, myUserId, now)
       : undefined;
     if (sessionAware && mine === false) {
-      if (sessionId) this.clearSessionLocalStart(sessionId);
+      /*
+       * 🔴 这里**不能**清本地标记(2026-11-11,NG0600)。
+       *
+       * 本方法由模板里的 `taskLine()` 调用,正处在 Angular 的渲染阶段 ——
+       * 在其中写任何 signal 都会抛 `NG0600: Writing to signals is not allowed
+       * while Angular renders the template`,**中断整个渲染**。
+       *
+       * 这正是「新建会话发消息卡住」的元凶:控制台一路报 NG0600,
+       * 消息区因此一直不重绘。
+       *
+       * 现在改成「不清理,靠 TTL 自然失效」:
+       * `sessionLocalStartAt()` 自带 60 秒过期,不需要主动删。
+       */
       return { text: '在线', tone: 'success' };
     }
 
@@ -366,7 +378,7 @@ export class TaskPulseService {
       if (quietMs < REPLY_QUIET_MS) {
         return { text: '正在回复…', tone: 'info', active: true };
       }
-      if (sessionId) this.clearSessionLocalStart(sessionId);
+      /* 同上:不在渲染期清 signal,靠 TTL 自然失效 */
       return { text: '在线', tone: 'success' };
     }
     /*

+ 32 - 7
src/app/desktop/pages/chat/chat.ts

@@ -3398,14 +3398,39 @@ export class DkChatPage implements OnDestroy {
    */
   private syncClock(): void {
     /*
-     * 2026-11-11:静默等待期也要走表。
-     * `live` 在 25 秒静默后就转 false,但用户仍在等(180 秒才超时)——
-     * 只按 `live` 起表的话,等待文案里的秒数会冻住。
+     * 🔴 **不要用 `aiRows()` 判断**(2026-11-11 用户实测「33s 卡住不动」)。
+     *
+     * 这里曾经写成 `this.aiRows().some(r => r.turn.live)`,而 `aiRows()`
+     * 自己的计算里就依赖 `clockTick()` —— 形成**死锁**:
+     *
+     *   · `clockTick` 要靠计时器推进
+     *   · 计时器只在「有 live 回合」时启动
+     *   · 判断「有没有 live 回合」又要读 `aiRows()`(它依赖 `clockTick`)
+     *
+     * 某一次求值时 `live` 恰好为 false → 计时器停止 → `clockTick` 不再变 →
+     * `aiRows` 永不重算 → `live` 永远停在那个旧值。
+     * 表现就是卡片「33s 输出中」冻住,右上角和左侧也一起卡住。
+     *
+     * 现在直接用**原始消息数据**判断:有消息、且最后一条距现在没超静默窗口,
+     * 就算「可能还在动」。这个判据不依赖任何派生计算,不会成环。
      */
-    const needsTick = (): boolean =>
-      this.aiRows().some(
-        (r) => r.kind === 'turn' && (r.turn.live || r.turn.blocks.some((b) => b.waiting)),
-      );
+    const needsTick = (): boolean => {
+      const msgs = this.chat.messages();
+      if (msgs.length === 0) return false;
+      const last = msgs[msgs.length - 1];
+      const lastAt = Date.parse(last.ts) || 0;
+      if (!lastAt) return false;
+      /*
+       * 三条「还要继续走表」的理由,任一成立即可:
+       *   ① 用户刚发过消息、容器还没回(`waiting`)
+       *   ② 最后一条是容器发的、且在静默窗口内(可能还在输出)
+       *   ③ 本会话容器上报为 running
+       */
+      const recent = Date.now() - lastAt < QUIET_MS;
+      if (last.sender === 'me') return true;
+      if (recent) return true;
+      return this.chat.sessionSignals().sessionRunning === true;
+    };
     const live = needsTick();
     if (live && this.clockHandle === null) {
       this.clockHandle = setInterval(() => {