Skip to content

[Bug] 模型设置:自定义供应商「重命名」输入框闪退(Radix 菜单退场动画 pointermove 抢占焦点) #870

Description

@jzcangshu

提交前确认 · Pre-submission checklist

  • 我已搜索过现有 issue,确认这不是重复
  • 我已阅读 CONTRIBUTING.md

问题类别 · 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

  1. 设置 → 模型设置 → 选中任意自定义供应商(或新建一个)
  2. 点击卡片右上角 ... 按钮打开菜单
  3. 用鼠标点击「重命名」
  4. 点击后手不要停,鼠标自然移动一小段(真人操作几乎必然发生,哪怕 1~2px 抖动)
  5. 观察名称输入框:出现后立刻消失,闪回纯文本状态

关键对照实验(同一环境实测):

  • 新建供应商后立刻重命名 vs 填写完字段后再重命名:两者都能复现,字段填写不是必要条件
  • 用 CDP Input.dispatchMouseEvent 模拟「点完原地不动」:12/12 次全部正常(输入框保持)
  • 模拟真人鼠标(分步 mousemove 悬停菜单项 + mousedown 按住 ~110ms + 点击后鼠标继续漂移):8/8 次全部闪回

即:触发条件不是字段保存,而是「点击重命名后鼠标是否再次扫过还在退场动画中的菜单」。

期望表现 · Expected behavior

点击「重命名」后,名称输入框应稳定进入编辑态并持有焦点,直到用户按 Enter/Esc 或点击其它区域。

实际表现 · Actual behavior

输入框短暂出现(约 10~300ms)后失去焦点并退回文本状态。事件时间线(实测,CDP 注入 HTMLElement.prototype.focus 调用栈探针):

894ms  NAME_INPUT_MOUNTED                     ← setEditingName(true),输入框挂载
896ms  FOCUS_CALL→input#model-provider-name-input   ← rAF 聚焦,来自 handleStartEditName
896ms  focusin input#model-provider-name-input
900ms  FOCUS_CALL→div#model-provider-name-edit-button ← ★焦点被菜单项抢走
900ms  focusout input#model-provider-name-input       ← 输入框 blur
900ms  focusin div#model-provider-name-edit-button
904ms  NAME_INPUT_REMOVED                     ← handleNameBlur 视为确认失焦,编辑态关闭

★ 处 focus() 的调用栈指向 Radix Menu 的 RovingFocusGroup 指针移动处理:

at styles-Qlp0Bew7.js:254:15680   (MenuItem onPointerMove → focus)
at styles-Qlp0Bew7.js:254:21466   (composeEventHandlers)

根因定位 · Root cause

完整链路(packages/ui/src/settings/model-provider-section/):

  1. ProviderCardSections.tsx 的 ProviderCardHeader:菜单项 onSelect → renameRequestedRef.current = true; onStartEditName(),并已通过 onCloseAutoFocus 阻止 Radix 把焦点还给触发按钮(对已知的「closeAutoFocus 抢焦点」做了防护)。
  2. InlineEditableProviderCard.tsx 的 handleStartEditName:setEditingName(true) + requestAnimationFrame(() => nameInputRef.current?.focus())。
  3. Radix DropdownMenu 关闭时内容并非立刻卸载——Presence 退场动画期间 DOM 仍挂载(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 高亮逻辑)。
  4. 真人点击后鼠标几乎必然再动一下,pointermove 落在退场中的菜单项上 → 菜单项被重新 focus() → 刚拿到焦点的名称输入框被 blur。
  5. InlineEditableProviderCard.tsx 的 handleNameBlur 是「blur 即提交」语义:setEditingName(false) → 输入框卸载 → 用户看到「闪回」。
  6. 之后 FocusScope unmount 的 setTimeout(0) 里派发的 closeAutoFocus 事件虽然被 renameRequestedRef 守卫拦下,但为时已晚——焦点早已被第 4 步抢走。

现有代码只防护了 onCloseAutoFocus 这一条焦点回收路径,没有覆盖退场动画窗口内的 pointermove 路径。自动化脚本点击后指针原地不动,所以一次都不触发;真人操作则高频触发,表现为「时好时坏」。

修复方案建议 · Proposed fix

方案 A(推荐,一行 CSS,已实测验证):退场中的菜单不响应指针事件。加到全局样式即可:

[data-radix-menu-content][data-state="closed"] {
  pointer-events: none !important;
}

验证结果:注入该规则后,用之前 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

在官方修复发布前,本机可用两种方式临时修复:

  1. 补丁安装包:把上述一行 CSS 注入 resources/app.asar 内的 out/renderer/index.html 既有 <style> 块(等长字节替换,无需重打包 asar)。已在完整副本上验证可正常启动并修复问题。注意:客户端自动更新会覆盖 app.asar,更新后需重打。现成的一键脚本(应用/还原 .cmd + 幂等补丁 .mjs)已附在评论区。
  2. 启动时注入:用带 --remote-debugging-port 的方式启动客户端,通过 CDP Page.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 打桩捕获)。如需补充录屏或完整事件日志可以随时提供。

Activity

  1. github-actions commented on Sep 29, 2026

    @github-actions

    👋 感谢你的反馈,我们已经收到。

    • 维护者看到后会尽快回复你。
    • 状态保持为 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.
  2. jzcangshu commented on Sep 29, 2026

    @jzcangshu
    Author

    补充查重结果:本 issue 与 #758(@zakimimit 于 2026-09-21 提交,更早)报告的是同一问题——「重命名输入框需要多次点击才能出现/闪退」。#758 下方已补充根因摘要并指向本 issue 的完整分析(事件级 trace + 安装版 bundle 核对 + 两个修复方案),供维护者合并参考;两个 issue 暂时都保持 open,合并/关闭方式交给维护者裁量。

  3. jzcangshu commented on Sep 29, 2026

    @jzcangshu
    Author

    本地临时修复工具包 · Ready-made local patch toolkit

    按正文「本地临时修复」方式 1 做好的幂等补丁脚本,三个文件放到同一目录:

    使用方法

    1. 完全退出 ZCode(托盘图标也退出;注意会中断其中正在运行的 Agent 会话)。
    2. 双击 应用补丁-请先关闭ZCode.cmd(需要已安装 Node.js),或在命令行运行 node patch-app-asar.mjs。
    3. 启动 ZCode 验证:模型设置 → ... → 重命名,鼠标划过应能稳定保持输入框编辑态。
    4. 如需还原:退出 ZCode 后运行 还原.cmd。
    5. 客户端自动更新会覆盖 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions