fix: ESC 关闭 local-jsx 面板后添加 grace-period 防止误触 cancel

/workflows 等面板通过 ESC 关闭时,React unmount 与 chat:cancel
keybinding 的 isActive 解除之间存在竞态窗口,导致同一按 ESC
会穿透到 onCancel 并中止正在执行的 Workflow 工具。

添加 500ms grace-period guard:面板关闭时打时间戳,onCancel 在窗口
内吞掉 ESC 并 reset,后续有意 ESC 仍正常取消。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-15 16:48:49 +08:00
parent 0eabcccce9
commit bb100b16b3

View File

@@ -1136,6 +1136,18 @@ export function REPL({
const abortControllerRef = useRef<AbortController | null>(null);
abortControllerRef.current = abortController;
// Timestamp (ms) of the most recent local-jsx panel dismissal (e.g. ESC on
// /workflows). Used by onCancel's grace-period guard: the ESC that closes
// a local-jsx panel (or any quick follow-up ESC within the grace window)
// must not fall through to abortController.abort('user-cancel') — otherwise
// closing the /workflows panel via ESC would kill the in-flight Workflow
// tool. The chat:cancel keybinding's isActive gate (`!isLocalJSXCommand`)
// only shields the panel while it's mounted; once React commits the
// unmount, the next ESC reaches onCancel unguarded. This ref closes that
// race without touching keybinding registration order.
const LOCAL_JSX_CLOSE_CANCEL_GRACE_MS = 500;
const localJSXClosedAtRef = useRef(0);
// Track whether the last turn was user-aborted (Ctrl+C / Escape).
// When true, useGoalContinuation skips the continuation enqueue so
// interrupted turns don't spin into an unstoppable loop. Reset to
@@ -1355,6 +1367,9 @@ export function REPL({
if (args?.clearLocalJSX) {
localJSXCommandRef.current = null;
setToolJSXInternal(null);
// Stamp the dismissal so onCancel's grace-period guard can swallow
// the ESC that just dismissed the panel (and any quick follow-up).
localJSXClosedAtRef.current = Date.now();
return;
}
// Otherwise, keep the local JSX command visible - ignore tool updates
@@ -2534,6 +2549,24 @@ export function REPL({
return;
}
// Grace-period guard: if a local-jsx panel (e.g. /workflows) was just
// dismissed via ESC, swallow the same / immediately-following ESC so it
// doesn't fall through to abortController.abort('user-cancel') and kill
// the in-flight Workflow tool. Single-press ESC closes the panel
// (handled by the panel's own useInput → onDone → setToolJSX); the
// chat:cancel keybinding's isActive gate shields while the panel is
// mounted but not in the React commit window right after unmount.
// Reset the stamp so a later, deliberate ESC still cancels normally.
if (
localJSXClosedAtRef.current !== 0 &&
Date.now() - localJSXClosedAtRef.current < LOCAL_JSX_CLOSE_CANCEL_GRACE_MS
) {
localJSXClosedAtRef.current = 0;
logForDebugging('[onCancel] suppressed: local-jsx panel just dismissed');
return;
}
localJSXClosedAtRef.current = 0;
logForDebugging(`[onCancel] focusedInputDialog=${focusedInputDialog} streamMode=${streamMode}`);
// Pause proactive mode so the user gets control back.