ソースを参照

feat: 会话执行中发消息先「等待」,用户可选撤回/打断发送

用户设计(2026-11-13):容器 busy_input_mode=interrupt,
直接发会打断正在跑的任务;改成 queue 又让"改主意"要干等几分钟。

折中方案:
- 本会话正在执行时,纯文字消息先存为「待发」,不直接发出
- 输入框上方显示提示条:[撤回] [打断发送]
- 点「打断发送」立即发出(容器 interrupt 生效)
- 点「撤回」丢弃
- 都不点 → 会话空闲后自动发出(effect 监听 sessionBusy)

实现:
- ChatService: _heldMessage / heldMessage / withdrawHeld /
  sendHeldNow / armHeldAutoSend;sendText 拆出 force 分支
- 移动端 + 桌面端各加一个提示条
- 附件发送不走拦截(已上传到待发区,避免重复上传)

容器侧 busy_input_mode 已改回 interrupt(App 已做显式确认)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 2 時間 前
親
コミット
37db41c04a

+ 97 - 0
src/app/core/chat.service.ts

@@ -49,6 +49,7 @@ import { ChatSessionService } from './chat-session.service';
 import { RelayService } from './relay.service';
 import { TaskPulseService } from './task-pulse.service';
 import { sessionRunning, sessionStateOf } from './agent-state';
+import { resolveSessionPhase } from './agent-liveness';
 
 export type ConnectionState = 'connecting' | 'connected' | 'disconnected';
 
@@ -357,6 +358,67 @@ export class ChatService {
   /** 是否可能还有更早历史(真实态:上次分页仍有返回) */
   readonly hasMoreHistory = this._hasMoreHistory.asReadonly();
 
+  /**
+   * 本会话是否**正在执行**(容器在跑这一轮)。
+   *
+   * 用于「等待中」拦截:容器 `busy_input_mode=interrupt`,
+   * 直接发会把正在跑的任务打断,所以要先让用户确认。
+   */
+  readonly sessionBusy = computed(() => {
+    const s = this.sessionSignals();
+    return resolveSessionPhase({ ...s, turnLive: false }).busy;
+  });
+
+  /**
+   * 待发消息(2026-11-13 用户设计)。
+   *
+   * ## 为什么有它
+   *
+   * 容器配置 `busy_input_mode=interrupt` —— 新消息到达会**打断当前 turn**。
+   * 直接发出去的话,用户在长任务中途随手补一句,前面的活全白做。
+   *
+   * 但反过来用 `queue` 又不行:用户「改主意」时得干等几分钟。
+   *
+   * ## 折中方案(用户裁决)
+   *
+   * 本会话正在执行时,新消息**不立即发出**,而是先存为待发:
+   *   · 输入框上方显示「上一条还在执行 · [撤回] [打断发送]」
+   *   · 点「打断发送」→ 立刻发出(容器 interrupt 生效)
+   *   · 点「撤回」→ 丢弃
+   *   · **都不点** → 当前任务结束后自动发出
+   */
+  private readonly _heldMessage = signal<{ text: string; at: number } | null>(null);
+  /** 待发消息(null = 没有)。UI 据此渲染提示条 */
+  readonly heldMessage = this._heldMessage.asReadonly();
+
+  /** 撤回待发消息 */
+  withdrawHeld(): void {
+    this._heldMessage.set(null);
+  }
+
+  /** 立即发出待发消息(打断当前任务) */
+  async sendHeldNow(): Promise<void> {
+    const held = this._heldMessage();
+    if (!held) return;
+    this._heldMessage.set(null);
+    await this.forceSendText(held.text);
+  }
+
+  /**
+   * 本会话空闲后,自动把待发消息发出去。
+   *
+   * 用 effect 监听 `sessionBusy`:由 true → false 时触发一次。
+   */
+  private armHeldAutoSend(): void {
+    effect(() => {
+      const busy = this.sessionBusy();
+      const held = this._heldMessage();
+      if (busy || !held) return;
+      this._heldMessage.set(null);
+      void this.forceSendText(held.text);
+    });
+  }
+
   readonly agent = computed<Agent | undefined>(() => {
     const id = this._agentId();
     return id ? this.live.agentById(id) : undefined;
@@ -443,6 +505,8 @@ export class ChatService {
   }
 
   constructor() {
+    /* 待发消息:会话空闲后自动发出(2026-11-13) */
+    this.armHeldAutoSend();
     /**
      * 🔴 连接状态跟随 SDK 的 sync 状态(FR-04 AC-04.9)。
      *
@@ -1768,6 +1832,22 @@ export class ChatService {
   }
 
   async sendText(text: string): Promise<void> {
+    await this.sendTextInternal(text, false);
+  }
+
+  /**
+   * 强制发送(跳过「等待中」拦截)。
+   *
+   * 供 `sendHeldNow()`(用户点「打断发送」)与 `armHeldAutoSend()`(自动发出)使用。
+   */
+  private async forceSendText(text: string): Promise<void> {
+    await this.sendTextInternal(text, true);
+  }
+
+  /**
+   * @param force 跳过「本会话正在执行 → 先拦住」的检查
+   */
+  private async sendTextInternal(text: string, force: boolean): Promise<void> {
     const body = text.trim();
     /*
      * 只有附件、没有文字也要能发 —— 用户选完图直接点发送是常见操作。
@@ -1796,6 +1876,23 @@ export class ChatService {
     const conversationId = this._agentId();
     if (!conversationId) return;
 
+    /*
+     * 🔴 本会话正在执行 → **先拦住**,不直接发(2026-11-13 用户设计)。
+     *
+     * 容器 `busy_input_mode=interrupt`:直接发会打断正在跑的任务。
+     * 这里改成「存为待发 + 提示条让用户选」:
+     *   · 打断发送 → sendHeldNow()
+     *   · 撤回     → withdrawHeld()
+     *   · 都不点   → 会话空闲后由 armHeldAutoSend() 自动发出
+     *
+     * ⚠️ 附件(`_pendingFiles`)不走这条路径:附件已上传到待发区,
+     *    单纯文字才拦截 —— 混合发送保持原行为,避免附件重复上传。
+     */
+    if (!force && body && this._pendingFiles().length === 0 && this.sessionBusy()) {
+      this._heldMessage.set({ text: body, at: Date.now() });
+      return;
+    }
+
     /*
      * 乐观消息 id(2026-11-09)。
      *

+ 57 - 0
src/app/desktop/pages/chat/chat.ts

@@ -620,6 +620,27 @@ interface ConversationRow {
             </div>
           }
 
+          <!--
+            待发消息提示条(2026-11-13 用户设计):
+            本会话正在执行时,新消息先拦住(容器 interrupt 会打断当前任务)。
+            撤回 / 打断发送 / 都不点则任务结束后自动发出。
+          -->
+          @if (chat.heldMessage(); as held) {
+            <div class="held-bar" role="status" aria-live="polite">
+              <i class="fa-solid fa-hourglass-half held-icon" aria-hidden="true"></i>
+              <span class="held-text">
+                上一条还在执行,这条将等待发送
+                <em class="held-preview">{{ heldPreview(held.text) }}</em>
+              </span>
+              <span class="held-acts">
+                <ui-button variant="ghost" size="sm" (click)="chat.withdrawHeld()">撤回</ui-button>
+                <ui-button variant="primary" size="sm" (click)="chat.sendHeldNow()">
+                  打断发送
+                </ui-button>
+              </span>
+            </div>
+          }
+
           <app-composer
             [draft]="chat.draft()"
             [sending]="chat.sending()"
@@ -1774,6 +1795,36 @@ interface ConversationRow {
         padding: var(--sp-3) var(--sp-4) 0;
       }
 
+      /* 待发消息提示条(2026-11-13):输入框正上方 */
+      .held-bar {
+        display: flex;
+        align-items: center;
+        gap: var(--sp-2);
+        padding: var(--sp-2) var(--sp-4);
+        border-top: 1px solid var(--edge);
+        background: var(--warning-subtle);
+        font-size: var(--fs-13);
+      }
+      .held-icon {
+        color: var(--warning);
+        flex-shrink: 0;
+      }
+      .held-text {
+        flex: 1;
+        min-width: 0;
+        color: var(--ink-2);
+      }
+      .held-preview {
+        font-style: normal;
+        color: var(--muted);
+        font-size: var(--fs-12);
+      }
+      .held-acts {
+        display: flex;
+        gap: var(--sp-2);
+        flex-shrink: 0;
+      }
+
       /*
        * 消息流(2026-10-29 对照 ChatGPT demo 重做):
        *   · **居中限宽 768px** —— 原实现撑满整列,宽屏下长段落一行 1200px+,
@@ -3285,6 +3336,12 @@ export class DkChatPage implements OnDestroy {
     void this.matrixSession.kick();
   }
 
+  /** 待发消息预览文本(截断,避免长消息撑破提示条) */
+  protected heldPreview(text: string): string {
+    const t = text.replace(/\s+/g, ' ').trim();
+    return t.length > 32 ? `${t.slice(0, 32)}…` : t;
+  }
+
   retryOpen(): void {
     const id = this.chat.agentId();
     if (id) void this.chat.open(id, this.chat.sessionId() || undefined, true);

+ 75 - 0
src/app/mobile/pages/chat/chat.ts

@@ -260,6 +260,26 @@ type SheetTab = 'task' | 'file' | 'deliver' | 'state';
       </div>
 
       <div class="m-composer-area">
+        <!--
+          待发消息提示条(2026-11-13 用户设计):
+          本会话正在执行时,新消息先拦住不直接发(容器 interrupt 会打断当前任务)。
+          用户可选:撤回 / 打断发送;都不点则当前任务结束后自动发出。
+        -->
+        @if (chat.heldMessage(); as held) {
+          <div class="m-held" role="status" aria-live="polite">
+            <span class="m-held-icon" aria-hidden="true"><i class="fa-solid fa-hourglass-half"></i></span>
+            <span class="m-held-text">
+              上一条还在执行,这条将等待发送
+              <em class="m-held-preview">{{ heldPreview(held.text) }}</em>
+            </span>
+            <span class="m-held-acts">
+              <button type="button" class="m-held-btn" (click)="chat.withdrawHeld()">撤回</button>
+              <button type="button" class="m-held-btn m-held-btn--primary" (click)="chat.sendHeldNow()">
+                打断发送
+              </button>
+            </span>
+          </div>
+        }
         <!-- 待发附件:图片出缩略图(与 PC 端同构),其余出文件名 chip -->
         @if (chat.pendingFiles().length > 0) {
           <div class="m-pending" role="list" aria-label="待发送附件">
@@ -641,6 +661,55 @@ type SheetTab = 'task' | 'file' | 'deliver' | 'state';
         }
       }
       .m-composer-area {
+        /* 待发消息提示条(2026-11-13):输入框正上方 */
+        .m-held {
+          display: flex;
+          align-items: center;
+          gap: var(--sp-2);
+          margin-bottom: var(--sp-2);
+          padding: var(--sp-2) var(--sp-3);
+          border: 1px solid var(--edge);
+          border-radius: var(--r-md);
+          background: var(--warning-subtle);
+          font-size: var(--fs-13);
+        }
+        .m-held-icon {
+          color: var(--warning);
+          flex-shrink: 0;
+        }
+        .m-held-text {
+          flex: 1;
+          min-width: 0;
+          color: var(--ink-2);
+        }
+        .m-held-preview {
+          display: block;
+          font-style: normal;
+          color: var(--muted);
+          font-size: var(--fs-12);
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+        .m-held-acts {
+          display: flex;
+          gap: var(--sp-1);
+          flex-shrink: 0;
+        }
+        .m-held-btn {
+          min-height: 32px;
+          padding: 0 var(--sp-3);
+          border: 1px solid var(--edge);
+          border-radius: var(--r-sm);
+          background: var(--surface);
+          color: var(--ink-2);
+          font-size: var(--fs-12);
+        }
+        .m-held-btn--primary {
+          border-color: var(--primary);
+          background: var(--primary);
+          color: var(--primary-content);
+        }
         flex: none;
         padding-bottom: calc(var(--mob-safe-bottom) + var(--sp-3));
       }
@@ -910,6 +979,12 @@ export class MbChatPage implements OnDestroy {
    *    这里在会话页给出明确反馈。
    */
   protected readonly preparingRoom = signal(false);
+
+  /** 待发消息的预览文本(截断,避免长消息撑破提示条) */
+  protected heldPreview(text: string): string {
+    const t = text.replace(/\s+/g, ' ').trim();
+    return t.length > 24 ? `${t.slice(0, 24)}…` : t;
+  }
   /** 其余文件走全屏预览 */
   protected readonly preview = signal<{ url: string; name: string; mimeType: string } | null>(null);
   protected readonly sheetTab = signal<SheetTab>('task');