소스 검색

feat: 发送永不拦截(移除持有条)+ 部署脚本修复

用户裁决:容器结束返回后再发一条仍进「等待状态」,拦截带来的困扰
大于打断代价 —— 改为永远不拦、直接发。

- 移除 _heldMessage / heldMessage / withdrawHeld / discardHeld /
  sendHeldNow / forceSendText / sendTextInternal 的 force 参数
- 移除 sendTextInternal 里的忙判拦截分支
- 双端移除持有条 DOM、样式与 heldPreview()
- 保留 sessionBusy() 与 sendHint():「发送将打断当前任务」成为唯一告知渠道
- 测试:改写连点两条用例、新增「忙时发送不被拦截」、删除持有条自动清除块

部署脚本三处踩坑修复:
- obsutil sync 无 -f 参数(flag provided but not defined)
- PATH 中的 hcloud 是 Hetzner CLI,华为云 KooCLI 需完整路径
- KooCLI 无 profile 时静默跳过刷新 → 改为直传 AK/SK

验证:tsc ✅ / 1778 SUCCESS / build ✅ / 线上 main-2VGSQKOG.js 一致

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 13 시간 전
부모
커밋
59b709b910
6개의 변경된 파일과 127개의 추가작업 그리고 315개의 파일을 삭제
  1. 15 0
      docs/00-tracking/操作追踪.md
  2. 47 6
      scripts/deploy.mjs
  3. 40 60
      src/app/core/chat.service.spec.ts
  4. 18 111
      src/app/core/chat.service.ts
  5. 5 62
      src/app/desktop/pages/chat/chat.ts
  6. 2 76
      src/app/mobile/pages/chat/chat.ts

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

@@ -8146,3 +8146,18 @@ URL 不会重复显示:`chat.service.ts:2349` 的 `stripLinks` 会把已提取
 | **⑤⑥ 文件(容器侧)** | 容器自查根因:`strip_media` 把 `` 行整行删掉(878→829 字符 = 该行长度),文件没发出去。已下发任务书:只剥离重复 https、放行本地路径;每个文件发独立 `m.file` 附件 |
 | **验证** | `npx tsc --noEmit` ✅ / `npm test` **1772 SUCCESS** / `npm run build` ✅ |
 | **待验证** | ① 请求数是否真的降到个位数(需浏览器实测)② 上下重复是否消除 ③ 打断三按钮的实际手感 ④ 容器 strip_media 修复结果 |
+
+## 2026-11-14(续)· 移除持有条:发送永远不拦 + 部署脚本修复
+
+| 项 | 内容 |
+|---|---|
+| **触发** | 用户实测:「容器已经结束返回了,我再发一条,还是进等待状态」。上一版(`7c44250`)做的「空闲后自动撤回持有条」治标不治本 —— 根因是**拦截本身** |
+| **用户裁决** | **永远不拦,直接发**(AskUserQuestion 三选一明确选定)。代价知情:容器 `busy_input_mode=interrupt`,任务真在跑时发消息会打断它 |
+| **移除内容** | `_heldMessage` / `heldMessage` / `withdrawHeld()` / `discardHeld()` / `sendHeldNow()` / `forceSendText()` / constructor 的空闲自动撤回 effect / `sendTextInternal` 的 `force` 参数 / `sendTextInternal` 里的忙判拦截分支 |
+| **UI 移除** | 桌面 `chat.ts` 持有条 DOM + `.held-*` 样式 + `heldPreview()`;移动端 `m-held` 同上 |
+| **保留** | `sessionBusy()` 与 `sendHint()` —— 既不再拦截,「发送将打断当前任务」的悬停提示就成了唯一告知渠道;`_awaitingReplySince` 仍供状态判定用 |
+| **测试** | 删「持有条自动清除」2 条;改写「连点两次」为「两条都发出」;新增「本会话在跑时发送不被拦截」1 条 |
+| **部署脚本(3 处踩坑)** | ① `obsutil sync` **无 `-f` 参数** → 删除(`Error: flag provided but not defined: -f`)② PATH 里的 `hcloud` 是 **Hetzner Cloud CLI**(1.51.0),与华为云 KooCLI 同名不同物 → 改用完整路径 `E:\coding-environment\huaweicloud-koocli\hcloud.exe` ③ KooCLI 无 `default` profile 时报 `[USE_ERROR]不存在配置项` 后**静默跳过**,CDN 没刷但探活仍命中旧缓存显示"成功" → 改为直接传 `--cli-access-key/--cli-secret-key` |
+| **AK/SK 口径** | `~/.obsutilconfig` 里的 43 位 AK 是 OBS 专用的,**不是** KooCLI 那对 20 位。已从 `deploy.ps1` 取回正确的一对,按黄金规则 9 集中于 `scripts/deploy.mjs` 顶部 |
+| **验证** | `npx tsc --noEmit` ✅ / `npm test` **1778 SUCCESS** / `npm run build` ✅ / 部署探活 4 项全过 / 线上 `main-2VGSQKOG.js` 与本地一致 |
+| **未验证** | 真机手感(忙时发送是否确实直接发出、无持有条)—— 需用户在 App 里实测 |

+ 47 - 6
scripts/deploy.mjs

@@ -8,7 +8,9 @@
  *   node scripts/deploy.mjs --dry-run        # 只打印将要执行的动作,不执行
  *
  * 前置条件:
- *   ① 已安装 obsutil 与 hcloud CLI,且在 `hcloud configure list` 中已配置 profile
+ *   ① 已安装 obsutil 与**华为云 KooCLI**(注意:PATH 里的 `hcloud` 可能是 Hetzner 的同名 CLI,
+ *      直接用 `HCLOUD_BIN` 指定完整路径);AK/SK 已内置在本脚本顶部,无需提前 configure
+ *      (`obsutil` 自己的 `~/.obsutilconfig` 仍需配好,用于 OBS 上传)
  *   ② 构建配置 `npm run build` 可用(production)
  *
  * 🔴 与旧 deploy.ps1 的差异:
@@ -30,6 +32,18 @@ const CDN_BASE = 'https://ai.fmode.cn';
 const OBS_EP = 'obs.cn-south-1.myhuaweicloud.com';
 const CDN_REGION = 'cn-north-1';
 
+/*
+ * 华为云 AK/SK(2026-11-14)。
+ *
+ * 按 CLAUDE.md 黄金规则 9 集中存放于此(用户已裁决「Token 与密钥可明文存放」)。
+ * 来源:`deploy.ps1`(华为云控制台创建的那一对,20 位 AK)。
+ *
+ * ⚠️ **不要**从 `~/.obsutilconfig` 取:那里是 OBS 的 43 位 AK,
+ *    与本对不是同一套;KooCLI 的 `cli-access-key` 长度限制 20 位,取了会直接报错。
+ */
+const HW_AK = process.env.HW_AK || '2BFF7JWXAIJ0UGNJ0OSB';
+const HW_SK = process.env.HW_SK || 'NaPCiJCGmD3nklCzX65s8mSK1Py13ueyhgepa0s1';
+
 const args = new Set(process.argv.slice(2));
 const skipBuild = args.has('--skip-build');
 const dryRun = args.has('--dry-run');
@@ -114,16 +128,28 @@ console.log(`  ✓ index.html + ${files.length} 个文件`);
 console.log('\n=== [3/5] 同步到 OBS ===');
 /*
  * 先 sync 覆盖、再清多余文件:不用先清空桶,避免「清空后上传失败」导致站点空窗。
- * `-f` 跳过确认;**不传 `-delete`** —— 桶里可能还有非本站资源(assets/ 等),
+ * 🔴 `obsutil sync` **没有 `-f` 参数**(2026-11-14 踩坑:`Error: flag provided but not
+ *    defined: -f`)。同步默认不询问覆盖,需要时用 `-u`(增量上传已存在文件)。
+ * **不传 `-delete`** —— 桶里可能还有非本站资源(assets/ 等),
  * 本应删除的旧产物需人工确认,见 README「部署」一节。
  * OBS 兼容 S3 协议,AK/SK 由 obsutil 自身的配置(`obsutil config`)提供,不写在脚本里。
  */
-run('obsutil', ['sync', distDir, BUCKET, '-f', `-e=${OBS_EP}`]);
+run('obsutil', ['sync', distDir, BUCKET, `-e=${OBS_EP}`]);
 run('obsutil', ['chattri', BUCKET, '-r', '-f', `-e=${OBS_EP}`, '-acl=public-read']);
 
 // ── 4. 刷新 CDN ────────────────────────────────────────────────────────────
 console.log('\n=== [4/5] 刷新 CDN ===');
-const profile = process.env.HCLOUD_PROFILE || 'default';
+/*
+ * 🔴 必须用**华为云 KooCLI**的完整路径(2026-11-14 踩坑)。
+ *
+ * PATH 里的 `hcloud` 是 **Hetzner Cloud CLI**(`hcloud 1.51.0`),与华为云
+ * KooCLI 同名不同物。直接调 PATH 会报 `unknown flag: --cli-region`,
+ * 而这时候 OBS 上传已经成功了 —— 只是 CDN 没刷,用户拿到的还是旧缓存。
+ *
+ * 可用 `HCLOUD_BIN` 环境变量覆盖。
+ */
+const HCLOUD_BIN =
+  process.env.HCLOUD_BIN || 'E:\\coding-environment\\huaweicloud-koocli\\hcloud.exe';
 /*
  * 🔴 必须同时刷新「固定文件名的资源」(2026-11-13 实测踩坑)。
  *
@@ -142,14 +168,29 @@ const refreshUrls = [`${CDN_BASE}/`, `${CDN_BASE}/index.html`, ...FIXED_NAME_ASS
 const refreshArgs = [
   'CDN',
   'CreateRefreshTasks/v2',
+  /*
+   * 🔴 `--cli-region` 必须**内联在数组里**(2026-11-14)。
+   *    `run()` 用的是 `execFileSync`(无 shell),数组元素原样作为独立 argv 传入,
+   *    所以这里不能写成 shell 风格的 `--cli-region=xxx` 之外的形态,
+   *    也不能依赖 shell 的引号处理 —— 与 deploy.ps1 的写法保持等价。
+   */
   `--cli-region=${CDN_REGION}`,
+  `--refresh_task.type=file`,
   ...refreshUrls.map((u, i) => `--refresh_task.urls.${i + 1}=${u}`),
-  `--cli-profile=${profile}`,
+  /*
+   * 🔴 直接传 AK/SK,**不依赖 `--cli-profile`**(2026-11-14)。
+   *
+   * KooCLI 会先尝试读凭证文件;没配 profile 时报 `[USE_ERROR]不存在配置项 default`,
+   * 然后**静默跳过** —— CDN 没刷,但脚本继续探活,探活仍然打旧缓存后命中 200,
+   * 看起来"部署成功",用户实际拿到的还是老代码(隐蔽失败,代价极大)。
+   */
+  `--cli-access-key=${HW_AK}`,
+  `--cli-secret-key=${HW_SK}`,
 ];
 console.log('  刷新 URL:');
 for (const u of refreshUrls) console.log(`    ${u}`);
 // KooCLI 首次运行会交互式询问《隐私政策声明》,无人值守时会卡死并报 USE_ERROR
-run('hcloud', refreshArgs, { input: 'y\ny\ny\n' });
+run(HCLOUD_BIN, refreshArgs, { input: 'y\ny\ny\n' });
 
 // ── 5. 探活 ────────────────────────────────────────────────────────────────
 console.log('\n=== [5/5] 线上探活 ===');

+ 40 - 60
src/app/core/chat.service.spec.ts

@@ -733,10 +733,9 @@ describe('ChatService', () => {
     it('第二次发送带上一条消息的 event_id(m.in_reply_to 降级用)', async () => {
       await svc.sendText('第一条');
       /*
-       * 2026-11-14:连发会被判忙拦住(新行为),这里模拟容器已回、
-       * 第一轮真正结束,以便验证「第二次发送」的 event_id 逻辑本身。
+       * 2026-11-14:发送不再拦截(用户裁决「永远不拦」),
+       * 这里不必再模拟「第一轮结束」,直接发第二条即可。
        */
-      (svc as unknown as { _awaitingReplySince: { set(v: number): void } })._awaitingReplySince.set(0);
       mxStub.sendText.calls.reset();
       await svc.sendText('第二条');
       // 第一条是本地乐观 id(local-*),不是真实 event,故 lastEventId 应为 undefined
@@ -1651,12 +1650,13 @@ describe('ChatService · 新建会话期间不得回填历史(2026-11-08)',
       expect(mine[0].id).toBe('$e1');
     });
 
-    it('🔴 连点两次发送 → 第二条被拦截进待发区(2026-11-14 行为变更)', async () => {
+    it('🔴 连点两次发送 → 两条都真正发出(2026-11-14 起不再拦截)', async () => {
       /*
-       * 用户裁决:连点也和「立刻再发」一样拦住,行为保持一致。
+       * 用户裁决(2026-11-14):**永远不拦,直接发**。
        *
-       * 原期望「两条都发出」,但新逻辑下第一条发出后立刻判忙
-       * (`_awaitingReplySince` 填补真空期),第二条应进 heldMessage。
+       * 原行为是连点第二条进待发区(持有条),用户实测「容器已经结束返回了,
+       * 再发一条还是进等待状态」,认为拦截带来的困扰大于打断代价。
+       * 现在两条都必须真正发出。
        */
       await svc.open('agent-node-lj-test');
       mxStub.sendText.and.callFake(
@@ -1665,10 +1665,34 @@ describe('ChatService · 新建会话期间不得回填历史(2026-11-08)',
       await Promise.all([svc.sendText('第一条'), svc.sendText('第二条')]);
 
       const mine = svc.messages().filter((m) => m.sender === 'me');
-      expect(mine.length).withContext('只有第一条真正发出').toBe(1);
-      expect(svc.heldMessage()?.text)
-        .withContext('第二条应进待发区,等用户确认')
-        .toBe('第二条');
+      expect(mine.length).withContext('两条都要真正发出').toBe(2);
+    });
+
+    it('🔴 本会话在跑时发送不被拦截(2026-11-14 用户裁决)', async () => {
+      /*
+       * `open()` 必须在塞入「已发出未回复」标记**之前**完成:
+       * 它会复位该标记,放在后面会把前置条件冲掉。
+       */
+      await svc.open('agent-node-lj-test');
+      /*
+       * 制造「本会话正在跑」:`_awaitingReplySince` 非 0 时 `sessionBusy()` 为真。
+       * 这正是用户实测的场景 —— 上一轮的忙判还没消退,新消息被吞进持有条。
+       */
+      (svc as unknown as { _awaitingReplySince: { set(v: number): void } })._awaitingReplySince.set(
+        Date.now(),
+      );
+      expect(svc.sessionBusy()).withContext('前置:判忙').toBeTrue();
+
+      /* 本组的 sendText 桩是「挂了不 resolve」的,且首发要先 await 落库(见上一条用例),
+         所以必须显式放开、并给足微任务轮次,否则 await 卡死超时 */
+      const p = svc.sendText('打断它');
+      for (let k = 0; k < 5; k++) await Promise.resolve();
+      await new Promise((r) => setTimeout(r, 0));
+      releaseSend();
+      await p;
+
+      const mine = svc.messages().filter((m) => m.sender === 'me');
+      expect(mine.map((m) => m.text)).withContext('忙时发送也要直接发出').toContain('打断它');
     });
 
     it('失败消息在实时流到达后仍保留(可重试,AC-04.8)', async () => {
@@ -1733,67 +1757,23 @@ describe('ChatService · 新建会话期间不得回填历史(2026-11-08)',
       expect(svc.turnSettledLocally()).withContext('前置:上一轮已结束').toBeTrue();
 
       /*
-       * 不 await `sendText`:新逻辑下发出后会判忙,`sendText` 内部会走
-       * 等待/挂起路径,await 会一直挂着。这里只验证**发出的同步副作用**。
+       * 2026-11-14:发送不再拦截,「不 await 以免挂起」的顾虑已不成立,
+       * 但仍然只验证发出的同步副作用。
        */
       void svc.sendText('新的一句话');
       await Promise.resolve();
 
       expect(svc.turnEndedAfter())
-        .withContext('发出新消息后旧的结束信号必须清掉,否则会误判为空闲而不拦截')
+        .withContext('发出新消息后旧的结束信号必须清掉,否则会误判为空闲')
         .toBeNull();
       expect(svc.turnSettledLocally()).toBeFalse();
     });
   });
 
   /*
-   * 持有条在会话空闲后自动清除(2026-11-14 用户实测修复)。
-   *
-   * 用户截图:会话早已结束、成果都出来了,输入框上方仍挂着
-   * 「任务执行中 · 确认发送将中断当前任务」的持有条。
-   *
-   * 根因:「不点不发」改动移除了 `armHeldAutoSend()`,但没加替代的清理机制。
+   * 「持有条自动清除」用例已随持有条机制一并删除(2026-11-14 用户裁决「永远不拦」)。
+   * 相关验证见上方「本会话在跑时发送不被拦截」。
    */
-  describe('持有条自动清除', () => {
-    const awaiting = (): { set(v: number): void } =>
-      (svc as unknown as { _awaitingReplySince: { set(v: number): void } })._awaitingReplySince;
-    const held = (): { set(v: unknown): void } =>
-      (svc as unknown as { _heldMessage: { set(v: unknown): void } })._heldMessage;
-
-    it('🔴 会话空闲后,持有条自动撤回(回到草稿,不自动发出)', async () => {
-      (environment as { useMockData: boolean }).useMockData = false;
-      mxStub.timelineToMessages.and.returnValue([]);
-      apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
-      await svc.open('agent-node-lj-test');
-
-      awaiting().set(Date.now());
-      held().set({ text: '被拦住的那条', at: Date.now() });
-      expect(svc.sessionBusy()).withContext('前置:判忙').toBeTrue();
-
-      awaiting().set(0);
-      await new Promise((r) => setTimeout(r));
-
-      expect(svc.heldMessage()).withContext('空闲后持有条必须消失').toBeNull();
-      expect(svc.draft()).withContext('内容回到输入框,不自动发出').toBe('被拦住的那条');
-    });
-
-    it('草稿已有新内容时,旧持有内容直接丢弃不覆盖', async () => {
-      (environment as { useMockData: boolean }).useMockData = false;
-      mxStub.timelineToMessages.and.returnValue([]);
-      apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
-      await svc.open('agent-node-lj-test');
-
-      awaiting().set(Date.now());
-      held().set({ text: '旧的那条', at: Date.now() });
-      svc.setDraft('用户正在写的新内容');
-
-      awaiting().set(0);
-      await new Promise((r) => setTimeout(r));
-
-      expect(svc.heldMessage()).toBeNull();
-      expect(svc.draft()).withContext('不能覆盖用户正在写的内容').toBe('用户正在写的新内容');
-    });
-  });
 });
 
 /**

+ 18 - 111
src/app/core/chat.service.ts

@@ -424,53 +424,20 @@ export class ChatService {
     return resolveSessionPhase({ ...s, turnLive: false }).busy;
   });
 
-  /**
-   * 待发消息(2026-11-13 用户设计)。
-   *
-   * ## 为什么有它
-   *
-   * 容器配置 `busy_input_mode=interrupt` —— 新消息到达会**打断当前 turn**。
-   * 直接发出去的话,用户在长任务中途随手补一句,前面的活全白做。
+  /*
+   * 🔴 已移除「待发消息持有条」(2026-11-14 用户裁决)。
    *
-   * 但反过来用 `queue` 又不行:用户「改主意」时得干等几分钟。
+   * 原行为:本会话正在执行时,新消息先存为待发、由用户三选一
+   * (确认发送 / 撤回 / 删除),不点就不发。
    *
-   * ## 折中方案(用户裁决)
+   * 用户实测后裁决:**永远不拦,直接发**。
+   * 他说「容器已经结束返回了,再发一条还是进等待状态」——
+   * 拦截带来的困扰大于「打断当前任务」的代价,后者由 `sendHint()`
+   * 在发送按钮上提示即可。
    *
-   * 本会话正在执行时,新消息**不立即发出**,而是先存为待发:
-   *   · 输入框上方显示「上一条还在执行 · [撤回] [打断发送]」
-   *   · 点「打断发送」→ 立刻发出(容器 interrupt 生效)
-   *   · 点「撤回」→ 丢弃
-   *   · **都不点** → 当前任务结束后自动发出
+   * 代价(用户知情):容器 `busy_input_mode=interrupt`,
+   * 任务真在跑时发消息会打断它。
    */
-  private readonly _heldMessage = signal<{ text: string; at: number } | null>(null);
-  /** 待发消息(null = 没有)。UI 据此渲染提示条 */
-  readonly heldMessage = this._heldMessage.asReadonly();
-
-  /**
-   * 撤回:把待发内容**还回输入框**,用户可继续编辑(2026-11-13 用户裁决)。
-   *
-   * 与「删除」的区别:撤回是「先不发了,我再改改」,删除是「这条不要了」。
-   */
-  withdrawHeld(): void {
-    const held = this._heldMessage();
-    if (!held) return;
-    this._heldMessage.set(null);
-    /* 还回草稿,用户能接着改;若他已有新草稿则不覆盖(避免吞掉刚打的字) */
-    if (!this._draft().trim()) this._draft.set(held.text);
-  }
-
-  /** 删除:直接丢弃这条待发内容,不回填输入框(2026-11-13 用户裁决) */
-  discardHeld(): void {
-    this._heldMessage.set(null);
-  }
-
-  /** 确认发送:立即发出并打断当前任务(2026-11-13 用户裁决) */
-  async sendHeldNow(): Promise<void> {
-    const held = this._heldMessage();
-    if (!held) return;
-    this._heldMessage.set(null);
-    await this.forceSendText(held.text);
-  }
 
   readonly agent = computed<Agent | undefined>(() => {
     const id = this._agentId();
@@ -558,45 +525,7 @@ export class ChatService {
   }
 
   constructor() {
-    /*
-     * 🔴 已移除 `armHeldAutoSend()`(2026-11-13 用户裁决)。
-     *
-     * 原行为:忙碌时挂起的消息,等任务结束**自动发出**。
-     * 用户要求改成:**不点就不发** —— 由用户显式选择
-     * 【确认发送】/【撤回】/【删除】三选一。
-     *
-     * 理由:自动发会让用户失去对「是否打断」的控制权,
-     * 而且提示条上的「打断发送」按钮在自动发模式下语义混乱。
-     */
-    /*
-     * 🔴 会话转为空闲 → 自动清除持有条(2026-11-14 用户实测修复)。
-     *
-     * ## 问题
-     *
-     * 截图实证:会话早已结束、成果都出来了,输入框上方仍挂着
-     * 「任务执行中 · 确认发送将中断当前任务」的持有条。
-     *
-     * ## 根因
-     *
-     * 「不点不发」改动移除了 `armHeldAutoSend()`(空闲后自动发),
-     * **但没加替代的清理机制** —— `_heldMessage` 一旦写入就再没人清,
-     * 用户不点任何按钮它就永远挂着。
-     *
-     * ## 现在的行为
-     *
-     * 会话空闲时把持有条**自动撤回**(内容回到草稿),而不是自动发出。
-     * 这与用户「不点就不发」的裁决一致:不发,但要有个收尾。
-     *
-     * 若草稿已有内容(用户又打了别的),不覆盖它 —— 旧的持有内容直接丢弃,
-     * 因为用户显然已经在写新的了。
-     */
-    effect(() => {
-      const busy = this.sessionBusy();
-      const held = untracked(() => this._heldMessage());
-      if (busy || !held) return;
-      this._heldMessage.set(null);
-      if (!untracked(() => this._draft()).trim()) this._draft.set(held.text);
-    });
+    /* 持有条机制已整体移除(2026-11-14 用户裁决「永远不拦」),见上方说明 */
     /**
      * 🔴 连接状态跟随 SDK 的 sync 状态(FR-04 AC-04.9)。
      *
@@ -2081,23 +2010,18 @@ export class ChatService {
     }
   }
 
-  async sendText(text: string): Promise<void> {
-    await this.sendTextInternal(text, false);
-  }
-
   /**
-   * 强制发送(跳过「等待中」拦截)。
+   * 发送文字(**不做任何忙判拦截**)。
    *
-   * 供 `sendHeldNow()`(用户点「打断发送」)与 `armHeldAutoSend()`(自动发出)使用。
+   * 🔴 2026-11-14 用户裁决:本会话正在执行时也直接发。
+   *    容器 `busy_input_mode=interrupt` 会因此打断当前任务 ——
+   *    用户知情并接受,仅在发送按钮上由 `sendHint()` 提示。
    */
-  private async forceSendText(text: string): Promise<void> {
-    await this.sendTextInternal(text, true);
+  async sendText(text: string): Promise<void> {
+    await this.sendTextInternal(text);
   }
 
-  /**
-   * @param force 跳过「本会话正在执行 → 先拦住」的检查
-   */
-  private async sendTextInternal(text: string, force: boolean): Promise<void> {
+  private async sendTextInternal(text: string): Promise<void> {
     const body = text.trim();
     /*
      * 只有附件、没有文字也要能发 —— 用户选完图直接点发送是常见操作。
@@ -2126,23 +2050,6 @@ 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)。
      *

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

@@ -628,33 +628,7 @@ interface ConversationRow {
             </div>
           }
 
-          <!--
-            待发消息提示条(2026-11-13 用户设计):
-            本会话正在执行时,新消息先拦住(容器 interrupt 会打断当前任务)。
-            撤回 / 打断发送 / 都不点则任务结束后自动发出。
-          -->
-          @if (chat.heldMessage(); as held) {
-            <!--
-              待发消息持有条(2026-11-13 用户裁决重做)。
-
-              行为变更:**不点就不发**(原实现空闲后会自动发出)。
-              用户三选一:确认发送(打断当前任务)/ 撤回(还回输入框)/ 删除(丢弃)。
-            -->
-            <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.discardHeld()">删除</ui-button>
-                <ui-button variant="ghost" size="sm" (click)="chat.withdrawHeld()">撤回</ui-button>
-                <ui-button variant="primary" size="sm" (click)="chat.sendHeldNow()">
-                  确认发送
-                </ui-button>
-              </span>
-            </div>
-          }
+          <!-- 待发消息持有条已移除(2026-11-14 用户裁决「永远不拦,直接发」) -->
 
           <app-composer
             [draft]="chat.draft()"
@@ -1813,35 +1787,7 @@ 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-11-14 持有条机制下线) */
 
       /*
        * 消息流(2026-10-29 对照 ChatGPT demo 重做):
@@ -3464,18 +3410,15 @@ 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;
-  }
-
   /**
    * 发送按钮的悬停提示(2026-11-13)。
    *
    * 忙碌时明确告知「发送会打断当前任务」——这是实测确认的行为
    * (容器 busy_input_mode 配置为 interrupt,实测 0.7 秒内打断并响应)。
    * 空闲时返回空串,按钮用默认的「发送」。
+   *
+   * 🔴 2026-11-14 起这是**唯一**的打断告知渠道:发送不再被拦截,
+   *    用户按下即发出,容器会打断正在跑的那一轮。
    */
   readonly sendHint = computed(() =>
     this.chat.sessionBusy() ? '发送将打断当前任务,容器会立即开始处理这条' : '',

+ 2 - 76
src/app/mobile/pages/chat/chat.ts

@@ -261,28 +261,7 @@ type SheetTab = 'task' | 'file' | 'deliver' | 'state';
       </div>
 
       <div class="m-composer-area">
-        <!--
-          待发消息提示条(2026-11-13 用户设计):
-          本会话正在执行时,新消息先拦住不直接发(容器 interrupt 会打断当前任务)。
-          用户可选:撤回 / 打断发送;都不点则当前任务结束后自动发出。
-        -->
-        @if (chat.heldMessage(); as held) {
-          <!-- 待发持有条(2026-11-13 用户裁决):不点就不发,三选一 -->
-          <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.discardHeld()">删除</button>
-              <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>
-        }
+        <!-- 待发消息持有条已移除(2026-11-14 用户裁决「永远不拦,直接发」) -->
         <!-- 待发附件:图片出缩略图(与 PC 端同构),其余出文件名 chip -->
         @if (chat.pendingFiles().length > 0) {
           <div class="m-pending" role="list" aria-label="待发送附件">
@@ -665,55 +644,7 @@ 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);
-        }
+        /* 待发消息提示条样式已移除(2026-11-14 持有条机制下线) */
         flex: none;
         padding-bottom: calc(var(--mob-safe-bottom) + var(--sp-3));
       }
@@ -996,11 +927,6 @@ export class MbChatPage implements OnDestroy {
     this.chat.sessionBusy() ? '发送将打断当前任务' : '',
   );
 
-  /** 待发消息的预览文本(截断,避免长消息撑破提示条) */
-  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');