Skip to content

fix(minimax-pet): 独立窗口拖拽按指针在窗口内的位移算目标位置,不再读 screenX - #132

Open
July-X wants to merge 4 commits into
MiniMax-AI:mainfrom
July-X:fix/minimax-pet-window-drag
Open

July-X wants to merge 4 commits into
MiniMax-AI:mainfrom
July-X:fix/minimax-pet-window-drag

Conversation

@July-X

@July-X July-X commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Fixes the desktop pet's drag in the popped-out window. The pet page runs inside a Host <webview>, where screenX is relative to the webview itself, so the old formula fed the window-relative pointer offset back into the window origin and the window kept bouncing around the grab point. The target position is now the Host's window origin plus the pointer's offset inside the window, and the page stops sending window updates as soon as the target stops changing.

Tested environment and results

MiniMax Code 3.1.14 (inside build 155) on Windows 11 (10.0.19045), Host app installed locally.

  • Repo gate npm run check (validate + test): 92 tests, 92 pass, 0 fail.
  • Package npm run check:package: 41 files match the source build byte for byte.
  • Package npm test: 259 tests, 258 pass, 1 fail. The failure is pet-art-validation.test.mjs, "the standalone CLI returns machine-readable success and failure with exit status": on Windows that case builds the CLI path from url.pathname and ends up with F:\F:\code\.... It fails the same way without this change and was left alone.
  • New dev/window-drag.test.mjs: 8 assertions covering a held pointer, pointer tracking, continuing from the position the Host kept after a refused move, the direction animation and the activation threshold. It runs the same assertions for screenX read as a local offset and as an absolute coordinate.
  • Mutation check: restoring the old implementation turns 4 of them red, and the "held pointer" case prints the oscillation signature 1030 -> 1000 -> 1030.
  • In the Host, on the same machine: the old build moves the window back and forth between 1580 and 1700 under a faithful replay of the pointer semantics; the fixed build moves it exactly +150,+75 to the pointer and then stays put for 8 seconds of Win32 sampling (2030,755 -> 2180,830 -> 2330,905 -> 2480,980 and then 2480,980). A refused move returns not_permitted and the window keeps the position the Host kept.
  • A driver level drag injection on the fixed build moves the window +100,+50 and it stays there; the same input on the old build moves a -100,-50 drag only -33,-17.

What was not verified

  • The "message panel keeps growing horizontally" variant did not reproduce here. The drag path only ever sends {x, y}, and the Host keeps the window width for a patch without width, so the drag loop cannot widen the window on its own. The two reported symptoms share the same engine, the per frame update loop, and that loop is gone now, but this variant has no separate reproduction and no separate comparison.
  • A human hand dragging with a physical mouse could not be exercised in this automated session. The Host window cannot take the foreground (the CUA cursor overlay owns it, and a full screen transparent Host overlay swallows system level clicks), so system mouse input never reaches the guest. The "stays put while held" evidence comes from a faithful synthetic pointer replay, and the 1:1 tracking evidence comes from a driver level drag injection. Both run the real page in the real Host, but neither is a physical mouse.
  • The failing package test above is an existing Windows path problem on the check side and was not touched.

Coordinate space and platform assumptions

The formula mixes two things: the pointer's offset inside the window (a clientX difference, CSS pixels) and the window origin the Host reports (win.getState(), getContentBounds(), Electron DIP). Both are logical pixels, so the ratio is 1:1 on Windows at any display scale and on macOS Retina.

  • The popped-out window is its own BrowserWindow and the guest page is a <webview> inside it, so the guest renders at zoom 1 and one CSS pixel is one DIP. A page zoom on the guest would change that ratio; the zoom call sites in the Host bundle (webui tab zoom, browser device preview, popup inheritance) do not reach that path today.
  • The window is frameless (frame: false). The Host persists the outer bounds but reports the content bounds to the page, and the two share an origin only while the window has no frame.
  • Off-screen moves are refused by the Host (not_permitted, at least 40px of the window must overlap a display work area) rather than clamped, so the rollback keeps the position the Host kept on every platform. Coordinates for a monitor left of or above the primary one are passed through unchanged.
  • During a drag the origin is tracked optimistically from the last target sent. If the Host moves the window on its own mid-drag (expanding the message panel anchors the pet to the panel's bottom right), the next pointer move pulls the window back to the older anchor for one frame. The old code tracked the origin from grab time, so its offset lasted the whole drag. Left as is: the trigger needs a click that cannot happen mid-drag.

Warnings from npm run check

None, the run reports no warnings. The one failing package test is listed above.

New access or higher minimum client version

None. No permission declaration changed, no new manifest field, the minimum client version stays 3.1.6.

Written by MiniMax Code (M3.1-Flash)

Root cause

The popped out window is a Host BrowserWindow that loads webview-host/index.html, and that page mounts the guest page in a <webview> tag (Host webview-host.js). Inside a <webview> the screenX of a pointer event is relative to the webview's own bounds, not to the screen. The drag log from the real Host shows it directly: on the pointer down frame screenX and clientX are both 138.

The old code computed dx = e.screenX - d.startX and added it to the window origin captured at grab time. The window follows the pointer, so the pointer's offset inside the window shrinks back to the grab point, the computed target falls back to the grab origin, the window jumps back, the offset grows again and the next frame pushes it forward. The window bounces between the grab position and the target position forever. The Host's own log of the old build shows exactly that, 1580 and 1700 alternating.

busy came after that. The old code sent win.update({x, y}) on every pointer move, so the Host's window IPC never went idle, and a mode switch that lands on a tab which is switching or rebuilding a page is answered with busy (updateMiniAppWindow in the Host).

The fix

sources/minimax-ai/minimax-pet/miniapp/client/pet-app.js

  • onSpritePointerDown records grabX/grabY from clientX/clientY, the window origin at grab time as base, and origin as the origin the app tracks optimistically. screenX/screenY are gone.
  • onSpritePointerMove computes target = origin + (client - grab). When the target equals the origin the handler returns without sending an update, so a held pointer sends nothing at all and the feedback loop has no place to start. The origin is updated optimistically and rolled back to the position the Host kept when an update is refused, including not_permitted for a target off screen. The threshold that activates the drag uses the travel against base.
  • updateDragDirection uses the target x instead of the pointer coordinate, so the running animation no longer flips while the pointer moves inside the moving window.

The formula needs only the pointer's relative movement inside the window and the Host's window origin. In the popped-out window the guest page is a <webview>, so clientX/clientY are the offset inside that window and not a screen position; the Host's origin is what turns that offset into a screen position. The inline panel has no separate window, so the drag only moves a window in the popped-out mode.

Version

0.8.0 to 0.8.1 in plugin.json, package.json, package-lock.json and the version line in index.html, plus one entry in each README.


修掉独立窗口里拖动桌宠时的自我移动。桌宠页面跑在宿主的 <webview> 里,那里的 screenX 是相对 webview 自己的坐标,旧公式把这个窗口内偏移又加回窗口原点,窗口就在抓取点附近来回弹。目标位置改成宿主窗口原点加指针在窗口内的位移,目标不再变化时页面也不再发窗口更新。

测试环境与结果

MiniMax Code 3.1.14(内部构建 155),Windows 11(10.0.19045),本机安装的宿主。

  • 仓库门禁 npm run check(validate + test):92 项测试,92 通过、0 失败。
  • 包内 npm run check:package:41 个文件与源构建逐字节一致。
  • 包内 npm test:259 项测试,258 通过、1 失败。失败的是 pet-art-validation.test.mjs 的「独立 CLI 返回机器可读的成功与失败并带退出码」用例:Windows 上该用例用 url.pathname 拼 CLI 路径,拼成了 F:\F:\code\...。不带本次改动时同样失败,属于既有问题,没有动它。
  • 新增 dev/window-drag.test.mjs 8 条断言,覆盖按住不动、跟随指针、被拒后从宿主保留的位置继续、方向动画与激活阈值,并对 screenX 的局部偏移与绝对坐标两种语义各跑一遍。
  • 变异自检:把修复整段还原成旧实现后 4 条转红,其中「按住不动」那条打印出 1030 -> 1000 -> 1030 的振荡签名。
  • 同一台机器的宿主内实测:旧构建在忠实复现指针语义的回放下窗口在 1580 与 1700 之间往复;修复版按 +150,+75 精确到位,随后 Win32 连续采样 8 秒原地不动(2030,755 -> 2180,830 -> 2330,905 -> 2480,980,之后保持 2480,980)。被拒的移动回 not_permitted,窗口停在宿主保留的位置。
  • 驱动级拖拽注入:修复版窗口精确跟随 +100,+50 并停住;同样输入喂给旧构建,-100,-50 的拖拽窗口只走了 -33,-17。

没有验证到的

  • 「消息面板不停横向变长」这一支在这里没复现。拖拽链路只发 {x, y},宿主对不含 width 的补丁保持窗口宽度不变,所以拖拽流本身撑不宽窗口。两种症状共用同一个引擎,也就是每帧发更新的反馈环,这个环现在断了,但这一支没有单独的复现和对照。
  • 真人手拿鼠标慢慢拖这一段没能在这轮自动化里跑通。宿主窗口抢不到前台(电脑控制工具自己的光标浮层占着前台,宿主还有个全屏透明浮层会吃掉系统级点击),系统级鼠标事件进不了 guest。「按住不动」那段证据来自忠实复现指针语义的合成事件回放,1:1 跟随那段证据来自驱动级拖拽注入。两段都是真实页面在真实宿主里,但都不是物理鼠标。
  • 上面那条失败的包内测试是既有的 Windows 路径问题,属判据侧,没有动。

坐标空间与平台前提

公式里混了两样东西:指针在窗口内的偏移(clientX 差值,CSS 像素)和宿主回报的窗口原点(win.getState()、getContentBounds()、Electron 的 DIP)。两边都是逻辑像素,比例 1:1,Windows 任意显示缩放和 macOS Retina 都一样。

  • 弹出的独立窗口是宿主自己的 BrowserWindow,guest 页面是里面的 <webview>,guest 以缩放 1 渲染,1 CSS 像素等于 1 DIP。给 guest 加页面缩放会改变这个比例;宿主整包里的缩放调用点(webui 标签页缩放、浏览器设备预览、弹窗继承缩放)目前都不在这条路上。
  • 窗口无边框(frame: false)。宿主持久化的是外框边界,回报给页面的是内容区边界,只有窗口没有边框时这两者的原点才重合。
  • 越界由宿主拒绝(not_permitted,窗口至少要有 40px 落在某个显示器的可用区里),不是夹取,所以各平台上回滚后都停在宿主保留的位置。副屏在主屏左边或上边时的负坐标原样传过去。
  • 拖拽中的原点是乐观跟踪的最后一次目标。宿主自己在拖拽中挪窗口时(展开消息面板会把桌宠锚到面板右下角),下一个 pointer move 会把窗口拉回旧锚点一帧。旧代码用的是抓取时刻的原点,偏差持续整个拖拽,这次是收窄。没继续改:触发它需要一次拖拽中不可能发生的点击。

npm run check 的剩余告警

没有。该次运行不报告警,唯一失败的那条包内测试已列在上面。

新增权限或更高的最低客户端版本

没有。权限声明未变,没有新增 manifest 字段,最低客户端版本仍是 3.1.6。

Written by MiniMax Code (M3.1-Flash)

根因

弹出的独立窗口是宿主的 BrowserWindow,加载 webview-host/index.html,那个页面把 guest 页面挂在 <webview> 标签里(宿主 webview-host.js)。在 <webview> 里,pointer 事件的 screenX 是相对 webview 自身边界的,不是屏幕坐标。真实拖拽日志能直接看到:按下那一帧 screenX 与 clientX 都是 138。

旧代码算 dx = e.screenX - d.startX,再加到抓取时记下的窗口原点上。窗口跟着指针走,指针在窗口内的位移就缩回抓取点,算出来的目标位置退回抓取原点,窗口弹回去,位移又变大,下一帧再把窗口推出去。窗口就这样在抓取位和目标位之间无限往返,宿主旧构建的日志里正是 1580 与 1700 交替。

busy 是之后才出现的。旧代码每个 pointermove 都发一次 win.update({x, y}),宿主窗口 IPC 一直不空,模式切换撞上正在切换或重建页面的 tab 就会被判 busy(宿主 updateMiniAppWindow)。

改法

sources/minimax-ai/minimax-pet/miniapp/client/pet-app.js

  • onSpritePointerDown 用 clientX/clientY 记 grabX/grabY,用抓取时的窗口原点作 base,另记一个乐观跟踪的 origin。screenX/screenY 不再出现。
  • onSpritePointerMove 算 target = origin + (client - grab)。target 与 origin 相同时直接 return,一个更新都不发,按住不动就彻底安静,反馈环没有起点。origin 乐观更新,发出的更新被拒时(包括目标越界的 not_permitted)回滚到宿主保留的位置。激活拖拽的阈值改用相对 base 的位移。
  • updateDragDirection 改用目标 x,窗口内坐标抖动不再让跑动方向来回翻。

这个公式只需要指针在窗口内的相对位移和宿主的窗口原点。独立窗口里 guest 页面是 <webview>,clientX/clientY 是它在窗口内的偏移而不是屏幕位置,是宿主给的原点把这个偏移换算成屏幕位置。内联面板没有独立窗口,所以只有弹出模式才需要移动窗口。

版本

plugin.json、package.json、package-lock.json 与 index.html 的版本文案从 0.8.0 改为 0.8.1,两个 README 各加一条说明。


Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.

powered by MiniMax Code

MiniMax-AI#131 合入后 0.9.0 的 pet-app.js 仍在用 screenX 算拖拽目标。桌宠页面跑在宿主的
webview 里,那里的 screenX 是相对 webview 自己的坐标,不是屏幕绝对坐标。旧公式
把窗口内的指针偏移又加回抓取时的窗口原点,形成"窗口跟随指针 → 窗口内位移回缩 →
目标回弹 → 窗口前冲"的振荡环,窗口就在抓取点附近无限往返,宿主旧构建的日志里是
1580 与 1700 交替。每帧 pointermove 都发 win.update,宿主窗口 IPC 一直不空,模式
切换撞上正在切换或重建页面的 tab 就被判 busy。

改成用宿主当前的窗口原点加指针在窗口内的位移算目标位置。目标与原点相同就不再发
win.update,按住不动时一个更新都不发,反馈环没有起点;宿主拒绝移动时把原点回滚
到上一个宿主接受的位置,从那里继续。跑动方向的动画改用同一个目标位置,窗口内
坐标抖动不再让方向来回翻。宿主没有报出窗口位置时仍按指针位移播放跑动动画,只是
窗口不动。

本分支原先基于 0.8.0,与 MiniMax-AI#131(0.9.0)冲突,已重放到 0.9.0 之上:版本 0.9.0 →
0.9.1,两个 README 补 0.9.1 说明。新增 dev/window-drag.test.mjs,8 条断言覆盖按住
不动、跟随指针、被拒后继续、方向动画与激活阈值。

门禁读数:仓库根 npm run check 92/92 通过;包内 npm test 268 项中 267 项通过,1 项
失败是 dev/pet-art-validation.test.mjs 在 Windows 上把 file:// URL 直接交给
execFile 导致的路径拼接问题,涉及文件本次未改动,与本改动无关;npm run
check:package 显示 41 个文件与源码构建逐字节一致。
@July-X
July-X force-pushed the fix/minimax-pet-window-drag branch from 387718e to 29b22ac Compare November 13, 2026 06:38
@July-X

July-X commented Nov 13, 2026

Copy link
Copy Markdown
Contributor Author

已按 #131 重整这个 PR。#131 在本 PR 之后 12 分钟合入,把 minimax-pet 升到 0.9.0 并重写了 miniapp/client/pet-app.js(新增 i18n.mjs、删除 workbench-window.mjs),与本 PR 的拖拽改动落在同一文件上,PR 此前处于 CONFLICTING;CI 的绿灯也是 #131 合入前那次运行的读数。

现在分支已基于 0.9.0 重放,PR 状态回到 MERGEABLE。

改动:

  • miniapp/client/pet-app.js:拖拽不再读 screenX。窗口目标 = 宿主当前窗口原点 + 指针在窗口内的位移;目标与原点相同就不再发 win.update,按住不动时一个更新都不发,反馈环没有起点;宿主拒绝移动时把原点回滚到上一个宿主接受的位置,从那里继续。跑动方向的动画改用同一个窗口目标,窗口内坐标抖动不再让方向来回翻。宿主没有报出窗口位置时仍按指针位移播放跑动动画,只是窗口不动。
  • 版本 0.9.0 → 0.9.1(plugin.json / package.json / package-lock.json / index.html),中英文 README 补 0.9.1 说明与测试边界。
  • 新增 dev/window-drag.test.mjs:8 条断言覆盖按住不动、跟随指针、被拒后继续、方向动画与激活阈值。
  • plugins/minimax-ai/minimax-pet/ 由 npm run package 的产物逐文件同步。

门禁读数:

  • 仓库根 npm run check:92/92 通过,0 失败。
  • 包内 npm run check:package:41 个文件与源码构建逐字节一致。
  • 包内 npm test:268 项中 267 项通过。唯一失败是 dev/pet-art-validation.test.mjs 在 Windows 上把 file:// URL 直接交给 execFile 造成的路径拼接问题(拼出 F:\F:\...),该文件本次没有改动,与本 PR 无关。

未验证:

  • 没有在真实 MiniMax Code 桌面窗口里实际拖拽,测试用的是模拟宿主。
  • “宿主窗口 IPC 一直不空,模式切换撞上正在切换的 tab 就被判 busy”这条宿主侧现象,本次没有在真实宿主上复测;改动针对的是页面每帧都发 win.update 的源头。

Copy link
Copy Markdown
Collaborator

Thanks for rebasing onto 0.9.0. The rebase looks clean: only the three drag functions changed, and the version reads 0.9.1 everywhere. There's one issue in onSpritePointerMove (pet-app.js) we'd like fixed before merging.

A rejected update can roll the origin back past a later one that succeeded. On rejection the code sets d.origin = accepted, which is the optimistic origin from before that update was sent. If an earlier win.update is rejected after a later one was already sent and accepted, d.origin falls back to that stale value. The next target is then computed from it, and the window jumps backwards. In a simulation starting at 1000, the update to 1030 is rejected and the update to 1090 is accepted. The origin resets to 1000, so the next move lands at 980 instead of 1070, a jump of about 110px. This is most likely near a screen edge or the gap between two monitors. The current rejection test rejects every update, so it doesn't catch this.

Fast drags still overshoot a little. d.origin is set to the target before the Host has moved the window, but clientX only changes after the move. If the next pointermove arrives first, the movement that hasn't landed yet is counted twice. The window overshoots by about one event's worth of movement, then snaps back once the pointer stops.

Suggested fix: allow one win.update in flight at a time. While it's pending, keep only the latest target and send it when the update returns. On rejection, fall back to the Host-confirmed app.winPos. That covers both issues. A test where one update is rejected and the next succeeds would lock it in.

We found this by reading the code and running it against a simulated host, not by dragging a real MiniMax Code window.

按 MiniMax-AI#132 的评审意见改的。原来的实现有两个问题。

一是被拒的更新会回退原点。更新失败时把原点还原成这次更新之前的乐观值,而此时
可能已经有一次更晚的更新被宿主接受并落地。原点回退到更早的位置,下一次目标位置
就从那里算,窗口往回跳。评审给的例子:从 1000 开始,1030 的更新被拒、1090 的更新
被接受,原点被还原成 1000,下一次落在 980 而不是 1070,跳了约 110px。

二是快速拖拽会多算一段位移。原点在宿主移动窗口之前就写成了目标位置,而 clientX
要等窗口真的动了才变。下一次 pointermove 先到时,没落地的位移被算了两遍,窗口冲
过头,指针停下后又弹回来。

改成同一时刻只有一个 win.update 在途:更新期间只保留最新的目标位置,更新返回后
再发;目标位置一律按宿主确认过的窗口原点算,不再用乐观原点;更新被拒时回落到
宿主确认的位置(app.winPos)。宿主在更新应答里带回窗口位置,成功后直接用应答里
的位置作为新的原点。窗口位置的算法没变:指针在窗口内的位移加宿主确认的窗口原点。

新增两条断言锁住这两个场景:
- a refused update does not roll the origin back past a later accepted one
  拒绝一次、接受一次之后,下一次目标位置是 1070,不是 980。
- a fast drag counts a movement that has not landed yet only once
  两次 pointermove 在宿主应答之前到达时,窗口落在 1060,不是 1090。

两条断言在改之前的实现上都会失败,分别落到 1120 和 1090;改之后通过。

门禁读数:仓库根 npm run check 92/92 通过;包内 npm run check:package 41 个文件与
源码构建逐字节一致;包内 npm test 270 项中 269 项通过,唯一失败仍是
dev/pet-art-validation.test.mjs 在 Windows 上把 file:// URL 直接交给 execFile 造成
的路径拼接问题,该文件本次没有改动。

未验证:没有在真实 MiniMax Code 桌面窗口里实际拖拽,也没有实测宿主对窗口更新的
应答时序。
@July-X

July-X commented Nov 13, 2026

Copy link
Copy Markdown
Contributor Author

两条都复现了,都按建议改成「同一时刻只有一个 win.update 在途」(提交 56096b5)。

被拒的更新不再回退原点。 原来失败时把原点还原成这次更新之前的乐观值;如果那时已经有一次更晚的更新被接受并落地,原点就退到了更早的位置,下一次目标位置从那里算,窗口往回跳。现在更新在途时只保留最新的目标位置、更新返回后再发,所以不会再出现「更晚的更新先落地、更早的拒绝后到达」这种情况;被拒时回落到宿主确认的位置(app.winPos),而不是某个乐观快照。宿主在更新应答里带回窗口位置,成功后直接用应答里的位置作为新的原点。

快速拖拽不再把未落地的位移算两遍。 原来在宿主移动窗口之前就把原点写成了目标位置,而 clientX 要等窗口真的动了才变。现在目标位置一律按宿主确认过的窗口原点加指针在窗口内的位移算,而且更新没返回之前不会再发出第二个请求,没有落地的位移不会被重复计算。

新增两条断言锁住这两个场景:

  • a refused update does not roll the origin back past a later accepted one:从 1000 开始,先拒 1030、再接受 1090,下一次目标位置是 1070,不是 980。
  • a fast drag counts a movement that has not landed yet only once:两次 pointermove 在宿主应答之前到达时,窗口落在 1060,不是 1090。

两条断言在改之前的实现上都会失败,分别落到 1120 和 1090;改之后通过。

窗口位置的算法没有变:指针在窗口内的位移加宿主确认的窗口原点。「按住不动时窗口停在原地」和「被拒的移动从宿主保留的位置继续」这两条性质也保持不变。

门禁读数:

  • 仓库根 npm run check:92/92 通过,0 失败。
  • 包内 npm run check:package:41 个文件与源码构建逐字节一致。
  • 包内 npm test:270 项中 269 项通过。唯一失败仍是 dev/pet-art-validation.test.mjs 在 Windows 上把 file:// URL 直接交给 execFile 造成的路径拼接问题,该文件本次没有改动。

未验证:没有在真实 MiniMax Code 桌面窗口里实际拖拽,也没有实测宿主对窗口更新的应答时序。

Copy link
Copy Markdown
Collaborator

Thanks for the update. Before we go further, could you share a screen recording of the jitter? Two short clips would be ideal: one on current main (minimax-pet 0.9.0) and one on this branch, each dragging the popped-out pet window the same way with a physical mouse. Please also note your OS and display scale. We haven't been able to reproduce the jitter when dragging on our side, so a recording would help us confirm the problem and that this change fixes it.

Copy link
Copy Markdown
Collaborator

One more issue from reviewing 56096b5. If a pointermove arrives while a win.update is still in flight and the window has already moved, clientX is measured from the new window position, but d.origin still holds the old one. The queued target then misses the in-flight step, and the window steps back. For example, the window starts at 1000, and a request to move it to 1030 has been applied but hasn't resolved yet. The pointer moves +10, and the next update sends 1010 instead of 1040. If the user releases right then, finishDrag sends that stale target and the window stays at 1010. The preload also batches updates to the next animation frame, so every request waits at least one frame, which makes this more likely.

Suggested fix: while a request is in flight, just record the latest pointer position. When it resolves, recompute the target from the Host-confirmed window position plus the latest pointer offset. On pointerup, wait for the in-flight request to finish before sending the final position. It would also help to add a test for the case where the window has moved, the reply is still pending, and a pointermove arrives.

按 MiniMax-AI#132 的评审意见改的。上一版用「宿主确认的窗口原点 + 指针在窗口内的位移」算目标
位置,在宿主已经移动窗口、但更新还没应答的时候,这个原点是旧的。评审给的例子:窗口
从 1000 被请求移到 1030、应答还没回来时指针又动了 10px,下一次更新发的是 1010 而不是
1040,窗口先往回退一步再被拉回来。反过来,在同一次动画帧里宿主还没来得及应用上一次
移动时到达的 pointermove,用同一个旧原点算出来的目标又会偏到指针前面。

改成把窗口放到「指针在屏幕上的位置减去抓取时的偏移」。指针是在窗口内测量的,它的
屏幕位置取决于事件测量时所在的窗口原点,所以要判断那个原点是宿主确认的那个,还是
宿主已经移动到、但应答还没回来的那个。判断依据是指针自己:两次事件之间指针只走几
个像素,窗口一次移动要远得多,哪个候选原点的指针位移更小就用哪个。

于是三种时序都对:宿主还没移动窗口时到达的 pointermove、宿主已经移动而应答未回时
到达的 pointermove、以及同一帧内连续到达的 pointermove。上一版的另外两个问题仍然
成立:同一时刻只有一个 win.update 在途,更新期间只保留最新的目标位置并按宿主确认
的位置重算,被拒的更新回落到宿主确认的位置(app.winPos),不会把窗口回退到更早一次
成功移动之前。

新增一条断言锁住评审给的场景:
- a pointermove that arrives while the Host moves the window does not step it back
  宿主把窗口移到 1030 但还没应答时指针再动 10px,下一次更新发 1040,不是 1010。
这条断言在改之前的实现上失败,实际发的是 1010;改之后通过。

门禁读数:仓库根 npm run check 92/92 通过;包内 npm run check:package 41 个文件与
源码构建逐字节一致;包内 npm test 271 项中 270 项通过,唯一失败仍是
dev/pet-art-validation.test.mjs 在 Windows 上把 file:// URL 直接交给 execFile 造成
的路径拼接问题,该文件本次没有改动。

未验证:没有在真实 MiniMax Code 桌面窗口里实际拖拽,也没有实测宿主对窗口更新的
应答时序。

Copy link
Copy Markdown
Collaborator

Thanks for the quick turnaround. dragTarget() (pet-app.js, around lines 963–976) can still pick the wrong origin during a normal, steady drag. It chooses between the Host-confirmed origin O and the in-flight target I, whichever gives the smaller pointer jump. Once the window has already moved to I, the correct candidate shifts by v (this event's pointer delta) and the wrong one by |v − s|, where s = I − O. So it picks the wrong one whenever s < 2v. During a steady drag, s is usually the pointer movement from the previous interval, roughly v to 2v, which falls right in that range. The new test uses s=30 and v=10, so it doesn't catch this. We modeled the dragTarget/flushDrag logic with a steady 10px per event (a model, not a real-device test): when the reply arrives 3 events late, the lag builds up to 60px, and after release the window settles at 1110 instead of 1120.

Choosing the origin by distance is ambiguous by nature, so tuning it won't fully fix this. Please go with the approach from our earlier comment instead: while a request is in flight, only record the latest client coordinates. When the reply arrives, recompute the target from the Host-confirmed origin plus the pointer offset. On pointerup, wait for the in-flight step to finish before sending the final one. Please also add tests with s≈v and s≈2v.

Before the next round, could you share the screen recordings we asked for? We'd like to confirm the jitter with a physical mouse first.

维护者指出 dragTarget() 用「指针位移最小」在两个候选原点之间择一,在匀速拖拽
时会选错(s < 2v),要求改用他们早前评论里的方案:

- 窗口更新在途时只记录最新的指针坐标,不再据此算目标。
- 宿主应答后,用「宿主确认的窗口原点 + 指针相对抓取点的偏移」重算目标。
- 抬手时等在途的那次更新结束,再把最终位置发出去。
- 只有在请求之后又来了新的指针事件时才重发。指针停住时,宿主确认的原点加上
  一个已经过期的偏移会让窗口自己往前爬;来了一条新指针事件,才说明指针可能
  真的动了。

测试:
- s ≈ v:在途位移等于一个指针步长时,目标位置仍落在指针上。
- s ≈ 2v:在途位移等于两个指针步长时,目标位置仍落在指针上。
- 抬手时等在途更新结束,最终位置才发出去。
- 快速拖拽与「被拒的更新不回退原点」两条按宿主在下一帧才移动窗口重写。

对照:同一套新测试跑在上一版实现(d779e2e)上失败 4 条——快速拖拽只发 1030、
s≈v 只发 1010、s≈2v 发成 1020→1010 往回退、抬手只发 1030;本版 13 条全过。

门禁:
- 仓库根 npm run check:92/92 通过,0 失败。
- 包内 npm run check:package:41 个文件与源码构建逐字节一致。
- 包内 npm test:273 项中 272 项通过。唯一失败仍是 dev/pet-art-validation.test.mjs
  在 Windows 上把 file:// URL 交给 execFile 造成的路径拼接问题(拼出 F:\F:\...),
  该文件本次没有改动。

未验证:没有在真实 MiniMax Code 桌面窗口里实际拖拽,也没有实测宿主对窗口更新
的应答时序;维护者要的屏幕录像还没提供。
@July-X

July-X commented Nov 13, 2026

Copy link
Copy Markdown
Contributor Author

已按你们早前评论里的方案重写(提交 9634e75)。

改了什么

  • 窗口更新在途时只记录最新的指针坐标,不再据此算目标。
  • 宿主应答后用「宿主确认的窗口原点 + 指针相对抓取点的偏移」重算目标:target = origin + (pointer − grab)。原来那个在「确认原点 O」和「在途目标 I」之间按指针位移最小择一的 dragTarget() 启发式已经删掉。
  • 抬手时等在途的那次更新结束,再把最终位置发出去:抬手事件先把指针坐标更新成释放点的坐标,在途应答到达后才发出去。
  • 只有在请求之后又来了新的指针事件时才重发。这一条是我们加的护栏:指针停住时宿主不会再应答,但手上那个偏移是宿主移动窗口之前测的,已经过期;拿「确认原点 + 过期偏移」重算,会让窗口每次应答都往前跳一格,越跳越远(指针不动、窗口自己爬)。有新的指针事件才说明指针可能真的动了,所以按事件计数判断,而不是比较坐标——指针和窗口同步移动时坐标可能完全不变。

新增测试(dev/window-drag.test.mjs,共 13 条)

  • a pending move of one pointer step keeps the target on the pointer:s ≈ v。在途位移 10、这一事件指针也走 10,窗口应落在 1020。
  • a pending move of two pointer steps keeps the target on the pointer:s ≈ 2v。在途位移 20、这一事件指针走 10,窗口应落在 1030。
  • the release waits for the update in flight before the final position:抬手时在途更新还没回来,最终位置要等它结束才发。

对照:同一套测试跑在上一版实现(d779e2e)上失败 4 条——快速拖拽只发 1030、s≈v 只发 1010、s≈2v 发成 1020 → 1010(往回退)、抬手只发 1030;本版 13 条全过。

测试脚手架的一处改动:宿主在收到更新后的下一帧才移动窗口,应答在这之后才回来,所以这几条用例改成显式调用 applyMove()(窗口已经移动、应答还没回)再发 pointermove,和你们 08:58 那条评论里的场景一致。

门禁读数

  • 仓库根 npm run check:92/92 通过,0 失败。
  • 包内 npm run check:package:41 个文件与源码构建逐字节一致。
  • 包内 npm test:273 项中 272 项通过。唯一失败仍是 dev/pet-art-validation.test.mjs 在 Windows 上把 file:// URL 交给 execFile 造成的路径拼接问题(F:\F:\...),该文件本次没有改动。
  • 拖拽用例 13 条全过。

未验证 / 还没做的

  • 没有在真实 MiniMax Code 桌面窗口里实际拖拽,也没有实测宿主对窗口更新的应答时序。
  • 屏幕录像这次仍然没有:录制需要一台装着这个分支的机器、用物理鼠标拖一次桌宠,我们这边只有仓库和模拟宿主的测试环境,没有可操作的桌面 GUI。如果需要录像,得在装了 main 0.9.0 和本分支的机器上各录一段,并注明系统与显示缩放;你们也可以先给一份真机上的窗口原点随时间变化的读数,我们按读数再改。

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.

3 participants