Bläddra i källkod

fix(chat): 新建会话后不停留在新建流程区

根因两处叠加:
1. startBlank() 把 state 置为 empty,首条消息建出会话后只设了 _sessionId,
   没有把 state 切回 ready —— 消息区按 state 分派,继续渲染「还没有对话」
2. 待建态(选完容器未发消息)与「该容器无会话」都满足 !currentSession(),
   被混为一谈,导致刚点完新建就提示「去点新建对话」

修复:
- ChatService 新增 pendingNewSession 信号,明确区分两种状态
- 首条消息建出会话时同步 state=ready 并清除待建态
- openReal 打开真实会话、close 离开页面时复位待建态
- 待建态显示欢迎引导且输入框可用;真无会话才提示新建
- 待建态建出会话后 URL 用 replaceUrl 换成真实会话地址(避免刷新回空白页)

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 1 dag sedan
förälder
incheckning
aa8830238d

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

@@ -3488,3 +3488,32 @@ messageCount: (s.messageCount ?? 0) + 1,   // 先累加
 - tsc 0 错误;单测 **1068/1068**;构建通过;verify:ui 12/12
 - **踩坑(第二次)**:CSS 模板字符串注释里用反引号包类名会提前闭合字符串,
   报错表现为 `Undefined mixin` / `Failed to resolve styles`,与真实原因无关。
+
+## 2026-11-01 第十轮:修复「新建会话后仍停在新建流程区」
+
+### 🔴 用户反馈
+选中容器新建对话、发出第一条消息后,右侧仍显示「还没有对话 / 新建对话」那一屏。
+
+### 根因(两处叠加)
+1. **状态没跟着走**:`startBlank()` 把 `state` 置为 `empty`,发送消息时只设了
+   `_sessionId`,**没有把 state 切回 `ready`**。消息区按 `@switch (state)` 分派,
+   于是会话建出来了、界面却继续渲染 `@case ('empty')` 的新建引导
+2. **两种「没有当前会话」混为一谈**:页面用 `!currentSession()` 判断要不要显示
+   「还没有对话」,但待建态(刚选完容器、还没发消息)`currentSession()` 也是空的,
+   于是一进新建页就提示「去点新建对话」—— 用户刚点完,形成死循环
+
+### 修复
+- `ChatService` 新增 `pendingNewSession` 信号,明确区分「待建态」与「该容器无会话」
+- 首条消息建出会话时同步:`state → 'ready'`、`pendingNew = false`
+- `openReal()` 打开真实会话、`close()` 离开页面时都复位待建态
+- 页面按 `pendingNewSession()` 分流:
+  - 待建态 → 欢迎区 + 建议起点(**输入框可用**,直接开始)
+  - 真无会话 → 「还没有对话」+ 新建按钮
+- 输入框显示条件改为 `currentSession() || pendingNewSession()`
+- 待建态建出会话后,URL 从 `/chat/:id/new` 用 `replaceUrl` 换成真实地址
+  (否则刷新会回到空白待建态,用户以为会话丢了)
+
+### 验证
+- tsc 0 错误;单测 **1071/1071**(新增 3 条回归:首条消息后状态切换 / 打开已有会话退出待建态 / close 清理)
+- 构建通过;verify:ui 12/12;verify:docs 79 文件
+- 移动端无新建流程区(空态为通用「还没有消息」且输入框常驻),不需同步改动

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

@@ -175,6 +175,48 @@ describe('ChatService', () => {
     (environment as { useMockData: boolean }).useMockData = false;
   }
 
+  describe('待建态(新建对话 → 首次发消息才建会话)', () => {
+    /*
+     * 🔴 回归:新建对话后界面仍停在「新建流程区」(2026-11-01 用户反馈)。
+     *
+     * 根因是 state 没跟着走 —— `startBlank()` 置 `empty`,建出会话后
+     * 只设了 `_sessionId`,state 仍是 `empty`,消息区按 state 分派就继续
+     * 渲染「还没有对话 / 新建对话」那一屏,看起来像发出去没反应。
+     */
+    it('🔴 首次发消息后 pendingNewSession 清除、state 变为 ready', async () => {
+      realMode();
+      mxStub.timelineToMessages.and.returnValue([]);
+
+      await svc.startBlank('agent-node-lj-test');
+      expect(svc.pendingNewSession()).withContext('进入待建态').toBeTrue();
+      expect(svc.currentSession()).withContext('待建态还没有会话').toBeUndefined();
+
+      await svc.sendText('第一条消息');
+
+      expect(svc.pendingNewSession()).withContext('建出会话后必须退出待建态').toBeFalse();
+      expect(svc.state()).withContext('状态必须切到 ready,否则界面停在新建引导').toBe('ready');
+      expect(svc.currentSession()).withContext('会话应已建出').toBeDefined();
+    });
+
+    it('打开一个已有会话也会退出待建态', async () => {
+      realMode();
+      mxStub.timelineToMessages.and.returnValue([]);
+      await svc.startBlank('agent-node-lj-test');
+      expect(svc.pendingNewSession()).toBeTrue();
+
+      await svc.open('agent-node-lj-test', undefined, true);
+      expect(svc.pendingNewSession()).toBeFalse();
+    });
+
+    it('close() 会清掉待建态(离开页面不继承空白态)', async () => {
+      realMode();
+      mxStub.timelineToMessages.and.returnValue([]);
+      await svc.startBlank('agent-node-lj-test');
+      svc.close();
+      expect(svc.pendingNewSession()).toBeFalse();
+    });
+  });
+
   describe('open() 真实态编排', () => {
     it('provision → connectSession → ensureRoom 顺序执行', async () => {
       realMode();

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

@@ -350,6 +350,17 @@ export class ChatService {
    * @param agentId 目标容器
    * @returns 是否成功切换到该容器的空白态(容器连接失败时为 false)
    */
+  /**
+   * 是否处于「待建态」:用户点了新建对话、选好容器,但还没发出第一条消息。
+   *
+   * 🔴 与「该容器没有会话」是**两种不同状态**,界面提示必须分开:
+   *    · 待建态 → 应该给输入框和欢迎引导,让用户直接开始;
+   *    · 无会话 → 才提示「点击左侧新建对话」。
+   *    混为一谈会导致:用户点了新建,页面却还在提示「去点新建对话」(死循环)。
+   */
+  private readonly _pendingNew = signal(false);
+  readonly pendingNewSession = this._pendingNew.asReadonly();
+
   async startBlank(agentId: string, workspaceId: string | null = null): Promise<boolean> {
     await this.open(agentId);
     if (this._agentId() !== agentId) return false;
@@ -364,6 +375,7 @@ export class ChatService {
     this._error.set(null);
     this._hasMoreHistory.set(false);
     this._blankWorkspace = workspaceId;
+    this._pendingNew.set(true);
     return true;
   }
 
@@ -530,6 +542,8 @@ 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)),
@@ -652,6 +666,9 @@ export class ChatService {
     this._peerTyping.set(false);
     this._paginating.set(false);
     this._hasMoreHistory.set(false);
+    // 离开聊天页 → 待建态作废(下次进来是新的一次,不该继承上次的空白态)
+    this._pendingNew.set(false);
+    this._blankWorkspace = null;
   }
 
   setDraft(text: string): void {
@@ -701,7 +718,22 @@ export class ChatService {
      */
     if (!this._sessionId()) {
       const created = this.ensureSessionForSend();
-      if (created) this._sessionId.set(created.id);
+      if (created) {
+        this._sessionId.set(created.id);
+        /*
+         * 🔴 必须同时把状态从 empty 切到 ready(2026-11-01 修复)。
+         *
+         * `startBlank()` 把 state 置为 `'empty'`,而消息区是按 state 分派的:
+         * `@case ('empty')` 里「没有 currentSession → 显示新建引导」。
+         * 发完第一条消息后 currentSession 有了,但 state 还是 empty ——
+         * 页面继续渲染新建流程区,看起来像「发出去没反应」(用户实测反馈)。
+         *
+         * 建出会话 = 这已经是正常会话了,状态必须跟着走。
+         */
+        this._state.set('ready');
+        // 会话已建出 → 不再是待建态
+        this._pendingNew.set(false);
+      }
     }
     /*
      * 会话元信息(标题/预览/时间)随**用户发出的消息**推进。

+ 61 - 11
src/app/desktop/pages/chat/chat.ts

@@ -315,17 +315,48 @@ interface ConversationRow {
               @case ('empty') {
                 @if (!chat.currentSession()) {
                   <!--
-                    还没有任何会话(2026-11-01 用户裁决):
-                    不再凭空创建「临时会话」,提示用户先新建对话再开始。
+                    两种「没有当前会话」的情形,提示词必须分开(2026-11-01):
+
+                    ① 待建态(用户刚点「新建对话」选了容器,还没发第一条消息)
+                       → 这是**正常的输入状态**,右侧应显示欢迎区 + 可用输入框,
+                         而不是再让用户点一次「新建对话」(那是个死循环)。
+
+                    ② 该容器确实一条会话都没有、也不是待建态
+                       → 才提示「还没有对话 / 点击左侧新建」。
                   -->
-                  <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>
+                  @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>
+                  }
                 } @else {
                 <!--
                   空会话欢迎区(2026-10-29,参考 ChatGPT 落地页):
@@ -423,7 +454,7 @@ interface ConversationRow {
                空态只保留说明 + 「新建对话」按钮,入口唯一。
                例外:state 是 'ready'/'empty' 但有 currentSession(正常会话)→ 正常显示。
           -->
-          @if (chat.currentSession()) {
+          @if (chat.currentSession() || chat.pendingNewSession()) {
           <div class="composer-hold">
           <!--
             暂存附件条:选文件后**先停在这里**,点发送才真正上传+发送。
@@ -2377,6 +2408,25 @@ export class DkChatPage {
       if (list.length === 0) return;
       void this.artifactSync.syncPending(list);
     });
+
+    /*
+     * 待建态建出会话后,把 URL 从 /chat/:id/new 换成真实会话地址(2026-11-01)。
+     *
+     * 🔴 不换的话 URL 一直停在 `new`:刷新页面会重新进入待建态,
+     *    用户看到刚发的会话「不见了」;分享/回退也会回到空白新建页。
+     *    这里用 replaceUrl,不产生历史记录堆积。
+     */
+    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> {