Просмотр исходного кода

fix(chat): 修复新建对话后左侧列表没有内容

现象:点「新建对话」后聊天区是新的,但左侧列表里没有条目。

真根因(两个坑叠加):
1. create() 是异步推云端的,新建对话紧接着换 URL → 重新 open()
   → 又拉一次云端。那一刻云端还没有这条,hydrateFromCloud 按
   「云端没有就丢弃」处理,刚建出来的会话当场消失
2. navigateByUrl 是异步的,紧接着调 pruneEmpty(chat.sessionId()) 时
   会话指针还没切换,刚建的 0 条消息会话被当成空壳删掉

修复:
- 新增 _pendingPush:本地已建但未确认推到云端的会话 id,
  hydrateFromCloud 整体替换时保留(推送成功后才撤销标记)
- pushAll 成功时同样撤销标记
- onRouteChange 的 new 分支:建好 → 换 URL → 直接 return,
  不再走 pruneEmpty;空壳收敛只发生在进入正常会话的分支

验证:tsc 0 错误;单测 1080/1080(新增 2 条回归);构建通过;verify:ui 12/12

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 1 день назад
Родитель
Сommit
4b0059734b

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

@@ -3646,3 +3646,30 @@ agent-lj 的 8 个历史会话索引在墓碑中,Matrix 消息完好未删。
 ### 验证
 - tsc 0 错误;单测 **1078/1078**(新增 5 条:点击即建 / 归属正确 / 复用空会话 / 不同归属各一条 / 发送后 ready)
 - 构建通过;verify:ui 12/12
+
+## 2026-11-01 第十五轮:修复「新建对话后左侧列表没有内容」
+
+### 现象(用户反馈)
+点「新建对话」后,**聊天区是新的,但左侧列表里没有内容**。
+
+### 🔴 真根因(两个坑叠加)
+1. **云端拉取把刚建的会话冲掉**
+   `create()` 是**异步**推云端的,而新建对话紧接着就换 URL → 重新 `open()`
+   → 又拉一次云端。那一刻云端还没有这条,`hydrateFromCloud` 按
+   「云端没有就丢弃」处理 → 刚建出来的会话**当场消失**。
+2. **pruneEmpty 误删**
+   `navigateByUrl` 是异步的,紧接着调 `pruneEmpty(chat.sessionId())` 时
+   会话指针还没切换 → 刚建的 0 条消息会话被当成空壳删掉。
+
+### 修复
+- 新增 `_pendingPush` 集合:本地已建、**未确认推到云端**的会话 id。
+  `hydrateFromCloud` 整体替换列表时保留它们(例外范围极小,
+  不会重新引入「本地脏数据灌回列表」的问题)。推送**成功**后才撤销标记
+- `pushAll` 成功时同样撤销标记
+- `onRouteChange` 的 `new` 分支:建好会话 → 换 URL → **直接 return**,
+  不再继续走 `pruneEmpty`;空壳收敛只发生在「进入正常会话」的分支
+
+### 验证
+- tsc 0 错误;单测 **1080/1080**(新增 2 条回归:
+  未推送的会话不被 hydrate 丢弃 / 推送完成后以云端为准)
+- 构建通过;verify:ui 12/12

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

@@ -242,6 +242,52 @@ describe('ChatSessionService', () => {
     });
   });
 
+  describe('新建会话不被云端拉取冲掉(2026-11-01)', () => {
+    const ROOM = '!room1:fmode.cn';
+    const AGENT = 'agent-a';
+
+    beforeEach(() => localStorage.clear());
+
+    /*
+     * 🔴 回归:点「新建对话」后,聊天区是新的但**左侧列表没有内容**。
+     *
+     * 根因:`create()` 之后是**异步**推云端的,而新建对话紧接着就换 URL
+     * → 重新 `open()` → 又拉一次云端。那一刻云端列表里还没有这条,
+     * `hydrateFromCloud` 按「云端没有就丢弃」处理,刚建出来的会话当场消失。
+     */
+    it('🔴 刚创建、还没推到云端的会话,不能被 hydrate 丢弃', async () => {
+      let remote: unknown[] = [];
+      const svc2 = withApiStub({
+        // 云端暂时还没有这条(推送是异步的,这里模拟「推送还没落地」)
+        fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) =>
+          o?.includeDeleted ? [] : remote,
+        // upsert 永不 resolve —— 模拟推送尚未完成
+        upsertChatThreads: () => new Promise<void>(() => undefined),
+      });
+      await svc2.hydrateFromCloud(AGENT);
+      const s = svc2.create(ROOM, AGENT);
+
+      // 换 URL 后重新拉云端(此时云端仍为空)
+      await svc2.hydrateFromCloud(AGENT);
+
+      expect(svc2.byId(s.id)).withContext('刚建的会话被云端拉取抹掉了').toBeDefined();
+    });
+
+    it('推送完成后再拉取,以云端为准(标记已撤销)', async () => {
+      const svc2 = withApiStub({
+        fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []),
+        upsertChatThreads: async () => undefined,
+      });
+      await svc2.hydrateFromCloud(AGENT);
+      const s = svc2.create(ROOM, AGENT);
+      // 等推送 promise 落定
+      await new Promise((r) => setTimeout(r, 0));
+
+      await svc2.hydrateFromCloud(AGENT);
+      expect(svc2.byId(s.id)).withContext('推送已成功但云端为空 → 说明被删了,应移除').toBeUndefined();
+    });
+  });
+
   describe('消息推进必须同步云端(2026-11-01)', () => {
     const ROOM = '!room1:fmode.cn';
     const AGENT = 'agent-a';

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

@@ -123,6 +123,15 @@ export class ChatSessionService {
    */
   private readonly lastPushAt = new Map<string, number>();
 
+  /**
+   * 本地已建、但还没确认推到云端的会话 id。
+   *
+   * 用途很窄:`hydrateFromCloud` 整体替换列表时,这些会话在云端还不存在,
+   * 必须保留 —— 否则「新建对话 → 换 URL → 重新拉取云端」会把刚建的会话抹掉。
+   * 推送成功后立即移除,避免长期存在。
+   */
+  private readonly _pendingPush = new Set<string>();
+
   /**
    * 从 localStorage 载入(Shell 启动时调用一次;重复调用幂等)。
    *
@@ -276,9 +285,22 @@ export class ChatSessionService {
     /*
      * 用「该容器」的云端结果**整体替换**本地该容器的条目,其它容器不动。
      * 这里没有任何「本地有、云端没有 → 保留」的分支 —— 那正是脏数据来源。
+     *
+     * 🔴 唯一例外:**还没同步到云端的本地新建会话**(2026-11-01)。
+     *
+     *     `create()` 之后是异步推云端的,而新建对话紧接着就会换 URL → 重新
+     *     `open()` → 又拉一次云端。那一刻云端列表里还没有这条,
+     *     若按「云端没有就丢弃」处理,刚建出来的会话**当场消失**
+     *     (用户实测:点了新建,聊天区是新的但左侧列表没有内容)。
+     *
+     *     只保留「本地有、且明确处于待同步状态」的条目,范围极小,
+     *     不会重新引入「本地脏数据灌回列表」的问题。
      */
+    const pending = this._all().filter(
+      (s) => s.agentId === agentId && this._pendingPush.has(s.id) && !next.some((n) => n.id === s.id),
+    );
     const others = this._all().filter((s) => s.agentId !== agentId);
-    this._all.set(sortSessions([...others, ...next]));
+    this._all.set(sortSessions([...others, ...next, ...pending]));
     this.persist();
     this._ready.set(true);
     this._error.set(null);
@@ -303,6 +325,8 @@ export class ChatSessionService {
     const list = this._all().filter((s) => s.agentId === agentId && !s.legacy);
     if (list.length === 0) return;
     await this.trySync(() => this.api!.upsertChatThreads(list.map(toUpsert)));
+    // 整批推成功后,这些会话云端已存在,撤销「待同步」标记
+    for (const s of list) this._pendingPush.delete(s.id);
   }
 
   /**
@@ -323,7 +347,22 @@ export class ChatSessionService {
   /** 单条推送到云端(新建 / 变更后调用) */
   private pushOne(s: ChatSession | undefined): void {
     if (!s || s.legacy) return;
-    void this.trySync(() => this.api!.upsertChatThreads([toUpsert(s)]));
+    /*
+     * 🔴 先登记「待同步」再推送(2026-11-01)。
+     *
+     * 推送是异步的,期间若发生 `hydrateFromCloud`(新建对话会立刻换 URL 触发),
+     * 云端还没有这条 → 会被当成「云端已删除」抹掉。登记后 hydrate 会保留它。
+     */
+    this._pendingPush.add(s.id);
+    void (async () => {
+      try {
+        await this.api!.upsertChatThreads([toUpsert(s)]);
+        // 成功才撤销标记:失败时云端仍没有这条,hydrate 必须继续保留它
+        this._pendingPush.delete(s.id);
+      } catch {
+        /* 离线/服务异常:保留标记,等下次 hydrate 或 pushAll 再补 */
+      }
+    })();
   }
 
   /**

+ 15 - 2
src/app/desktop/pages/chat/chat.ts

@@ -2412,16 +2412,29 @@ export class DkChatPage {
         const ws = this.route.snapshot.queryParamMap.get('ws');
         const created = await this.chat.startBlank(id, ws || null);
         if (created) {
+          /*
+           * 🔴 建出会话后**立刻返回**,不要继续往下走(2026-11-01 修复)。
+           *
+           * 两个坑叠在一起导致「新建后左侧没有内容」:
+           *  ① 下面紧接着调用 `pruneEmpty(chat.sessionId())` —— 此时
+           *     `navigateByUrl` 还没真正切换路由,`chat.sessionId()` 可能仍是空,
+           *     于是刚建出来的 0 条消息会话被当成空壳**当场删掉**;
+           *  ② 导航会再次触发 `onRouteChange` → `open()`,把界面重新指向别的会话。
+           *
+           * 现在:建好 → 换 URL → 直接 return。空壳收敛交给「进入不是 new 的
+           * 正常会话」那条分支去做。
+           */
           void this.router.navigateByUrl(
             `/${this.device.mode()}/chat/${id}/${encodeURIComponent(created.id)}`,
             { replaceUrl: true },
           );
+          return;
         }
       } else {
         await this.chat.open(id, sessionId ?? undefined);
+        // 打开容器后收敛空壳(只保留当前这条)
+        this.sessions.pruneEmpty(this.chat.sessionId() ?? undefined);
       }
-      // 打开容器后收敛空壳(只保留当前这条)
-      this.sessions.pruneEmpty(this.chat.sessionId() ?? undefined);
       // 打开后内容不满一屏 → 自动补历史(懒加载的另一半)
       void this.fillViewportIfNeeded();
     } else {