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

feat(chat): 新建对话全局只留一条,持久化唯一前提是发过消息

用户裁决:
- 新建对话可在工作区或日常对话发起
- 会话列表里只保留一条「新建对话」,别处新建时之前的直接清掉
- 不是定时清理,新建/切换那一刻就收敛
- 持久化的唯一前提是在该会话里发过消息

修复:
1. moveTo 对未落库会话不再发请求(原先新建到工作区就直接 upsert 落库)
2. pruneEmpty 改为「全局只留一条」:未落库删本地不发云端请求,
   已落库删本地 + 云端软删;去掉 60 秒保护期
3. startBlank 取消同归属复用,每次新建并立刻清掉其它空壳
4. ChatService.open 进入时先收敛,切走时清掉上一条空会话

验证:tsc 0 错误;单测 1085/1085;构建通过;verify:ui 12/12

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

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

@@ -3736,3 +3736,34 @@ agent-lj 的 8 个历史会话索引在墓碑中,Matrix 消息完好未删。
 - 无顶栏后 `.messages--bare` 自然撑满整个主列,卡片居中更干净
 
 验证:tsc 0 错误;单测 1082/1082;构建通过;verify:ui 12/12
+
+## 2026-11-01 第十八轮:「新建对话」全局只留一条 + 持久化唯一前提
+
+### 用户裁决
+1. 新建对话可以发生在多个地方(工作区文件夹 / 日常对话)
+2. **会话列表里只保留一条「新建对话」** —— 在别处新建时,之前的直接清掉
+3. **不是 60 秒定时清理**,而是新建/切换的那一刻就收敛
+4. **持久化的唯一前提**:在这条会话里发过消息
+
+### 修复
+**① `moveTo()` 不再对未落库会话发请求**
+新建到工作区时会调 `moveTo` 写归属,若直接 upsert 就等于「点一次新建就在云端留一条」。
+现在:`_pendingPush` 中的会话只改本地,归属随首次发消息的 `commit()` 一起落库。
+
+**② `pruneEmpty()` 改为「全局只留一条」**
+去掉 60 秒保护期逻辑,改为:
+- 未落库(`_pendingPush`)→ 删本地,**不发云端请求**(云端本来就没有)
+- 已落库(发过消息后被清空)→ 删本地 + 云端软删
+
+**③ `startBlank()` 取消「同归属复用」**
+每次新建都创建新会话,并立刻 `pruneEmpty(新会话)` 清掉之前所有空壳。
+不再按「同容器 + 同归属」复用 —— 那会让「工作区一条、日常一条」并存。
+
+**④ `ChatService.open()` 进入时先收敛**
+切走会话时清掉上一条空会话(保留本次要打开的那条)。
+
+### 验证
+- tsc 0 错误;单测 **1085/1085**
+  (新增:再次新建清掉旧的 / 跨归属也只留一条 / 已发消息的会话不被清 /
+    未落库会话清理不发云端请求)
+- 构建通过;verify:ui 12/12

+ 42 - 3
src/app/core/chat-session.service.spec.ts

@@ -296,7 +296,7 @@ describe('ChatSessionService', () => {
      * 真实时序是「建会话 → 换 URL → 第二轮 onRouteChange → open() → pruneEmpty」,
      * 那一轮 keepId 可能还没指向它,于是刚建好的 0 条会话被当场清掉。
      */
-    it('🔴 刚创建的会话不参与 pruneEmpty 清理', async () => {
+    it('🔴 正在看的那条空会话不会被清理(keepId 保护)', async () => {
       const svc2 = withApiStub({
         fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []),
         upsertChatThreads: async () => undefined,
@@ -305,10 +305,49 @@ describe('ChatSessionService', () => {
       await svc2.hydrateFromCloud(AGENT);
       const s = svc2.create(ROOM, AGENT);
 
-      // 模拟换 URL 后那一轮清理:keepId 尚未指向新会话
+      // 用户正看着它 → 必须保留
+      svc2.pruneEmpty(s.id);
+
+      expect(svc2.byId(s.id)).withContext('当前会话被误删').toBeDefined();
+    });
+
+    /*
+     * 🔴 用户反馈:多次新建对话后,前几次的都持久化/残留了。
+     *
+     * 规则是「**发过消息**才持久化」。从未落库的空会话(点了新建但没发消息)
+     * 应当在切走时直接消失,不该留在列表里。
+     */
+    it('🔴 连点新建留下、且没发过消息的空会话,切走时全部清掉', async () => {
+      const svc2 = withApiStub({
+        fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []),
+        upsertChatThreads: async () => undefined,
+        removeChatThread: async () => undefined,
+      });
+      await svc2.hydrateFromCloud(AGENT);
+      const a = svc2.create(ROOM, AGENT);
+      const b = svc2.create(ROOM, AGENT);
+      const c = svc2.create(ROOM, AGENT);
+
+      // 用户切走到别的会话(c 被打开 → a、b 应被清掉)
+      svc2.pruneEmpty(c.id);
+
+      expect(svc2.byId(a.id)).withContext('未落库的空会话应被清掉').toBeUndefined();
+      expect(svc2.byId(b.id)).toBeUndefined();
+      expect(svc2.byId(c.id)).withContext('当前这条要保留').toBeDefined();
+    });
+
+    it('🔴 未落库的空会话被清理时,不向云端发删除请求', async () => {
+      const remove = jasmine.createSpy('remove').and.returnValue(Promise.resolve());
+      const svc2 = withApiStub({
+        fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []),
+        upsertChatThreads: async () => undefined,
+        removeChatThread: remove,
+      });
+      await svc2.hydrateFromCloud(AGENT);
+      svc2.create(ROOM, AGENT);
       svc2.pruneEmpty(undefined);
 
-      expect(svc2.byId(s.id)).withContext('刚建的会话被当成空壳删掉了').toBeDefined();
+      expect(remove).withContext('云端本来就没有这条,不该发删除请求').not.toHaveBeenCalled();
     });
 
     it('超过保护期的空壳仍会被清理(不会长期堆积)', async () => {

+ 30 - 32
src/app/core/chat-session.service.ts

@@ -544,15 +544,20 @@ export class ChatSessionService {
     const cur = this.byId(id);
     if (!cur) return;
     /*
-     * 🔴 用 **upsert 而不是 patch**(2026-11-01 修复)。
+     * 🔴 **未落库的会话只改本地,不发请求**(2026-11-01 用户裁决)。
      *
-     * `patch` 要求云端已有这条记录;而「新建对话 → 立刻移动工作区」时
-     * 云端记录可能还没落地(create 是异步推的),patch 返回 404 被当成成功,
-     * 归属**压根没写进云端**。下次拉取时云端 workspaceId 为空 →
-     * 会话掉回「日常对话」(用户实测:工作区里新建的对话跑到日常去了)。
+     * 持久化的唯一前提是「该会话里发过消息」。新建对话刚建出来(还没发消息)时
+     * 就被 `startBlank()` 调 `moveTo` 挂到工作区下 —— 若这里直接 upsert,
+     * 等于**点一次新建就在云端留一条记录**,连点几次就堆一堆空会话
+     * (用户实测:多次新建后,前几次的都持久化了)。
      *
-     * upsert 是幂等的:有就更新、没有就创建,两种时序都正确。
+     * 归属先记在本地,等首次发消息触发 `commit()` 时随会话一起落库。
+     *
+     * 已落库的会话走 upsert(不是 patch):patch 要求云端已有记录,
+     * 「新建 → 立刻移动工作区」时可能还没落地,patch 会 404 被静默吞掉,
+     * 归属压根没写进云端 → 下次拉取掉回「日常对话」。
      */
+    if (this._pendingPush.has(id)) return;
     void this.trySync(() => this.api!.upsertChatThreads([toUpsert(cur)]));
   }
 
@@ -595,49 +600,42 @@ export class ChatSessionService {
   }
 
   /**
-   * 清理**空壳会话**(`messageCount === 0`),保留 `keepId`。
+   * 收敛「新建对话」空壳:**整个列表最多只留一条**。
    *
-   * 2026-11-01 重新引入:新建对话改为「点击即建」(左侧要立刻显示并选中),
-   * 代价是用户点了不发消息就切走会留下空壳。这里在**打开容器**和
-   * **切换会话**时收敛,只保留当前正在看的那条。
+   * 2026-11-01 用户裁决:
+   *   新建对话是「点击即建」(左侧立刻可见并选中),但持久化的唯一前提是
+   *   **在该会话里发过消息**。所以:
+   *   · 「新建对话」在列表里只能有一条 —— 不论在哪个工作区、哪个容器新建,
+   *     一旦新建,**之前所有没发过消息的空会话一律清掉**;
+   *   · 不需要等 60 秒,也不需要定时器 —— 新建/切换的那一刻就收敛。
    *
-   * 同时清掉云端对应记录(软删),否则下次 `hydrateFromCloud` 会把它们拉回来。
+   * 实现上区分两类空壳:
+   *   · **从未落库**(`_pendingPush`)→ 删本地即可,不发云端请求(云端本来就没有);
+   *   · **已落库**(发过消息后又被清空)→ 删本地 + 云端软删,否则下次拉取会回来。
    *
-   * @param keepId 当前正在看的会话,必须保留(它也是 0 条,但用户正要发消息)
+   * @param keepId 要保留的那条(通常是刚新建/正在看的会话)
    */
   pruneEmpty(keepId?: string): void {
-    const now = Date.now();
     const victims = this._all().filter(
-      (s) =>
-        !s.legacy &&
-        s.id !== keepId &&
-        (s.messageCount ?? 0) === 0 &&
-        /*
-         * 🔴 刚创建的空会话**不参与清理**(2026-11-01)。
-         *
-         * 新建对话是「点击即建」,建出来就是一个 0 条消息的会话;
-         * 紧接着换 URL 会触发第二轮 `onRouteChange` → `open()` →
-         * 又一次 `pruneEmpty`。那一轮的 `keepId` 若因为路由时序还没指向它,
-         * 这条刚建好的会话就被**当场当作空壳删掉**
-         * (用户实测:聊天区是新的,左侧列表里却没有)。
-         *
-         * 给新会话 60 秒保护期:足够熬过 URL 切换与首次同步,
-         * 又不会让空壳长期堆积(超过 60 秒还没发消息的仍会被清掉)。
-         */
-        now - s.createdAt > 60_000,
+      (s) => !s.legacy && s.id !== keepId && (s.messageCount ?? 0) === 0,
     );
     if (victims.length === 0) return;
+
     const dead = new Set(victims.map((s) => s.id));
     this._all.update((list) => list.filter((x) => !dead.has(x.id)));
     this.persist();
-    // 云端同步删除,避免下次拉取又出现
+
     for (const v of victims) {
+      if (this._pendingPush.has(v.id)) {
+        // 从没写过云端 → 撤销标记即可,不发请求
+        this._pendingPush.delete(v.id);
+        continue;
+      }
       void this.trySync(() =>
         this.api!.removeChatThread({ threadRoot: v.id, roomId: v.roomId, agentId: v.agentId }),
       );
     }
   }
-
   /**
    * 直接设置消息计数与最后活动时间。
    *

+ 29 - 5
src/app/core/chat.service.spec.ts

@@ -205,7 +205,13 @@ describe('ChatService', () => {
       expect(svc.sessions.byId(created!.id)?.workspaceId).toBe('ws-test');
     });
 
-    it('🔴 同一归属下已有空会话时复用,不重复创建', async () => {
+    /*
+     * 🔴 用户裁决:整个列表**最多保留一条**「新建对话」。
+     *
+     * 每次新建都把之前所有没发过消息的空会话清掉 —— 不按归属复用,
+     * 也不依赖定时清理,否则会出现「工作区一条、日常一条」并存。
+     */
+    it('🔴 再次新建时,之前的空会话被清掉,只留一条', async () => {
       realMode();
       mxStub.timelineToMessages.and.returnValue([]);
       apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
@@ -214,11 +220,12 @@ describe('ChatService', () => {
       const a = await svc.startBlank('agent-node-lj-test', 'ws-test');
       const b = await svc.startBlank('agent-node-lj-test', 'ws-test');
 
-      expect(b!.id).withContext('连点新建不该攒出多条').toBe(a!.id);
+      expect(svc.sessions.byId(a!.id)).withContext('上一次的空会话应被清掉').toBeUndefined();
+      expect(svc.sessions.byId(b!.id)).toBeDefined();
       expect(svc.sessions.listFor('agent-node-lj-test').length).toBe(1);
     });
 
-    it('不同归属各建一条(工作区 A 与工作区 B 互不干扰)', async () => {
+    it('🔴 不同归属新建时,旧的空会话同样被清掉(全局只留一条)', async () => {
       realMode();
       mxStub.timelineToMessages.and.returnValue([]);
       apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
@@ -227,8 +234,25 @@ describe('ChatService', () => {
       const a = await svc.startBlank('agent-node-lj-test', 'ws-a');
       const b = await svc.startBlank('agent-node-lj-test', 'ws-b');
 
-      expect(b!.id).not.toBe(a!.id);
-      expect(svc.sessions.listFor('agent-node-lj-test').length).toBe(2);
+      expect(svc.sessions.byId(a!.id)).withContext('工作区 A 的那条应被清掉').toBeUndefined();
+      expect(svc.sessions.byId(b!.id)?.workspaceId).toBe('ws-b');
+      expect(svc.sessions.listFor('agent-node-lj-test').length).toBe(1);
+    });
+
+    it('🔴 发过消息的会话不会被新建操作清掉', async () => {
+      realMode();
+      mxStub.timelineToMessages.and.returnValue([]);
+      apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
+      svc.sessions.reset();
+
+      // 先建一条并发消息(= 已持久化)
+      const old = await svc.startBlank('agent-node-lj-test');
+      await svc.sendText('这条已经发过消息');
+      // 再新建一条
+      const fresh = await svc.startBlank('agent-node-lj-test');
+
+      expect(svc.sessions.byId(old!.id)).withContext('有消息的会话不能被清').toBeDefined();
+      expect(svc.sessions.byId(fresh!.id)).toBeDefined();
     });
 
     it('建出会话后发消息,state 正常切到 ready', async () => {

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

@@ -280,6 +280,17 @@ export class ChatService {
     }
     const my = ++this.seq;
 
+    /*
+     * 🔴 切走前先清掉上一条空会话(2026-11-01)。
+     *
+     * 场景:用户在不同工作区连点几次「新建对话」,每次都建出一条 0 条消息的会话,
+     * 且都没发消息就切走了。持久化的唯一条件是「发过消息」,
+     * 这些没落库的空壳应当**在离开时消失**,而不是留在左侧列表里。
+     *
+     * 保留 `sessionId`(本次要打开的那条)—— 用户刚点完新建正在看它。
+     */
+    this.sessions.pruneEmpty(sessionId);
+
     this.teardownSubscriptions();
     this._agentId.set(agentId);
     this._state.set('loading');
@@ -373,14 +384,21 @@ export class ChatService {
     if (!roomId) return null;
 
     /*
-     * 🔴 复用规则:该容器 + 该归属下已经有空会话时,直接打开它而不是再建一条。
-     *    否则用户连点几次「新建对话」就会攒出一排同名的「新对话」。
+     * 🔴 全局只保留**一条**「新建对话」(2026-11-01 用户裁决)。
+     *
+     * 用户可以在工作区里新建、也可以在日常对话里新建。规则是:
+     *   每次新建 → 之前**所有没发过消息**的空会话一律清掉,只留这一条。
+     * 不再按「同容器 + 同归属」复用,也不依赖 60 秒定时清理 ——
+     * 那样会出现「工作区一条、日常一条」并存的情况,不符合预期。
+     *
+     * 新建的会话归属到目标工作区(`workspaceId`),但**不落库**:
+     * 持久化的唯一前提是在这条会话里发过消息(见 create/commit 的分工)。
      */
-    const existEmpty = this.sessions
-      .listFor(agentId)
-      .find((s) => !s.legacy && (s.messageCount ?? 0) === 0 && (s.workspaceId ?? null) === (workspaceId ?? null));
-    const s = existEmpty ?? this.sessions.create(roomId, agentId);
-    if (!existEmpty && workspaceId) this.sessions.moveTo(s.id, workspaceId);
+    const s = this.sessions.create(roomId, agentId);
+    if (workspaceId) this.sessions.moveTo(s.id, workspaceId);
+
+    // 清掉此前留下的其它空会话,保证列表里只有这一条「新建对话」
+    this.sessions.pruneEmpty(s.id);
 
     // 切到这条新会话(左侧随之高亮,见 session-tree 的 activeId)
     this._sessionId.set(s.id);