From bb100b16b3993e516e41be5bb3c80d398a34658a Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Mon, 15 Jun 2026 16:48:49 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20ESC=20=E5=85=B3=E9=97=AD=20local-jsx=20?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E5=90=8E=E6=B7=BB=E5=8A=A0=20grace-period=20?= =?UTF-8?q?=E9=98=B2=E6=AD=A2=E8=AF=AF=E8=A7=A6=20cancel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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 --- src/screens/REPL.tsx | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 47ffb2050..54dce8b6d 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -1136,6 +1136,18 @@ export function REPL({ const abortControllerRef = useRef(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.