Repository navigation
[Bug] 模型设置:自定义供应商「重命名」输入框闪退(Radix 菜单退场动画 pointermove 抢占焦点) #870
Copy link
Copy link
Open
Labels
priority: P2普通优先级普通优先级
Description
Activity
👋 感谢你的反馈,我们已经收到。
- 维护者看到后会尽快回复你。
- 状态保持为
status: 待评估,你可以随时补充信息。 - 信息不全时我们会打上
needs: 更多信息标签并 @ 你。
👋 Thanks — we've received your issue.
- A maintainer will get back to you as soon as we can.
- Status stays at
status: 待评估(Triage); feel free to add context. - If we need more details, we'll add
needs: 更多信息and ping you.
补充查重结果:本 issue 与 #758(@zakimimit 于 2026-09-21 提交,更早)报告的是同一问题——「重命名输入框需要多次点击才能出现/闪退」。#758 下方已补充根因摘要并指向本 issue 的完整分析(事件级 trace + 安装版 bundle 核对 + 两个修复方案),供维护者合并参考;两个 issue 暂时都保持 open,合并/关闭方式交给维护者裁量。
本地临时修复工具包 · Ready-made local patch toolkit
按正文「本地临时修复」方式 1 做好的幂等补丁脚本,三个文件放到同一目录:
使用方法
- 完全退出 ZCode(托盘图标也退出;注意会中断其中正在运行的 Agent 会话)。
- 双击
应用补丁-请先关闭ZCode.cmd(需要已安装 Node.js),或在命令行运行node patch-app-asar.mjs。 - 启动 ZCode 验证:模型设置 →
...→ 重命名,鼠标划过应能稳定保持输入框编辑态。 - 如需还原:退出 ZCode 后运行
还原.cmd。 - 客户端自动更新会覆盖
app.asar,更新后重新运行步骤 1–2 即可(脚本每次打补丁前都会把备份刷新为当前版本的原始文件,不会用旧备份还原出降级版本)。
行为说明:脚本解析 asar 头定位
out/renderer/index.html,把开头两条启动 CSS 改写为等价压缩写法,在腾出的空间内等长注入修复规则[data-radix-menu-content][data-state="closed"]{pointer-events:none!important}——不改变文件长度、无需重打包 asar;写入前自动备份为app.asar.bak-rename-flash;已打过补丁时幂等退出;客户端版本变化导致目标 CSS 块找不到时直接中止,不会写坏文件。默认路径为D:/Program Files/ZCode/resources/app.asar,其它安装位置可用环境变量ZCODE_ASAR_PATH覆盖(还原.cmd中的路径需同步手改)。已在完整安装副本上验证:补丁后可正常启动,6/6 重命名保持编辑态;本机实测补丁可在 ZCode 运行中原位写入(等长替换不触发文件锁),重启客户端后生效。
应用补丁-请先关闭ZCode.cmd
@echo off rem ZCode rename-flash fix - apply patch. ASCII only + CRLF so cmd.exe parses it safely. chcp 65001 >nul echo ============================================ echo ZCode rename-flash fix - apply patch echo ============================================ echo. tasklist /FI "IMAGENAME eq ZCode.exe" 2>nul | find /I "ZCode.exe" >nul if %errorlevel%==0 ( echo [!] ZCode is still running. Please QUIT ZCode completely, including tray icon, first. echo Note: quitting ZCode interrupts Agent sessions running inside it. pause exit /b 1 ) where node >nul 2>nul if %errorlevel% neq 0 ( echo [!] Node.js not found in PATH. Please install Node.js first. pause exit /b 1 ) node "%~dp0patch-app-asar.mjs" echo. pause
patch-app-asar.mjs
/** * ZCode「重命名输入框闪退」本地修复补丁 * * 原理:向 resources/app.asar 内 out/renderer/index.html 的既有 <style> 块 * 注入一行 CSS:[data-radix-menu-content][data-state="closed"]{pointer-events:none!important} * 使 Radix 菜单退场动画期间不再响应 pointermove,避免焦点被菜单项抢走。 * * 采用「等长字节替换」:把 index.html 中前两条启动 CSS 规则改写为等价压缩写法, * 腾出的空间原位写入修复规则。不改变文件长度,不需要重打包 asar。 * * 用法:先完全退出 ZCode,然后运行 node patch-app-asar.mjs * 幂等:已打过补丁时会直接提示并退出。还原:运行 还原.cmd 或手动把 * app.asar.bak-rename-flash 改回 app.asar。 */ import { readFileSync, writeFileSync, copyFileSync, openSync, readSync, writeSync, closeSync, statSync } from 'node:fs'; const ASAR = process.env.ZCODE_ASAR_PATH || 'D:/Program Files/ZCode/resources/app.asar'; const BACKUP = ASAR + '.bak-rename-flash'; const FIX_RULE = '[data-radix-menu-content][data-state="closed"]{pointer-events:none!important}'; // --- locate index.html via asar header --- // asar 布局:[u32 picklePayloadSize][u32 headerSize][header pickle][padding][文件数据] // header pickle:[u32 payloadSize][u32 jsonLen][json...] function locateIndexHtml(asarPath) { const fd = openSync(asarPath, 'r'); const sizeBuf = Buffer.alloc(8); readSync(fd, sizeBuf, 0, 8, 0); const headerSize = sizeBuf.readUInt32LE(4); const headerBuf = Buffer.alloc(headerSize); readSync(fd, headerBuf, 0, headerSize, 8); closeSync(fd); const jsonLen = headerBuf.readUInt32LE(4); const header = JSON.parse(headerBuf.toString('utf8', 8, 8 + jsonLen)); const node = header.files.out.files.renderer.files['index.html']; if (!node || node.size === undefined || node.offset === undefined) { throw new Error('asar 头中未找到 out/renderer/index.html,客户端版本可能已变化'); } return { absOffset: 8 + headerSize + Number(node.offset), size: node.size }; } console.log('读取', ASAR); const { absOffset, size } = locateIndexHtml(ASAR); const fd = openSync(ASAR, 'r'); const buf = Buffer.alloc(size); readSync(fd, buf, 0, size, absOffset); closeSync(fd); const html = buf.toString('utf8'); if (!html.includes('<title>ZCode</title>')) throw new Error('index.html 内容与预期不符,中止'); if (html.includes(FIX_RULE)) { console.log('已打过补丁,无需重复操作。'); process.exit(0); } // --- 等长替换目标:启动 CSS 的前两条规则(含中间空行),共 191 字节 --- const TARGET = ' html,\n' + ' body,\n' + ' #root {\n' + ' margin: 0;\n' + ' width: 100%;\n' + ' height: 100%;\n' + ' }\n' + '\n' + ' #root {\n' + ' opacity: 0;\n' + ' transition: opacity 0.16s ease;\n' + ' }\n'; if (!html.includes(TARGET)) throw new Error('未找到目标 CSS 块,客户端版本可能已更新,需要人工核对'); const ruleA = 'html,body,#root{margin:0;width:100%;height:100%}'; const ruleB = '#root{opacity:0;transition:opacity .16s ease}'; let repl = ruleA + ruleB + FIX_RULE; if (repl.length > TARGET.length) throw new Error('替换内容超长,中止'); repl = repl + ' '.repeat(TARGET.length - repl.length); const patched = html.replace(TARGET, repl); if (patched.length !== html.length) throw new Error('长度校验失败,中止'); const patchedBuf = Buffer.from(patched, 'utf8'); if (patchedBuf.length !== buf.length) throw new Error('字节长度校验失败,中止'); // --- 备份 + 原位写回 --- try { // 每次实际打补丁前都刷新备份:客户端更新覆盖 app.asar 后重打时, // 旧备份是上一版本的文件,还原它会降级;备份必须始终等于"打补丁前一刻"的状态。 copyFileSync(ASAR, BACKUP); console.log('已备份原文件 →', BACKUP); const out = openSync(ASAR, 'r+'); writeSync(out, patchedBuf, 0, patchedBuf.length, absOffset); closeSync(out); } catch (err) { if (String(err.message).includes('busy') || String(err.code) === 'EBUSY' || String(err.code) === 'EPERM') { console.error('[!] app.asar 被占用:请先完全退出 ZCode(包括托盘)再运行本脚本。'); } else { console.error('[!] 写入失败:', err.message); } process.exit(1); } console.log('补丁写入完成:', ASAR, '(文件总大小', statSync(ASAR).size, '字节,未改变)'); console.log('现在可以启动 ZCode 验证:模型设置 → ... → 重命名 应能稳定保持输入框。'); console.log('如需还原:先退出 ZCode,再运行 restore.cmd。');
还原.cmd
@echo off rem ZCode rename-flash fix - restore original app.asar from backup echo ============================================ echo ZCode rename-flash fix - restore echo ============================================ echo. tasklist /FI "IMAGENAME eq ZCode.exe" 2>nul | find /I "ZCode.exe" >nul if %errorlevel%==0 ( echo [!] ZCode is still running. Please QUIT ZCode completely, including tray icon, first. pause exit /b 1 ) if not exist "D:Program FilesZCode esourcesapp.asar.bak-rename-flash" ( echo [!] Backup file app.asar.bak-rename-flash not found. Patch not applied? pause exit /b 1 ) copy /Y "D:Program FilesZCode esourcesapp.asar.bak-rename-flash" "D:Program FilesZCode esourcesapp.asar" >nul if %errorlevel%==0 ( del "D:Program FilesZCode esourcesapp.asar.bak-rename-flash" echo [OK] Original app.asar restored, backup removed. ) else ( echo [!] Restore failed. Rename app.asar.bak-rename-flash to app.asar manually. ) pause
Metadata
Metadata
Assignees
Labels
priority: P2普通优先级普通优先级
提交前确认 · Pre-submission checklist
问题类别 · Category
模型设置 / 切换 · Model config
涉及的 Agent 框架 · Agent framework
ZCode Agent(自研)
严重程度 · Severity
影响体验 · Major(功能可用但体验受损)
复现频率 · Reproducibility
偶现 · Sometimes(真人鼠标操作高频触发;自动化秒点不触发,详见下文)
问题描述 · Description
模型设置页中,自定义供应商的「重命名」入口(
...菜单 → 重命名)点击后名称会变为输入框,但随即又闪回不可编辑状态,需要连续重试多次才能成功进入编辑。时好时坏,非常影响自定义供应商的日常维护。经过对 v3.14.4 渲染层 bundle 的事件级实测,本 bug 与「是否先填写过 Base URL 等其他字段」无关(初步怀疑的保存竞态已被排除),真实根因是 Radix DropdownMenu 退场动画期间的 pointermove 焦点抢占,与供应商卡片自身的 blur-提交逻辑相互叠加。
复现步骤 · Steps to reproduce
...按钮打开菜单关键对照实验(同一环境实测):
Input.dispatchMouseEvent模拟「点完原地不动」:12/12 次全部正常(输入框保持)即:触发条件不是字段保存,而是「点击重命名后鼠标是否再次扫过还在退场动画中的菜单」。
期望表现 · Expected behavior
点击「重命名」后,名称输入框应稳定进入编辑态并持有焦点,直到用户按 Enter/Esc 或点击其它区域。
实际表现 · Actual behavior
输入框短暂出现(约 10~300ms)后失去焦点并退回文本状态。事件时间线(实测,CDP 注入
HTMLElement.prototype.focus调用栈探针):★ 处
focus()的调用栈指向 Radix Menu 的 RovingFocusGroup 指针移动处理:根因定位 · Root cause
完整链路(
packages/ui/src/settings/model-provider-section/):ProviderCardSections.tsx的ProviderCardHeader:菜单项onSelect→renameRequestedRef.current = true; onStartEditName(),并已通过onCloseAutoFocus阻止 Radix 把焦点还给触发按钮(对已知的「closeAutoFocus 抢焦点」做了防护)。InlineEditableProviderCard.tsx的handleStartEditName:setEditingName(true)+requestAnimationFrame(() => nameInputRef.current?.focus())。data-state="closed"),且仍响应 pointermove:@radix-ui/react-dropdown-menu@2.1.16(依赖react-focus-scope@1.1.7,见 pnpm-lock.yaml)的 MenuItem 会在onPointerMove时对菜单项执行focus()(RovingFocusGroup 高亮逻辑)。focus()→ 刚拿到焦点的名称输入框被 blur。InlineEditableProviderCard.tsx的handleNameBlur是「blur 即提交」语义:setEditingName(false)→ 输入框卸载 → 用户看到「闪回」。setTimeout(0)里派发的closeAutoFocus事件虽然被renameRequestedRef守卫拦下,但为时已晚——焦点早已被第 4 步抢走。现有代码只防护了
onCloseAutoFocus这一条焦点回收路径,没有覆盖退场动画窗口内的 pointermove 路径。自动化脚本点击后指针原地不动,所以一次都不触发;真人操作则高频触发,表现为「时好时坏」。修复方案建议 · Proposed fix
方案 A(推荐,一行 CSS,已实测验证):退场中的菜单不响应指针事件。加到全局样式即可:
验证结果:注入该规则后,用之前 8/8 必现闪回的同一真人化鼠标脚本重测,8/8 保持编辑态;打进安装包(app.asar 内 index.html 内联样式)后再测 6/6 保持,启动渲染均无异常。退场中的菜单本来就不应再接收交互,该规则无副作用。
方案 B(逻辑层兜底,可选与 A 叠加):
handleStartEditName记录编辑开始时间戳;handleNameBlur中若 blur 发生在开始后的短窗口内(如 300ms)且relatedTarget位于[role=menu]/[data-radix-menu-content]内,则不视为提交,改为 rAF 重新聚焦输入框。本地临时修复(已验证) · Local workaround
在官方修复发布前,本机可用两种方式临时修复:
resources/app.asar内的out/renderer/index.html既有<style>块(等长字节替换,无需重打包 asar)。已在完整副本上验证可正常启动并修复问题。注意:客户端自动更新会覆盖 app.asar,更新后需重打。现成的一键脚本(应用/还原 .cmd + 幂等补丁 .mjs)已附在评论区。--remote-debugging-port的方式启动客户端,通过 CDPPage.addScriptToEvaluateOnNewDocument向每个新文档注入同一条 CSS。不改任何文件、不怕更新覆盖,但需要替换启动方式且本地开放调试端口。版本 · Version
v3.14.4(Windows x64,Electron 41.0.3 / Chrome 146)
设备 / 系统 · Device / OS
Windows 11 Pro 23H2 (10.0.22631) x64,桌面客户端
截图 / 录屏 / 日志 · Evidence
关键证据为上文事件时间线与
focus()调用栈(通过 CDP 对HTMLElement.prototype.focus打桩捕获)。如需补充录屏或完整事件日志可以随时提供。