Skip to content

feat(web-shell): friendlier Esc interruption + queued-prompt UX - #6025

Merged
wenshao merged 3 commits into
QwenLM:mainfrom
carffuca:feat/web-shell-esc-interrupt
Jun 30, 2026
Merged

feat(web-shell): friendlier Esc interruption + queued-prompt UX#6025
wenshao merged 3 commits into
QwenLM:mainfrom
carffuca:feat/web-shell-esc-interrupt

Conversation

@carffuca

@carffuca carffuca commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

While the web-shell is streaming a turn, pressing Esc now arms a two-press confirmation instead of cancelling immediately: the send button becomes a stop control with a countdown ring and a "Press Esc again to stop" hint, and a second Esc within the window confirms the cancel. When nothing is streaming and the composer has text, the same two-press gesture clears the input instead. A cancelled turn now leaves a clear "You cancelled this request" row in the transcript, so it is obvious why the turn ended. Finally, cancelling a turn that has messages queued behind it no longer strands them — once the cancelled turn settles, the next queued message is sent automatically, and the queue keeps flowing one turn at a time.

Why it's needed

Before this change a single Esc cancelled a streaming turn with no confirmation and no visual feedback, which made accidental interruptions easy and left the user unsure whether anything had happened. Queued messages were also abandoned when the active turn was cancelled, forcing the user to retype and resend them. Making interruption a deliberate, legible two-press gesture and keeping the queue draining after a cancel removes both of those rough edges. The change also fixes a pre-existing race in which a burst of synchronous re-renders could pop two queued prompts in the same tick and silently drop one.

Reviewer Test Plan

How to verify

Start a turn and let it stream, then press Esc once: the send button should turn into a stop control with a countdown ring and a "Press Esc again to stop" hint, and the turn should keep running. Press Esc a second time within the window and the turn cancels, leaving a "You cancelled this request" row in the transcript. Repeat the first Esc but wait out the window without a second press — the affordance should disarm on its own and the turn should continue uninterrupted.

With no turn streaming, type some text into the composer and press Esc twice: the first press shows the clear hint, the second clears the input.

Queue several messages while a turn is running, then cancel that turn: the next queued message should send automatically once the cancelled turn settles, with the remaining queue preserved and advancing one turn at a time. While a drained message is still pending, switch sessions or navigate away and confirm nothing fires into the wrong or torn-down session.

Evidence (Before & After)

Before
esc-old-1

After
esc-new-4
esc-new-2

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Local web-shell dev server (vite) against a running daemon (cli serve).

Risk & Scope

  • Main risk or tradeoff: the Esc handler and the queue auto-drain both touch timing-sensitive streaming state; the riskiest path — the drain timing gate — is covered by pure-function unit tests plus manual verification.
  • Not validated / out of scope: under a very slow daemon the turn-start gate's 2.5s safety-net timer can reopen before the turn registers as streaming. A proper fix needs an explicit turn-start signal from the daemon and is deferred.
  • Breaking changes / migration notes: none.

Linked Issues

中文说明

这个 PR 做了什么

在 web-shell 流式输出某个回合时,按 Esc 不再立即取消,而是变成两次确认:发送按钮会变成一个带倒计时环的停止控件,并显示"再按一次 Esc 停止"的提示,在窗口期内按下第二次 Esc 才确认取消。当没有任何回合在流式输出、而输入框里有文字时,同样的两次手势改为清空输入。被取消的回合现在会在对话记录里留下一行清晰的"你已取消请求",让人一眼看出回合为何结束。最后,取消一个后面还排着消息的回合不再会丢弃这些消息——被取消的回合结束后,下一条排队消息会自动发送,队列继续逐回合推进。

为什么需要

在此改动之前,单次 Esc 会在没有任何确认、也没有任何视觉反馈的情况下取消流式回合,这让误触很容易发生,用户也不确定到底发生了什么。排队消息还会在当前回合被取消时被丢弃,迫使用户重新输入并发送。把中断变成一个刻意、可读的两次手势,并在取消后让队列继续流转,消除了这两处粗糙体验。该改动同时修复了一个既有竞态:一连串同步重渲染可能在同一个时钟周期内弹出两条排队消息并悄悄丢掉其中一条。

评审验证计划

如何验证

开始一个回合并让它流式输出,然后按一次 Esc:发送按钮应变成带倒计时环的停止控件并显示"再按一次 Esc 停止"提示,回合应继续运行。在窗口期内按第二次 Esc,回合取消,对话记录里留下一行"你已取消请求"。重复第一次 Esc,但不按第二次、等待窗口期结束——提示应自行解除,回合不受影响继续。

在没有回合流式输出时,在输入框里输入一些文字并按两次 Esc:第一次显示清空提示,第二次清空输入。

在一个回合运行时排入若干消息,然后取消该回合:被取消的回合结束后,下一条排队消息应自动发送,其余队列保留并逐回合推进。当某条已出队消息仍在等待发送时,切换会话或离开页面,确认不会有消息被发送到错误的或已销毁的会话里。

证据(前后对比)

测试平台

系统 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

运行环境(可选)

本地 web-shell 开发服务器(vite),对接运行中的 daemon(cli serve)。

风险与范围

  • 主要风险或权衡:Esc 处理器与队列自动出队都涉及对时序敏感的流式状态;最高风险的路径——出队时序闸门——由纯函数单测加手工验证覆盖。
  • 未验证 / 范围外:在 daemon 非常慢时,回合起始闸门的 2.5s 安全网定时器可能在回合被识别为流式输出之前重新打开。彻底修复需要 daemon 提供显式的回合起始信号,本次延后处理。
  • 破坏性改动 / 迁移说明:无。

carffuca added 2 commits June 29, 2026 12:04
The auto-drain effect popped a queued prompt, called setQueuedPrompts,
then submitted via setTimeout(0). Because the daemon flips streamingState
asynchronously, the setState re-render could re-run the effect and pop a
second prompt before the first registered as streaming — both submitted
back-to-back and the first was lost.

Arm an "awaiting turn start" gate synchronously at pop so the re-run is
blocked until streamingState goes non-idle, released by a dedicated
effect with a safety-net timer for a prompt that never streams (e.g. a
queued slash command). Cleanup no longer cancels/re-queues the pending
submit while the gate is armed.
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

On direction: this is a solid UX improvement — accidental Esc cancellation of a streaming turn is a real pain point, and the two-press confirmation pattern is well-established (editors, terminals). The queue-drain fix (race condition dropping queued prompts) is a genuine correctness issue. CHANGELOG has cancellation-related work in CLI/core but no prior web-shell Esc handling — this fills a gap.

On approach: the scope feels right. Extracting decideEscapeIntent and canDrainQueue as pure functions with unit tests is the right call — the decision logic is testable without the full React tree. Moving QueuedPromptDisplay to its own component reduces App.tsx bloat. The countdown ring CSS is a bit elaborate (@property, conic-gradient, mask) but serves a clear purpose and includes prefers-reduced-motion. One question: the escLiveRef pattern (reading streamingState through a ref to avoid re-mounting the keydown listener) is correct but subtle — the comment explaining it is good, worth keeping.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

方向:这是一个扎实的 UX 改进——流式输出时误触 Esc 取消是真实痛点,两次确认模式在编辑器和终端中已有成熟先例。队列出队竞态修复也是真正的正确性问题。CHANGELOG 中有 CLI/core 的取消相关改动,但 web-shell 的 Esc 处理此前没有——本 PR 填补了这个空缺。

方案:范围合理。将 decideEscapeIntentcanDrainQueue 抽成纯函数加单测是正确做法——决策逻辑无需挂载完整 React 树即可测试。将 QueuedPromptDisplay 提取为独立组件减少了 App.tsx 的臃肿。倒计时环 CSS 稍复杂(@propertyconic-gradient、mask),但目的明确且包含了 prefers-reduced-motion。一个问题:escLiveRef 模式(通过 ref 读取 streamingState 以避免重新挂载 keydown 监听器)是正确的但较微妙——解释它的注释写得不错,建议保留。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

The implementation is well-structured. Three things stand out as good decisions:

  1. Pure function extractiondecideEscapeIntent and canDrainQueue are side-effect-free, making the timing-sensitive Esc and queue-drain logic testable without mounting the full app. The test coverage is thorough (17 tests for the two utilities alone, covering priority, re-arming, and edge cases).

  2. escLiveRef pattern — reading streamingState through a ref to avoid re-mounting the keydown listener on every sub-state flip is correct and well-commented. The alternative (including streamingState as an effect dep) would wipe the armed state mid-turn.

  3. Queue drain race fix — the awaitingTurnStart gate is the right fix for the double-pop race. The 2.5s safety-net timer with forced re-render for non-streaming prompts (e.g. slash commands) is a reasonable backstop.

No correctness bugs or security issues found. One minor observation:

  • The countdown ring CSS (@property --esc-countdown + conic-gradient + mask) is the most elaborate piece. Browser support is solid (Chrome 85+, Safari 15.4+, Firefox 128+) and prefers-reduced-motion is handled. No issue, just noting the complexity budget is spent well.

Test Results

All 564 tests pass across 40 test files — including the 24 new ones:

 ✓ utils/escapeIntent.test.ts (9 tests)
 ✓ utils/queueDrain.test.ts (8 tests)
 ✓ components/QueuedPromptDisplay.test.tsx (4 tests)
 ✓ components/messages/SystemMessage.test.tsx (3 tests)

 Test Files  40 passed (40)
      Tests  564 passed (564)

TypeScript check: clean, no errors.

Real-Scenario Testing

This PR targets the web-shell (browser app requiring vite dev server + daemon). The tmux-based CLI testing workflow doesn't apply here — Esc key interactions, countdown ring animations, and queue drain timing are browser-only behaviors that can't be meaningfully exercised from a terminal. The unit tests cover the decision logic; visual/interactive verification needs manual testing in the browser.

中文说明

代码审查

实现结构良好。三个值得肯定的设计决策:

  1. 纯函数提取decideEscapeIntentcanDrainQueue 无副作用,使得时序敏感的 Esc 和队列出队逻辑可以在不挂载完整应用的情况下测试。测试覆盖充分(两个工具函数共 17 个测试,覆盖了优先级、重新装备和边界情况)。

  2. escLiveRef 模式 — 通过 ref 读取 streamingState 以避免在每次子状态变化时重新挂载 keydown 监听器,做法正确且注释充分。替代方案(将 streamingState 作为 effect 依赖)会在回合中途清除已装备状态。

  3. 队列出队竞态修复awaitingTurnStart 闸门是修复双重弹出竞态的正确方案。2.5 秒安全网定时器配合强制重渲染(用于非流式提示如斜杠命令)是合理的兜底。

未发现正确性 bug 或安全问题。一个小观察:

  • 倒计时环 CSS(@property --esc-countdown + conic-gradient + mask)是最复杂的部分。浏览器兼容性良好(Chrome 85+、Safari 15.4+、Firefox 128+),且处理了 prefers-reduced-motion。没有问题,复杂度预算花得值。

测试结果

564 个测试全部通过,涵盖 40 个测试文件——包括 24 个新增测试:

 ✓ utils/escapeIntent.test.ts (9 tests)
 ✓ utils/queueDrain.test.ts (8 tests)
 ✓ components/QueuedPromptDisplay.test.tsx (4 tests)
 ✓ components/messages/SystemMessage.test.tsx (3 tests)

 Test Files  40 passed (40)
      Tests  564 passed (564)

TypeScript 检查:无错误。

真实场景测试

本 PR 针对 web-shell(需要 vite 开发服务器 + daemon 的浏览器应用)。基于 tmux 的 CLI 测试工作流不适用于此——Esc 键交互、倒计时环动画和队列出队时序是纯浏览器行为,无法在终端中有意义地验证。单测覆盖了决策逻辑;视觉/交互验证需要在浏览器中手动测试。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

This is a well-executed PR. The motivation is clear (accidental Esc cancels are a real annoyance), the implementation is clean, and the test coverage is solid.

Going back to my independent proposal: I would have kept the Esc logic and queue drain inline in App.tsx. This PR does better — extracting decideEscapeIntent and canDrainQueue as pure functions makes the timing-sensitive decision logic auditable and testable without the full React tree. The QueuedPromptDisplay extraction is a natural refactor that reduces App.tsx from an already large file. The queue drain race fix (awaitingTurnStart gate) is the right solution for a real bug.

The only thing I can't verify programmatically is the browser-side experience (countdown ring animation, two-press timing feel, transcript marker appearance) — that needs manual testing. The unit tests cover the decision logic thoroughly, and all 564 existing + new tests pass.

LGTM. Approving. ✅

中文说明

这是一个执行良好的 PR。动机清晰(误触 Esc 取消是真实的困扰),实现干净,测试覆盖充分。

回到我的独立方案:我会把 Esc 逻辑和队列出队保留在 App.tsx 内联。本 PR 做得更好——将 decideEscapeIntentcanDrainQueue 提取为纯函数,使时序敏感的决策逻辑可审计且无需完整 React 树即可测试。QueuedPromptDisplay 提取是自然的重组,减少了已经很大的 App.tsx。队列出队竞态修复(awaitingTurnStart 闸门)是针对真实 bug 的正确解决方案。

唯一无法程序化验证的是浏览器端体验(倒计时环动画、两次按键时序手感、取消标记外观)——这需要手动测试。单测充分覆盖了决策逻辑,全部 564 个新旧测试通过。

LGTM。批准。✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@carffuca
carffuca requested review from wenshao and ytahdn June 29, 2026 15:12
Comment thread packages/web-shell/client/components/QueuedPromptDisplay.tsx
Comment thread packages/web-shell/client/hooks/useComposerCore.ts
Comment thread packages/web-shell/client/components/StatusBar.tsx Outdated
Comment thread packages/web-shell/client/components/ChatEditor.tsx
Comment thread packages/web-shell/client/App.tsx Outdated
Comment thread packages/web-shell/client/App.tsx
Comment thread packages/web-shell/client/App.tsx Outdated
Comment thread packages/web-shell/client/App.tsx
Comment thread packages/web-shell/client/components/QueuedPromptDisplay.tsx Outdated
Comment thread packages/web-shell/client/components/ChatEditor.module.css Outdated
Comment thread packages/web-shell/client/App.tsx
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully. Qwen review aborted with an API error before posting comments. See workflow logs.

@wenshao

wenshao commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

✅ Maintainer local verification — built & exercised against a real browser

I built this PR head (e7b5bc8) in an isolated worktree (npm ci + full npm run build) and exercised it end-to-end. Everything passes — safe to merge from a verification standpoint. A few optional, non-blocking cleanup notes at the bottom.

How I verified

Check Result
New unit/component tests (escapeIntent 9, queueDrain 8, QueuedPromptDisplay 4, SystemMessage 3) 24/24 pass
Full web-shell vitest suite (40 files) 564/564 pass — no regressions
tsc --noEmit (whole client) clean
eslint (14 changed .ts/.tsx) clean
npm run build (incl. web-shell SPA) exit 0
Real-browser E2E (Playwright + real qwen serve --web daemon + a slow streaming fake-OpenAI endpoint) 25/25 checks pass
Mutation A/B (prove the tests are load-bearing) confirmed

Real-browser E2E (the headline UX, in an actual Chromium against the built SPA)

  • A — two-press cancel + marker. While a turn streams, the first Esc arms: the send button turns into an Esc control, aria-label flips to "Press Esc again to stop", and a polite aria-live region announces it — and the turn keeps running. The second Esc cancels, the button returns to send, and a "You cancelled this request" row appears in the transcript. ✔
  • B — disarm on timeout. Arm with one Esc, then wait out the window with no second press → the affordance disarms on its own and the turn continues uninterrupted (not cancelled). ✔
  • C — idle clear gesture. With no turn streaming and text in the composer, the first Esc shows "Press Esc again to clear" (input untouched); the second Esc clears the composer. ✔
  • D — queued-prompt auto-drain after cancel. Queue two messages behind a running turn (DOM shows 2 queued; neither is sent to the model yet). Cancel the running turn → the first queued message auto-drains to the model and the queue drops to 1. Cancel that turn → the second drains and the queue drops to 0. No stranded prompts; queue advances one turn at a time.

Evidence for D is taken from the DOM queue-count transitions (2 → 1 → 0, each step gated on a cancel + a fresh streaming turn) plus the fake model receiving each drained prompt as a new turn. Note the fake endpoint's request body accumulates prior context, so I relied on the DOM queue state (authoritative) for ordering rather than naive substring matching.

Mutation A/B (tests are load-bearing, not vacuous)

Mutation Effect
escapeIntent: arm cancelclear the 3 streaming-priority assertions fail; clear/blocked stay green
queueDrain: drop the !streaming guard "holds the queue when a turn is streaming" fails
SystemMessage: disable the prompt_cancelled short-circuit the 2 cancelled-marker assertions fail

Each mutation broke exactly its targeted assertions and nothing else; reverting → all green again.

Notes on the change (read-through)

  • prompt_cancelled is pre-existing daemon plumbing (acp-bridge already broadcasts it; transcriptToMessages already maps it). This PR only restyles the presentation (right-aligned marker + no timestamp wrapper), so the row genuinely appears on a real cancel — confirmed in scenario A.
  • The once-mounted keydown listener reading live state via escLiveRef, and the streamingState-boolean boundary reset, correctly keep a half-armed two-press alive across intra-turn sub-state flips (waiting/responding/thinking) — verified in scenario B (arm survived ~2.4s of streaming).

Optional / non-blocking cleanups

  1. Orphaned i18n key. queue.footer ("… · Esc to clear queue" / "… · Esc 清空队列") is no longer rendered. Removing the footer is correct (its text described the old Esc-clears-the-queue behavior), but the key is now dead in both EN and ZH — consider deleting it.
  2. CSS ↔ JS duration coupling. The countdown-ring animation is hard-coded to 2000ms and must stay in sync with ESC_CANCEL_CONFIRM_WINDOW_MS (they match today; the code comments flag it). A shared CSS var / constant would remove the footgun.
  3. The two deferred-scope items the PR calls out itself — the 2.5s turn-start safety-net for a queued local command, and cancelling a still-pending drained submit on session switch/unmount — look correct on read-through; I did not reproduce them in the black-box E2E (timing-dependent), which matches the PR's own scope note.
中文说明(点击展开)

✅ 维护者本地验证 —— 已构建并在真实浏览器中跑通

我在独立 worktree 里构建了该 PR 头(e7b5bc8npm ci + 完整 npm run build)并做了端到端验证。全部通过,从验证角度看可以合并。 末尾有几条可选、不阻塞合并的清理建议。

验证方式

检查项 结果
新增单元/组件测试(escapeIntent 9、queueDrain 8、QueuedPromptDisplay 4、SystemMessage 3) 24/24 通过
web-shell 完整 vitest 套件(40 个文件) 564/564 通过,无回归
tsc --noEmit(整个 client) 干净
eslint(14 个改动的 .ts/.tsx 干净
npm run build(含 web-shell SPA) exit 0
真实浏览器 E2E(Playwright + 真实 qwen serve --web daemon + 一个缓慢流式的伪 OpenAI 端点) 25/25 通过
变异 A/B 测试(证明测试真的承重) 确认

真实浏览器 E2E(核心交互,在真实 Chromium 中对构建出的 SPA 验证)

  • A —— 两次按 Esc 取消 + 标记。 回合流式输出时,第一次 Esc 武装:发送按钮变成 Esc 控件、aria-label 变为「Press Esc again to stop」、并由 aria-live 礼貌播报,且回合继续运行。第二次 Esc 取消,按钮恢复为发送,对话记录里出现一行 "You cancelled this request"(你已取消请求)。✔
  • B —— 超时自动解除。 按一次 Esc 武装后,不按第二次、等待窗口期结束 → 提示自行解除,回合不受影响继续运行(未被取消)。✔
  • C —— 空闲态清空手势。 没有回合在流式输出、输入框里有文字时,第一次 Esc 显示「Press Esc again to clear」(输入未动),第二次 Esc 清空输入框。✔
  • D —— 取消后排队消息自动出队。 在运行中的回合后面排入两条消息(DOM 显示排队 2 条,且都尚未发给模型)。取消运行中的回合 → 第一条排队消息自动出队发给模型,队列降为 1。再取消该回合 → 第二条出队,队列降为 0没有消息被遗弃;队列逐回合推进。

D 的证据取自 DOM 的队列计数变化(2 → 1 → 0,每一步都以一次取消 + 一个新流式回合为前提),外加伪模型确实把每条出队消息作为新回合收到。注意伪端点的请求体会累积此前上下文,所以排序我以 DOM 队列状态(权威来源)为准,而非简单的子串匹配。

变异 A/B 测试(测试是承重的,不是空过场)

变异 效果
escapeIntent:把武装的 cancel 改成 clear 3 条「流式优先」断言失败;clear/blocked 仍然绿
queueDrain:去掉 !streaming 这个条件 「回合流式输出时应保持队列」失败
SystemMessage:禁用 prompt_cancelled 短路分支 2 条取消标记断言失败

每个变异都恰好只破坏其针对的断言、其它不受影响;还原后全部恢复绿色。

对改动的通读说明

  • prompt_cancelled既有的 daemon 通路(acp-bridge 早已广播,transcriptToMessages 早已映射)。本 PR 只是改了它的呈现(右对齐标记 + 去掉时间戳包裹),所以真实取消时这一行确实会出现 —— 已在场景 A 确认。
  • keydown 监听器只挂一次、通过 escLiveRef 读实时状态,加上以 streamingState 布尔边界做重置,能在回合内子状态切换(waiting/responding/thinking)时正确保住一个「半武装」的两次手势 —— 已在场景 B 确认(武装态在约 2.4s 流式输出中存活)。

可选 / 不阻塞的清理建议

  1. 孤立的 i18n key。 queue.footer(「… · Esc 清空队列」)已不再被渲染。移除该底部提示是正确的(它描述的是旧的「Esc 清空队列」行为),但这个 key 现在在 ENZH 里都成了死代码 —— 建议删掉。
  2. CSS 与 JS 的时长耦合。 倒计时环动画写死为 2000ms,必须与 ESC_CANCEL_CONFIRM_WINDOW_MS 保持一致(目前一致,代码注释也提醒了)。用一个共享的 CSS 变量/常量可以消除这个隐患。
  3. PR 自己点出的两个延后范围项 —— 排队的「本地命令」对应的 2.5s 回合起始安全网,以及切换会话/卸载时取消仍待发的出队提交 —— 通读代码看是正确的;我没有在黑盒 E2E 里复现它们(时序相关),这与 PR 自己的范围说明一致。

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated code review — no high-confidence critical findings in the changed code at this commit.

Reviewed the two-press Esc confirm refactor, decideEscapeIntent() pure function, canDrainQueue() gate, and QueuedPromptDisplay extraction. The queue drain race is addressed by arming awaitingTurnStartRef before setState triggers a re-render. Streaming takes priority over clearing text in the Esc intent logic. No critical issues found.


Generated by Claude Code

wenshao
wenshao previously approved these changes Jun 30, 2026
Behavior-preserving cleanups addressing review feedback on the Esc-interruption
and queued-prompt changes:

- Remove the now-dead queue.footer i18n key (EN + ZH) and the unreferenced
  .queuedHint CSS, orphaned when the Esc-clears-queue behavior was dropped.
- Co-locate the queued-prompt styles in QueuedPromptDisplay.module.css instead
  of reaching into the parent App.module.css.
- Make the Esc confirm-window constants the single source of truth: export them
  from escapeIntent.ts and drive the countdown-ring duration from one of them
  via a CSS custom property.
- Nudge the queue-drain safety net with a dedicated tick counter instead of
  cloning queuedPrompts, so it no longer re-renders the composer for a no-op.
- Drop a redundant !compact guard in StatusBar left over from flattening a
  ternary.
- Document the pop/gate-arm ordering invariant in the drain effect.
@wenshao
wenshao added this pull request to the merge queue Jun 30, 2026
@carffuca
carffuca dismissed stale reviews from wenshao and qwen-code-ci-bot via f1d5d12 June 30, 2026 01:27
if (source === 'prompt_cancelled') {
return (
<div className={styles.cancelled} role="status">
<span>{t('turn.stopped')}</span>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Every prompt_cancelled block now renders as You cancelled this request, but the bridge publishes the same event for peer/client cancellations and for reason: 'forward_failed'. In those paths this UI tells the current user they cancelled work they did not cancel, hiding transport failures or another client's stop. Since content is already the neutral localized cancellation text from transcriptBlocksToDaemonMessages, render that unless originator/reason is preserved and checked.

Suggested change
<span>{t('turn.stopped')}</span>
<span>{content}</span>

— GPT-5 via Qwen Code /review

// While the gate is armed the re-run is already blocked, so let the
// pending submit fire — don't cancel it or re-queue. Only when unarmed
// (a genuine dependency change before submit) restore the prompt.
if (!awaitingTurnStartRef.current) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] While awaitingTurnStartRef.current is true, this cleanup intentionally skips clearing the pending setTimeout(0). That handles the benign rerender after popNextQueuedPrompt(), but it also lets the stale submit fire after real invalidations such as pendingApproval, interactionBlocked, or connected changing before the timeout executes. Those cases used to cancel/requeue the popped prompt; now the prompt can still call handleSubmit under a blocked/offline state and be lost. Please distinguish the benign queue-state rerender from real dependency invalidations, or revalidate against live refs before submitting and requeue on failure.

— GPT-5 via Qwen Code /review

Merged via the queue into QwenLM:main with commit fce79dd Jun 30, 2026
6 checks passed
}, TURN_START_GATE_SAFETY_MS);

drainingQueueRef.current = true;
let sent = false;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Silent prompt loss when handleSubmit throws during auto-drain.

The drain's setTimeout(0) callback wraps handleSubmit in try/finally with no catch. When the awaitingTurnStart gate is armed, the cleanup deliberately skips re-queuing — so if handleSubmit throws (e.g., connection drops between the canDrainQueue check and the submit), the prompt has already been popped via popNextQueuedPrompt() but is neither submitted nor re-queued. It vanishes silently with no error toast and no recovery path.

Before this PR, the cleanup always re-queued unsent prompts unconditionally. The new gate-armed path breaks that safety net.

Suggested change
let sent = false;
const timer = setTimeout(() => {
drainSubmitTimerRef.current = null;
sent = true;
try {
handleSubmit(nextPrompt.text, nextPrompt.images);
nextPrompt.onComplete?.();
} catch (err) {
queuedPromptsRef.current = [nextPrompt, ...queuedPromptsRef.current];
setQueuedPrompts(queuedPromptsRef.current);
} finally {
drainingQueueRef.current = false;
}
}, 0);

— qwen3.7-max via Qwen Code /review

queuedPromptsRef.current = [];
setQueuedPrompts([]);
drainingQueueRef.current = false;
awaitingTurnStartRef.current = false;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Session switch does not reset Esc armed state.

This effect clears queue, drain, and timer state on session switch — but does not call resetEscapeState(). If the user arms cancel (first Esc) on session A while it's streaming, then switches to session B which is also streaming, the escStreamingBoundary boolean stays true and the boundary effect doesn't re-fire. The armed state (cancelArmed, escArmedActionRef, escapeTimerRef) persists into the new session for up to 2 seconds.

During that window, a single Esc in session B hits decideEscapeIntent with armed: 'cancel' and returns { kind: 'cancel' } — bypassing the two-press confirmation that this PR specifically introduces to prevent accidental cancellation.

Suggested change
awaitingTurnStartRef.current = false;
awaitingTurnStartRef.current = false;
if (awaitingTurnStartTimerRef.current) {
clearTimeout(awaitingTurnStartTimerRef.current);
awaitingTurnStartTimerRef.current = null;
}
resetEscapeState();

Adding resetEscapeState here (and to the dep array — it's a stable useCallback with [] deps) ensures the two-press safety is never silently downgraded across sessions.

— qwen3.7-max via Qwen Code /review

if (e.defaultPrevented || e.isComposing) return;
const live = escLiveRef.current;

if (e.key !== 'Escape') {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Modifier-only key presses (Shift, Ctrl, Alt, Meta) disarm the two-press confirm.

Any keydown where e.key !== 'Escape' triggers resetEscapeState(). Modifier-only presses have e.key values like 'Shift', 'Control', 'Alt', 'Meta' — all of which enter this branch and disarm the pending cancel/clear. A user who presses Shift while reaching for the second Esc loses the armed state and must start over.

Suggested change
if (e.key !== 'Escape') {
if (e.key !== 'Escape') {
if (escArmedActionRef.current !== null && e.key.length === 1) {
resetEscapeState();
}

Filtering on e.key.length === 1 (or checking !['Shift', 'Control', 'Alt', 'Meta'].includes(e.key)) lets the user shift their grip between the two presses without losing the armed state.

— qwen3.7-max via Qwen Code /review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants