Bladeren bron

feat(chat): 新建对话改为点击即建,左侧立刻出现并选中

用户反馈:点「新建对话」后在左侧看不到任何条目,不知道操作有没有生效。
上一版是「首次发消息才建会话」——绝无空壳,但点击没反馈。

变更:
- startBlank 立刻创建会话并选中,归属到对应工作区 / 日常对话
- 同容器 + 同归属下已有空会话时复用,连点不会攒出一排「新对话」
- 建出后立刻 replaceUrl 到真实会话地址,左侧高亮走 URL,刷新不回 new
- 恢复 pruneEmpty:打开容器/切会话时收敛空壳(同步软删云端记录)

过程中修掉的缺陷:
- startBlank 原先调用 open(),而 open() 会顺带打开「最近会话」,
  把 _sessionId 指向别的会话 — 测试实测连点新建变成两条
- 建出的空会话 state 是 empty,发出消息时未切 ready,消息区继续渲染空态

验证:tsc 0 错误;单测 1078/1078(新增 5 条);构建通过;verify:ui 12/12

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 23 uur geleden
bovenliggende
commit
8816e9626f

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

@@ -3615,3 +3615,34 @@ messageCount: (s.messageCount ?? 0) + 1,   // 先累加
 ### 关于历史对话(用户裁决:不恢复)
 agent-lj 的 8 个历史会话索引在墓碑中,Matrix 消息完好未删。
 按用户决定保持现状,不重建索引。
+
+## 2026-11-01 第十四轮:新建对话改为「点击即建」
+
+### 用户反馈
+点「新建对话」后,左侧对应区域(工作区文件夹 / 日常对话)没有出现任何条目,
+看不到操作有没有生效。
+
+### 变更(裁决反转)
+上一版是「首次发消息才建会话」——好处是绝无空壳,代价是**点了没反馈**。
+现改为**点击即建**:立刻创建会话并在左侧选中。
+
+| 项 | 上一版 | 现在 |
+|---|---|---|
+| 点新建后列表 | 无变化 | 立刻出现「新对话」并高亮 |
+| 空会话产生 | 不会 | 会(不发消息就切走) |
+| 空壳处理 | — | `pruneEmpty` 在打开容器/切会话时收敛,只留当前那条 |
+
+### 关键修复
+1. **`startBlank` 不再调用 `open()`** —— `open()` 会顺带「打开该容器最近的会话」,
+   把 `_sessionId` 指向别的会话,与本次新建的那条来回横跳(测试实测变成两条)。
+   改为只在容器未连接时才 `open()`
+2. **复用规则**:同容器 + 同归属下已有空会话时直接打开它,连点不会攒出一排「新对话」
+3. **URL 立刻替换**:建出会话后马上 `navigateByUrl(replaceUrl)` 到真实地址,
+   左侧高亮走 URL(`activeId`),刷新也不会回到 `new`
+4. **发送后切 ready**:建出的空会话 state 是 `empty`,发出消息时切 `ready`,
+   否则消息区继续按空态渲染
+5. 恢复 `ChatSessionService.pruneEmpty()`(同步软删云端记录,避免下次拉取又回来)
+
+### 验证
+- tsc 0 错误;单测 **1078/1078**(新增 5 条:点击即建 / 归属正确 / 复用空会话 / 不同归属各一条 / 发送后 ready)
+- 构建通过;verify:ui 12/12

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

@@ -521,6 +521,33 @@ export class ChatSessionService {
     return this._removed().has(id);
   }
 
+  /**
+   * 清理**空壳会话**(`messageCount === 0`),保留 `keepId`。
+   *
+   * 2026-11-01 重新引入:新建对话改为「点击即建」(左侧要立刻显示并选中),
+   * 代价是用户点了不发消息就切走会留下空壳。这里在**打开容器**和
+   * **切换会话**时收敛,只保留当前正在看的那条。
+   *
+   * 同时清掉云端对应记录(软删),否则下次 `hydrateFromCloud` 会把它们拉回来。
+   *
+   * @param keepId 当前正在看的会话,必须保留(它也是 0 条,但用户正要发消息)
+   */
+  pruneEmpty(keepId?: string): void {
+    const victims = this._all().filter(
+      (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) {
+      void this.trySync(() =>
+        this.api!.removeChatThread({ threadRoot: v.id, roomId: v.roomId, agentId: v.agentId }),
+      );
+    }
+  }
+
   /**
    * 直接设置消息计数与最后活动时间。
    *

+ 49 - 21
src/app/core/chat.service.spec.ts

@@ -175,45 +175,73 @@ describe('ChatService', () => {
     (environment as { useMockData: boolean }).useMockData = false;
   }
 
-  describe('待建态(新建对话 → 首次发消息才建会话)', () => {
+  describe('新建对话(2026-11-01 点击即建)', () => {
     /*
-     * 🔴 回归:新建对话后界面仍停在「新建流程区」(2026-11-01 用户反馈)。
+     * 🔴 用户反馈:点「新建对话」之后左侧看不到任何变化。
      *
-     * 根因是 state 没跟着走 —— `startBlank()` 置 `empty`,建出会话后
-     * 只设了 `_sessionId`,state 仍是 `empty`,消息区按 state 分派就继续
-     * 渲染「还没有对话 / 新建对话」那一屏,看起来像发出去没反应。
+     * 上一版是「首次发消息才建会话」,点按钮只进入待建态、不落数据 ——
+     * 左侧列表当然不会出现条目,用户不知道操作有没有生效。
+     * 现在改为点击即建,并复用它归属下已有的空会话(避免连点攒出一排「新对话」)。
      */
-    it('🔴 首次发消息后 pendingNewSession 清除、state 变为 ready', async () => {
+    it('🔴 startBlank 立刻建出会话并选中', async () => {
       realMode();
       mxStub.timelineToMessages.and.returnValue([]);
+      // 云端目录置空:默认桩会返回 CLOUD_THREAD,断言会凭空多一条
+      apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
+      svc.sessions.reset();
 
-      await svc.startBlank('agent-node-lj-test');
-      expect(svc.pendingNewSession()).withContext('进入待建态').toBeTrue();
-      expect(svc.currentSession()).withContext('待建态还没有会话').toBeUndefined();
+      const created = await svc.startBlank('agent-node-lj-test');
 
-      await svc.sendText('第一条消息');
+      expect(created).withContext('必须立刻返回会话').toBeTruthy();
+      expect(svc.currentSession()?.id).withContext('新会话要处于选中态').toBe(created!.id);
+      expect(svc.sessions.listFor('agent-node-lj-test').length).withContext('左侧应出现 1 条').toBe(1);
+    });
+
+    it('指定工作区时归属正确', async () => {
+      realMode();
+      mxStub.timelineToMessages.and.returnValue([]);
 
-      expect(svc.pendingNewSession()).withContext('建出会话后必须退出待建态').toBeFalse();
-      expect(svc.state()).withContext('状态必须切到 ready,否则界面停在新建引导').toBe('ready');
-      expect(svc.currentSession()).withContext('会话应已建出').toBeDefined();
+      const created = await svc.startBlank('agent-node-lj-test', 'ws-test');
+      expect(svc.sessions.byId(created!.id)?.workspaceId).toBe('ws-test');
     });
 
-    it('打开一个已有会话也会退出待建态', async () => {
+    it('🔴 同一归属下已有空会话时复用,不重复创建', async () => {
       realMode();
       mxStub.timelineToMessages.and.returnValue([]);
-      await svc.startBlank('agent-node-lj-test');
-      expect(svc.pendingNewSession()).toBeTrue();
+      apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
+      svc.sessions.reset();
 
-      await svc.open('agent-node-lj-test', undefined, true);
-      expect(svc.pendingNewSession()).toBeFalse();
+      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.listFor('agent-node-lj-test').length).toBe(1);
     });
 
-    it('close() 会清掉待建态(离开页面不继承空白态)', async () => {
+    it('不同归属各建一条(工作区 A 与工作区 B 互不干扰)', async () => {
       realMode();
       mxStub.timelineToMessages.and.returnValue([]);
+      apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
+      svc.sessions.reset();
+
+      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);
+    });
+
+    it('建出会话后发消息,state 正常切到 ready', async () => {
+      realMode();
+      mxStub.timelineToMessages.and.returnValue([]);
+      apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
+      svc.sessions.reset();
+
       await svc.startBlank('agent-node-lj-test');
-      svc.close();
-      expect(svc.pendingNewSession()).toBeFalse();
+      await svc.sendText('第一条消息');
+
+      expect(svc.state()).toBe('ready');
+      expect(svc.currentSession()).toBeDefined();
     });
   });
 

+ 47 - 33
src/app/core/chat.service.ts

@@ -338,45 +338,58 @@ export class ChatService {
   }
 
   /**
-   * 开启一张**空白的待建会话**(不落任何数据)。
+   * 新建对话:**立刻创建一条会话**并在界面选中它。
    *
-   * 2026-11-01 用户裁决「首次发消息才建会话」后的入口:
-   * 点「新建对话」只是把界面切到「该容器、还没选会话」的状态,
-   * 标题栏显示「新对话」,用户输入并发送时才真正 `create()`。
+   * 2026-11-01 用户裁决变更(原为「首次发消息才建」):
+   *   点「新建对话」之后,左侧对应区域(工作区文件夹 / 日常对话)必须**立刻**
+   *   出现一条「新对话」并且处于选中态 —— 否则用户点了按钮看不到任何反馈,
+   *   不知道操作有没有生效。
    *
-   * 🔴 因此**不会出现 0 消息的空壳**:用户点了又切走、关页面,什么都不留下。
-   *    这也是 `pruneEmpty` 这一整套事后清理可以整体删除的前提。
+   * 代价与对策:用户点了却不发消息就切走,会留下 0 条消息的空壳。
+   * 由 `sessions.pruneEmpty(runningId)` 在**打开容器**和**切走会话**时清理,
+   * 只保留当前正在看的那条。
    *
-   * @param agentId 目标容器
-   * @returns 是否成功切换到该容器的空白态(容器连接失败时为 false)
+   * @param agentId   目标容器
+   * @param workspaceId 归属工作区(`null` = 日常对话)
+   * @returns 创建出的会话;容器未就绪时返回 null
    */
-  /**
-   * 是否处于「待建态」:用户点了新建对话、选好容器,但还没发出第一条消息。
-   *
-   * 🔴 与「该容器没有会话」是**两种不同状态**,界面提示必须分开:
-   *    · 待建态 → 应该给输入框和欢迎引导,让用户直接开始;
-   *    · 无会话 → 才提示「点击左侧新建对话」。
-   *    混为一谈会导致:用户点了新建,页面却还在提示「去点新建对话」(死循环)。
-   */
-  private readonly _pendingNew = signal(false);
-  readonly pendingNewSession = this._pendingNew.asReadonly();
+  async startBlank(agentId: string, workspaceId: string | null = null): Promise<ChatSession | null> {
+    /*
+     * 🔴 不调用 `open()`(2026-11-01 修正)。
+     *
+     * `open()` 会顺带做「打开该容器最近的会话」这一步,把 `_sessionId` 指向已存在的
+     * 会话 —— 紧接着我们又把它改成本次新建的那条,来回横跳;
+     * 更糟的是当容器里已有空会话时,`open()` 会先选中它,导致复用判断和
+     * 「当前会话」指针不一致(测试实测:连点新建变成两条)。
+     *
+     * 这里只做「确保容器已连接」这件事,选会话完全由本方法自己决定。
+     */
+    if (this._agentId() !== agentId || !this.roomId) {
+      await this.open(agentId);
+    }
+    if (this._agentId() !== agentId) return null;
+    if (this._state() === 'error') return null;
+    const roomId = this.roomId;
+    if (!roomId) return null;
 
-  async startBlank(agentId: string, workspaceId: string | null = null): Promise<boolean> {
-    await this.open(agentId);
-    if (this._agentId() !== agentId) return false;
-    if (this._state() === 'error') return false;
     /*
-     * 只清「当前会话」指针,不动列表:写一条空消息会触发提示词与 UI 抖动,
-     * 这里直接复位即可。后续 sendText → ensureSessionForSend 会把会话建出来。
+     * 🔴 复用规则:该容器 + 该归属下已经有空会话时,直接打开它而不是再建一条。
+     *    否则用户连点几次「新建对话」就会攒出一排同名的「新对话」。
      */
-    this._sessionId.set('');
+    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);
+
+    // 切到这条新会话(左侧随之高亮,见 session-tree 的 activeId)
+    this._sessionId.set(s.id);
     this._messages.set([]);
     this._state.set('empty');
     this._error.set(null);
     this._hasMoreHistory.set(false);
     this._blankWorkspace = workspaceId;
-    this._pendingNew.set(true);
-    return true;
+    return this.sessions.byId(s.id) ?? s;
   }
 
   /**
@@ -542,8 +555,6 @@ export class ChatService {
     }
     if (my !== this.seq) return;
     this._sessionId.set(sid);
-    // 打开了真实会话 → 退出待建态(否则界面会一直按「还没开始」渲染)
-    this._pendingNew.set(false);
 
     const all = this.mapTimeline(
       rawTimeline.filter((m) => this.belongsToSession(m, sid)),
@@ -666,8 +677,6 @@ export class ChatService {
     this._peerTyping.set(false);
     this._paginating.set(false);
     this._hasMoreHistory.set(false);
-    // 离开聊天页 → 待建态作废(下次进来是新的一次,不该继承上次的空白态)
-    this._pendingNew.set(false);
     this._blankWorkspace = null;
   }
 
@@ -731,8 +740,6 @@ export class ChatService {
          * 建出会话 = 这已经是正常会话了,状态必须跟着走。
          */
         this._state.set('ready');
-        // 会话已建出 → 不再是待建态
-        this._pendingNew.set(false);
       }
     }
     /*
@@ -749,6 +756,13 @@ export class ChatService {
         ts: Date.parse(msg.ts) || Date.now(),
         fromMe: true,
       });
+      /*
+       * 发出消息 = 这已是正常对话(2026-11-01)。
+       * 新建对话是「点击即建」,建出来时 state 是 empty(还没有消息),
+       * 用户一发消息就该切到 ready —— 否则消息区继续按空态渲染,
+       * 发出去的内容要等下一次刷新才看得见。
+       */
+      if (this._state() === 'empty') this._state.set('ready');
     }
     /*
      * 🔴 用户一发出消息就进入「忙」——**这是事实,不是推测**(容器必定会收到)。

+ 31 - 62
src/app/desktop/pages/chat/chat.ts

@@ -315,48 +315,18 @@ interface ConversationRow {
               @case ('empty') {
                 @if (!chat.currentSession()) {
                   <!--
-                    两种「没有当前会话」的情形,提示词必须分开(2026-11-01):
-
-                    ① 待建态(用户刚点「新建对话」选了容器,还没发第一条消息)
-                       → 这是**正常的输入状态**,右侧应显示欢迎区 + 可用输入框,
-                         而不是再让用户点一次「新建对话」(那是个死循环)。
-
-                    ② 该容器确实一条会话都没有、也不是待建态
-                       → 才提示「还没有对话 / 点击左侧新建」。
+                    该容器下确实一条会话都没有(2026-11-01 点击即建之后,
+                    「新建对话」会立刻建出会话并选中,不会再走到这里;
+                    这里只在用户直接进入一个空容器时出现)。
                   -->
-                  @if (chat.pendingNewSession()) {
-                    <div class="welcome">
-                      <div class="welcome-inner">
-                        <ui-avatar [icon]="'fa-solid fa-robot'" [size]="'lg'" />
-                        <h2 class="welcome-title">
-                          {{ greeting() }},我是 {{ chat.agent()?.name ?? '你的数字生命' }}
-                        </h2>
-                        <p class="welcome-sub">直接输入第一条消息,就会开始这段对话。</p>
-                        <div class="suggests">
-                          @for (s of suggests; track s.title) {
-                            <button
-                              type="button"
-                              class="suggest"
-                              (click)="chat.setDraft(s.text)"
-                            >
-                              <i class="fa-solid" [class]="s.icon" aria-hidden="true"></i>
-                              <span class="suggest-title">{{ s.title }}</span>
-                              <span class="suggest-text">{{ s.text }}</span>
-                            </button>
-                          }
-                        </div>
-                      </div>
-                    </div>
-                  } @else {
-                    <div class="no-session">
-                      <i class="fa-solid fa-comments" aria-hidden="true"></i>
-                      <h2 class="no-session-title">还没有对话</h2>
-                      <p class="no-session-sub">点击左侧「新建对话」,选择一个数字生命后开始。</p>
-                      <ui-button variant="primary" size="sm" (click)="requestNewSession()">
-                        新建对话
-                      </ui-button>
-                    </div>
-                  }
+                  <div class="no-session">
+                    <i class="fa-solid fa-comments" aria-hidden="true"></i>
+                    <h2 class="no-session-title">还没有对话</h2>
+                    <p class="no-session-sub">点击左侧「新建对话」,选择一个数字生命后开始。</p>
+                    <ui-button variant="primary" size="sm" (click)="requestNewSession()">
+                      新建对话
+                    </ui-button>
+                  </div>
                 } @else {
                 <!--
                   空会话欢迎区(2026-10-29,参考 ChatGPT 落地页):
@@ -454,7 +424,7 @@ interface ConversationRow {
                空态只保留说明 + 「新建对话」按钮,入口唯一。
                例外:state 是 'ready'/'empty' 但有 currentSession(正常会话)→ 正常显示。
           -->
-          @if (chat.currentSession() || chat.pendingNewSession()) {
+          @if (chat.currentSession()) {
           <div class="composer-hold">
           <!--
             暂存附件条:选文件后**先停在这里**,点发送才真正上传+发送。
@@ -2414,23 +2384,9 @@ export class DkChatPage {
     });
 
     /*
-     * 待建态建出会话后,把 URL 从 /chat/:id/new 换成真实会话地址(2026-11-01)。
-     *
-     * 🔴 不换的话 URL 一直停在 `new`:刷新页面会重新进入待建态,
-     *    用户看到刚发的会话「不见了」;分享/回退也会回到空白新建页。
-     *    这里用 replaceUrl,不产生历史记录堆积。
+     * `new` 地址的替换已在 onRouteChange 里完成(建出会话后立刻 navigateByUrl),
+     * 这里不再需要 effect 兜底。
      */
-    effect(() => {
-      const sid = this.chat.sessionId();
-      const aid = this.chat.agentId();
-      if (!sid || !aid) return;
-      // 只在当前路由仍是待建态的 `new` 时替换,正常切会话不干预
-      if (!this.router.url.includes(`/chat/${aid}/new`)) return;
-      void this.router.navigateByUrl(
-        `/${this.device.mode()}/chat/${aid}/${encodeURIComponent(sid)}`,
-        { replaceUrl: true },
-      );
-    });
   }
 
   private async onRouteChange(id: string | null, sessionId: string | null): Promise<void> {
@@ -2443,16 +2399,29 @@ export class DkChatPage {
     ]);
     if (id) {
       /*
-       * `sessionId === 'new'` = 「新建对话」的待建地址(不落任何数据)。
-       * 2026-11-01:新建对话不再预建空会话,而是打开一个空白待建态,
-       * 用户发出第一条消息时才真正创建会话。
+       * `sessionId === 'new'` = 「新建对话」入口。
+       *
+       * 🔴 2026-11-01 裁决变更:**点击即建**,左侧立刻出现「新对话」并选中。
+       *    原实现只是进入空白待建态、不落任何数据 —— 用户点了按钮在左侧
+       *    看不到任何变化,不知道操作有没有生效。
+       *
+       * 建出来的会话随后用 replaceUrl 换成真实地址(见构造函数里的 effect),
+       * 避免刷新回到 `new`。
        */
       if (sessionId === 'new') {
         const ws = this.route.snapshot.queryParamMap.get('ws');
-        await this.chat.startBlank(id, ws || null);
+        const created = await this.chat.startBlank(id, ws || null);
+        if (created) {
+          void this.router.navigateByUrl(
+            `/${this.device.mode()}/chat/${id}/${encodeURIComponent(created.id)}`,
+            { replaceUrl: true },
+          );
+        }
       } else {
         await this.chat.open(id, sessionId ?? undefined);
       }
+      // 打开容器后收敛空壳(只保留当前这条)
+      this.sessions.pruneEmpty(this.chat.sessionId() ?? undefined);
       // 打开后内容不满一屏 → 自动补历史(懒加载的另一半)
       void this.fillViewportIfNeeded();
     } else {

+ 14 - 4
src/app/mobile/pages/chat/chat.ts

@@ -762,10 +762,20 @@ export class MbChatPage implements OnInit {
       const id = params.get('id');
       if (!id) return;
       const sid = params.get('sessionId');
-      // `new` = 新建对话的待建地址(不落数据,首条消息才建会话)
-      void (sid === 'new' ? this.chat.startBlank(id) : this.chat.open(id, sid ?? undefined)).then(
-        () => this.fillViewportIfNeeded(),
-      );
+      /*
+       * `new` = 新建对话入口。2026-11-01 改为**点击即建**:
+       * 马上建出会话并跳到真实地址,否则列表里看不到反馈。
+       */
+      if (sid === 'new') {
+        void this.chat.startBlank(id).then((created) => {
+          if (created) {
+            void this.router.navigate(['/mobile', 'chat', id, created.id], { replaceUrl: true });
+          }
+          this.fillViewportIfNeeded();
+        });
+      } else {
+        void this.chat.open(id, sid ?? undefined).then(() => this.fillViewportIfNeeded());
+      }
     });
   }