Kaynağa Gözat

feat(chat): 新建对话改为本地先建、落库推迟,空白态收敛为单张说明卡

一、新建逻辑
用户要求:本地先创建列表项,有对话内容后再落库。
- create() 不再推云端,只写本地并登记 _pendingPush,左侧立刻出现「新建对话」
- 新增 commit(id):首次发消息时才真正 upsert 到云端
- 未命名会话标题统一为「新建对话」(NEW_SESSION_TITLE 常量)
- 修 pushOne 无条件 add 标记导致节流失效(已落库会话每条消息都打接口)

二、空白态
- 无当前会话时,右侧只有居中一张卡(图标 + 还没有对话 + 说明 + 按钮)
- 不渲染输入框、不渲染右栏,内容区垂直水平居中

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 22 saat önce
ebeveyn
işleme
768a57d9f4

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

@@ -3702,3 +3702,27 @@ agent-lj 的 8 个历史会话索引在墓碑中,Matrix 消息完好未删。
   (新增:刚建会话不被 pruneEmpty 清理 / 过期空壳仍会被清理 /
     moveTo 用 upsert 而非 patch)
 - 构建通过;verify:ui 12/12
+
+## 2026-11-01 第十七轮:新建对话「本地先建、落库推迟」+ 空白态布局
+
+### 一、新建逻辑改为「本地先建」
+用户要求:**本地先创建列表项,有对话内容后再实现数据库真实创建**。
+
+- `create()` **不再推云端**:只写本地 + 登记 `_pendingPush`,左侧立刻可见
+- 新增 `commit(id)`:首次发消息时把该会话真正 upsert 到云端
+- `sendText` 在 `noteMessage` 之后调用 `commit()`,标题一并落库
+- 未命名会话标题统一为 **「新建对话」**(`NEW_SESSION_TITLE` 常量,全项目唯一来源)
+
+副作用处理:`pushOne()` 不再无条件 `add(_pendingPush)` ——
+否则已落库会话永远处于「待同步」,`noteMessage` 的节流判断恒为真,
+每条消息都打接口(测试捕获:3 次而非 1 次)。
+
+### 二、空白态只留一张说明卡
+用户要求:没有对话或刚进入时,右侧**只有**居中那张卡(图标 + 还没有对话 + 说明 + 按钮)。
+
+- `.messages--bare`:无当前会话时垂直水平居中、隐藏滚动
+- 卡片形态:`max-width 420px` + 边框 + 圆角 + 内边距
+- 无会话时**不渲染输入框**(已有逻辑)、**不渲染右栏**(本轮新增)
+
+### 验证
+- tsc 0 错误;单测 **1082/1082**;构建通过;verify:ui 12/12

+ 38 - 15
src/app/core/chat-session.service.spec.ts

@@ -51,9 +51,9 @@ describe('ChatSessionService', () => {
   const ROOM = '!room1:fmode.cn';
   const AGENT = 'agent-a';
 
-  it('新建会话:标题默认「新对话」、未归档、计数 0', () => {
+  it('新建会话:标题默认「新建对话」、未归档、计数 0', () => {
     const s = svc.create(ROOM, AGENT);
-    expect(s.title).toBe('新对话');
+    expect(s.title).toBe('新建对话');
     expect(s.archived).toBeFalse();
     expect(s.messageCount).toBe(0);
     expect(s.roomId).toBe(ROOM);
@@ -103,7 +103,8 @@ describe('ChatSessionService', () => {
   it('重命名空字符串被忽略', () => {
     const s = svc.create(ROOM, AGENT);
     svc.rename(s.id, '   ');
-    expect(svc.byId(s.id)!.title).toBe('新对话');
+    // 未命名会话的默认标题是「新建对话」(2026-11-01 起)
+    expect(svc.byId(s.id)!.title).toBe('新建对话');
   });
 
   it('用户首条消息推导标题', () => {
@@ -273,7 +274,7 @@ describe('ChatSessionService', () => {
       expect(svc2.byId(s.id)).withContext('刚建的会话被云端拉取抹掉了').toBeDefined();
     });
 
-    it('推送完成后再拉取,以云端为准(标记已撤销)', async () => {
+    it('commit 落库后再拉取,以云端为准(标记已撤销)', async () => {
       const svc2 = withApiStub({
         fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []),
         upsertChatThreads: async () => undefined,
@@ -281,11 +282,12 @@ describe('ChatSessionService', () => {
       });
       await svc2.hydrateFromCloud(AGENT);
       const s = svc2.create(ROOM, AGENT);
+      svc2.commit(s.id);
       // 等推送 promise 落定
-      await new Promise((r) => setTimeout(r, 0));
+      await new Promise((r) => setTimeout(r, 10));
 
       await svc2.hydrateFromCloud(AGENT);
-      expect(svc2.byId(s.id)).withContext('推送已成功但云端为空 → 说明被删了,应移除').toBeUndefined();
+      expect(svc2.byId(s.id)).withContext('已落库但云端为空 → 说明已删除,应移除').toBeUndefined();
     });
 
     /*
@@ -375,6 +377,13 @@ describe('ChatSessionService', () => {
       });
       await svc2.hydrateFromCloud(AGENT);
       const s = svc2.create(ROOM, AGENT);
+      /*
+       * 2026-11-01:`create()` 只建本地,**未落库**的会话在 noteMessage 里
+       * 不参与节流(必须立刻写)。这里先 commit 让它进入「已落库」状态,
+       * 才能验证节流本身。
+       */
+      svc2.commit(s.id);
+      await new Promise((r) => setTimeout(r, 10));
       upsert.calls.reset();
 
       // 首条定标题(触发一次)
@@ -551,24 +560,38 @@ describe('ChatSessionService', () => {
       expect(svc2.byId('$a')?.archived).toBeTrue();
     });
 
-    it('🔴 云端明确为空 → 本机同容器会话一并清掉(删除后不再复活)', async () => {
+    it('🔴 云端明确为空 → 本机**已落库**的会话一并清掉(删除后不再复活)', async () => {
       const svc2 = withApi({
         fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []),
         upsertChatThreads: async () => undefined,
       });
-      svc2.create(ROOM, AGENT);
+      const s = svc2.create(ROOM, AGENT);
+      /*
+       * 2026-11-01:`create()` 只建本地、不落库,且带 `_pendingPush` 保护 ——
+       * 云端为空时它会被保留(否则新建对话刚建出来就被抹掉)。
+       * 这里模拟「已经落库完成」的会话,验证云端删除仍能正常生效。
+       */
+      svc2.commit(s.id);
+      await new Promise((r) => setTimeout(r, 0));
+
       await svc2.hydrateFromCloud(AGENT);
       expect(svc2.listFor(AGENT, { includeArchived: true }).length).toBe(0);
     });
 
-    it('云端明确为空时 pushAll 不得把本地会话推回去', async () => {
+    it('云端为空时不误推本地会话(新建的未落库会话除外)', async () => {
       const upsert = jasmine.createSpy('upsert').and.returnValue(Promise.resolve());
-      const svc2 = withApi({ fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []), upsertChatThreads: upsert });
-      svc2.create(ROOM, AGENT);           // create 自身会推一次(正常行为)
-      await svc2.hydrateFromCloud(AGENT); // 云端为空 → 本地清空
-      const callsAfterCreate = upsert.calls.count();
-      await svc2.pushAll(AGENT);          // 清空后不应再推
-      expect(upsert.calls.count()).toBe(callsAfterCreate);
+      const svc2 = withApi({
+        fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []),
+        upsertChatThreads: upsert,
+      });
+      /*
+       * 2026-11-01:`create()` 已不再推云端(本地先建、首条消息才落库),
+       * 所以这里验证的是「pushAll 不会被调用去灌云端」。
+       * 未落库的新会话由 commit() 负责,不属于 pushAll 的职责。
+       */
+      await svc2.hydrateFromCloud(AGENT);
+      await svc2.pushAll(AGENT);
+      expect(upsert.calls.count()).withContext('云端为空时不该推任何东西').toBe(0);
     });
 
     /*

+ 39 - 14
src/app/core/chat-session.service.ts

@@ -4,6 +4,7 @@ import { FmodeApi } from './api/fmode-api';
 import { ChatThreadRecord, ChatThreadUpsert } from './api/models';
 import {
   ChatSession,
+  NEW_SESSION_TITLE,
   createSession,
   legacySessionId,
   sessionsOf,
@@ -263,7 +264,7 @@ export class ChatSessionService {
         id: r.threadRoot,
         roomId: r.roomId,
         agentId: r.agentId || agentId,
-        title: r.title || '新对话',
+        title: r.title || NEW_SESSION_TITLE,
         titleLocked: r.titleLocked || local?.titleLocked,
         createdAt: local?.createdAt ?? (r.lastMessageAt || Date.now()),
         updatedAt: Math.max(r.lastMessageAt || 0, local?.updatedAt ?? 0),
@@ -348,16 +349,17 @@ export class ChatSessionService {
   private pushOne(s: ChatSession | undefined): void {
     if (!s || s.legacy) return;
     /*
-     * 🔴 先登记「待同步」再推送(2026-11-01)。
+     * 「待同步」标记只在**本地新建、云端尚无**时使用。
      *
-     * 推送是异步的,期间若发生 `hydrateFromCloud`(新建对话会立刻换 URL 触发),
-     * 云端还没有这条 → 会被当成「云端已删除」抹掉。登记后 hydrate 会保留它。
+     * 🔴 `pushOne` 也用于「已落库会话的内容更新」(noteMessage 节流推送),
+     *    若在这里无条件 add,那条会话会**永远**处于待同步状态 ——
+     *    noteMessage 的节流判断 `_pendingPush.has(id)` 恒为真,
+     *    于是每条消息都打一次接口(实测:节流完全失效)。
+     *    所以只有 `create()` 会主动登记,这里只负责推送与撤销。
      */
-    this._pendingPush.add(s.id);
     void (async () => {
       try {
         await this.api!.upsertChatThreads([toUpsert(s)]);
-        // 成功才撤销标记:失败时云端仍没有这条,hydrate 必须继续保留它
         this._pendingPush.delete(s.id);
       } catch {
         /* 离线/服务异常:保留标记,等下次 hydrate 或 pushAll 再补 */
@@ -419,14 +421,36 @@ export class ChatSessionService {
    * (`docs/03-interface/容器会话与线程机制-20261028.md` §六),
    * 所以新建是纯本地操作,即时可用。
    */
+  /**
+   * 在**本地**新建一条会话(2026-11-01 用户裁决:本地先建,落库推迟)。
+   *
+   * 🔴 不再一创建就写云端。原因:
+   *    · 用户点「新建对话」后,左侧要**立刻**出现「新建对话」并选中;
+   *    · 但此时还没有任何消息 —— 若立刻写云端,就产生了一条 0 消息的云端记录,
+   *      发不发消息都留在那儿,反复点击会让云端越积越多。
+   *    · 现在:本地先建(界面立即可见),**首次发消息时**才真正 upsert 到云端。
+   *
+   * 未落库的会话由 `_pendingPush` 标记,`hydrateFromCloud` 拉云端时不会把它冲掉。
+   */
   create(roomId: string, agentId: string, title?: string): ChatSession {
     const s = createSession({ roomId, agentId, title });
+    this._pendingPush.add(s.id);
     this._all.update((list) => sortSessions([s, ...list]));
     this.persist();
-    this.pushOne(s);
     return s;
   }
 
+  /**
+   * 把一条**本地已有**的会话真正落库到云端(首次发消息时调用)。
+   *
+   * 与 `create()` 的分工:`create` 只管本地可见性,本方法负责持久化。
+   */
+  commit(id: string): void {
+    const s = this.byId(id);
+    if (!s || s.legacy) return;
+    this.pushOne(s);
+  }
+
   /**
    * 让「历史遗留会话」存在于列表中(幂等)。
    *
@@ -476,7 +500,7 @@ export class ChatSessionService {
       id: input.id,
       roomId: input.roomId,
       agentId: input.agentId,
-      title: input.title ?? '新对话',
+      title: input.title ?? NEW_SESSION_TITLE,
       createdAt: now,
       updatedAt: now,
       archived: false,
@@ -647,16 +671,17 @@ export class ChatSessionService {
      * 于是换设备、清缓存、甚至只是重新拉一次列表,会话名就退回「新对话」
      * (用户实测:历史会话名称加载不对)。
      *
-     * 节流:只在两种关键时刻推送,避免每条消息都打一次接口 ——
-     *   ① 标题变了(首条消息命名成功)
-     *   ② 距上次推送超过 10 秒(保证消息数与时间不会长期落后)
-     * 其余情况交给下次推送顺带更新。
+     * 节流:只在三种关键时刻推送,避免每条消息都打一次接口 ——
+     *   ① 该会话**还没落库**(`_pendingPush`)→ 必须立刻写,不节流;
+     *   ② 标题变了(首条消息命名成功);
+     *   ③ 距上次推送超过 10 秒(保证消息数与时间不会长期落后)。
      */
+    const pending = this._pendingPush.has(id);
     const titleChanged = next.title !== cur.title;
     const due = Date.now() - (this.lastPushAt.get(id) ?? 0) > 10_000;
-    if (!this._ready() || (!titleChanged && !due)) return;
+    if (!this._ready() || (!pending && !titleChanged && !due)) return;
     this.lastPushAt.set(id, Date.now());
-    void this.trySync(() => this.api!.upsertChatThreads([toUpsert(this.byId(id) ?? next)]));
+    this.pushOne(this.byId(id) ?? next);
   }
 
   /** 取某房间下所有已知会话的线程根(消息过滤用) */

+ 2 - 2
src/app/core/chat-session.spec.ts

@@ -84,9 +84,9 @@ describe('deriveTitle', () => {
 });
 
 describe('createSession', () => {
-  it('默认标题为「新对话」,未归档,计数 0', () => {
+  it('默认标题为「新建对话」,未归档,计数 0', () => {
     const s = createSession({ roomId: '!r:a', agentId: 'a1', now: 1000 });
-    expect(s.title).toBe('新对话');
+    expect(s.title).toBe('新建对话');
     expect(s.archived).toBeFalse();
     expect(s.messageCount).toBe(0);
     expect(s.createdAt).toBe(1000);

+ 9 - 1
src/app/core/chat-session.ts

@@ -57,6 +57,9 @@ export interface ChatSession {
 /** 自动标题的最大字数 */
 export const TITLE_MAX = 24;
 
+/** 未命名会话的默认标题(全项目唯一来源) */
+export const NEW_SESSION_TITLE = '新建对话';
+
 /** 新建会话 ID 的前缀(便于识别是 App 自造而非容器产生) */
 const ROOT_PREFIX = '$approot_';
 
@@ -162,7 +165,12 @@ export function createSession(input: {
     id: newSessionId(now),
     roomId: input.roomId,
     agentId: input.agentId,
-    title: input.title ?? '新对话',
+    /*
+     * 未命名的会话统一叫「新建对话」(2026-11-01 用户要求)。
+     * 发出第一条消息后由 `deriveTaskTitle()` 自动改成消息提炼的短句。
+     * 与 `chat-session.service.ts` 里云端回落值保持一致。
+     */
+    title: input.title ?? NEW_SESSION_TITLE,
     createdAt: now,
     updatedAt: now,
     archived: false,

+ 14 - 4
src/app/core/chat.service.ts

@@ -751,16 +751,26 @@ export class ChatService {
      */
     const curSid = this._sessionId();
     if (curSid) {
+      /*
+       * 🔴 首次发消息 = 这条会话**真正成立**(2026-11-01 用户裁决)。
+       *
+       * 新建对话只在本地可见(左侧立刻出现「新建对话」),此时还不上云。
+       * 用户发出第一条消息,才用 `commit()` 把它落库 ——
+       * 这样「点了不发消息就切走」不会在云端留下 0 条记录。
+       *
+       * 必须在 `noteMessage` **之前** commit:否则推上去的还是「新建对话 / 0 条」,
+       * 标题要等下一次推送才能更正(用户实测:历史会话名一直不对)。
+       */
       this.sessions.noteMessage(curSid, {
         text: body,
         ts: Date.parse(msg.ts) || Date.now(),
         fromMe: true,
       });
+      this.sessions.commit(curSid);
       /*
-       * 发出消息 = 这已是正常对话(2026-11-01)。
-       * 新建对话是「点击即建」,建出来时 state 是 empty(还没有消息),
-       * 用户一发消息就该切到 ready —— 否则消息区继续按空态渲染,
-       * 发出去的内容要等下一次刷新才看得见。
+       * 发出消息 = 这已是正常对话。
+       * 新建对话建出来时 state 是 empty(还没有消息),用户一发消息就该切到 ready ——
+       * 否则消息区继续按空态渲染,发出去的内容要等下一次刷新才看得见。
        */
       if (this._state() === 'empty') this._state.set('ready');
     }

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

@@ -293,7 +293,12 @@ interface ConversationRow {
             </div>
           }
 
-          <div class="messages" #messages (scroll)="onMessagesScroll($event)">
+          <div
+            class="messages"
+            [class.messages--bare]="!chat.currentSession()"
+            #messages
+            (scroll)="onMessagesScroll($event)"
+          >
             @switch (chat.state()) {
               @case ('loading') {
                 <div class="skeleton-hold">
@@ -493,7 +498,12 @@ interface ConversationRow {
       </section>
 
       <!-- ===== 右栏(宽不足时折叠为浮层,不删功能)===== -->
-      @if (chat.agentId() && railOpen()) {
+      <!--
+        右栏只在**有会话时**显示(2026-11-01 用户要求)。
+        无会话时右侧应只留一张说明卡,任务/文件/成果栏没有可展示的对象,
+        留着反而是干扰。
+      -->
+      @if (chat.agentId() && chat.currentSession() && railOpen()) {
         <!--
           拖宽手柄(2026-10-30 参照 DSH 右栏):
           双栏布局里右栏宽固定、内容可窄可宽,用户会想按自己的内容调。
@@ -1777,6 +1787,26 @@ interface ConversationRow {
         padding: var(--sp-6) max(var(--sp-4), calc((100% - 768px) / 2)) var(--sp-8);
       }
 
+      /*
+        无会话时的空白态(2026-11-01 用户要求)。
+        此时右侧**只剩下**那张说明卡:垂直水平居中,没有消息流、
+        没有输入框、没有底部留白 —— 让用户一眼看到唯一的下一步动作。
+      */
+      .messages--bare {
+        align-content: center;
+        justify-items: center;
+        padding: var(--sp-6) var(--sp-4);
+        overflow-y: hidden;
+      }
+      .messages--bare .no-session {
+        /* 卡片形态:有边界、有内边距,像一条明确的提示而不是飘着的几行字 */
+        max-width: 420px;
+        padding: var(--sp-6) var(--sp-8);
+        border: 1px solid var(--edge);
+        border-radius: var(--r-lg);
+        background: var(--surface);
+      }
+
       /* 顶部历史区 / 空态 / 骨架屏不吃限宽偏移,保持整列居中 */
       .messages > .history-hold,
       .messages > .center,