From 29b22acfaef312d7c3f8c801e839d4252e56e255 Mon Sep 17 00:00:00 2001 From: July-X Date: Fri, 13 Nov 2026 14:37:43 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(minimax-pet):=20=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E6=8B=96=E6=8B=BD=E6=8C=89=E6=8C=87=E9=92=88?= =?UTF-8?q?=E5=9C=A8=E7=AA=97=E5=8F=A3=E5=86=85=E7=9A=84=E4=BD=8D=E7=A7=BB?= =?UTF-8?q?=E7=AE=97=E7=9B=AE=E6=A0=87=E4=BD=8D=E7=BD=AE=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=AF=BB=20screenX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #131 合入后 0.9.0 的 pet-app.js 仍在用 screenX 算拖拽目标。桌宠页面跑在宿主的 webview 里,那里的 screenX 是相对 webview 自己的坐标,不是屏幕绝对坐标。旧公式 把窗口内的指针偏移又加回抓取时的窗口原点,形成"窗口跟随指针 → 窗口内位移回缩 → 目标回弹 → 窗口前冲"的振荡环,窗口就在抓取点附近无限往返,宿主旧构建的日志里是 1580 与 1700 交替。每帧 pointermove 都发 win.update,宿主窗口 IPC 一直不空,模式 切换撞上正在切换或重建页面的 tab 就被判 busy。 改成用宿主当前的窗口原点加指针在窗口内的位移算目标位置。目标与原点相同就不再发 win.update,按住不动时一个更新都不发,反馈环没有起点;宿主拒绝移动时把原点回滚 到上一个宿主接受的位置,从那里继续。跑动方向的动画改用同一个目标位置,窗口内 坐标抖动不再让方向来回翻。宿主没有报出窗口位置时仍按指针位移播放跑动动画,只是 窗口不动。 本分支原先基于 0.8.0,与 #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 个文件与源码构建逐字节一致。 --- .../minimax-pet/.minimax-plugin/plugin.json | 2 +- plugins/minimax-ai/minimax-pet/README.md | 3 + .../minimax-ai/minimax-pet/README.zh-CN.md | 4 + .../minimax-pet/miniapp/client/index.html | 2 +- .../minimax-pet/miniapp/client/pet-app.js | 52 +++++-- .../minimax-pet/.minimax-plugin/plugin.json | 2 +- sources/minimax-ai/minimax-pet/README.md | 3 + .../minimax-ai/minimax-pet/README.zh-CN.md | 4 + .../minimax-pet/dev/window-drag.test.mjs | 130 ++++++++++++++++++ .../minimax-pet/miniapp/client/index.html | 2 +- .../minimax-pet/miniapp/client/pet-app.js | 52 +++++-- .../minimax-ai/minimax-pet/package-lock.json | 4 +- sources/minimax-ai/minimax-pet/package.json | 2 +- 13 files changed, 229 insertions(+), 33 deletions(-) create mode 100644 sources/minimax-ai/minimax-pet/dev/window-drag.test.mjs diff --git a/plugins/minimax-ai/minimax-pet/.minimax-plugin/plugin.json b/plugins/minimax-ai/minimax-pet/.minimax-plugin/plugin.json index 7df6138..108d7af 100644 --- a/plugins/minimax-ai/minimax-pet/.minimax-plugin/plugin.json +++ b/plugins/minimax-ai/minimax-pet/.minimax-plugin/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "name": "minimax-pet", "displayName": "MiniMax Pet", - "version": "0.9.0", + "version": "0.9.1", "description": "轻量桌宠,聚合主窗口可见会话的任务状态、需要回答的提醒和最新结果,支持外观切换及图集导入。", "author": "MiniMax", "icon": "icon.png", diff --git a/plugins/minimax-ai/minimax-pet/README.md b/plugins/minimax-ai/minimax-pet/README.md index f1ce6fb..20427cd 100644 --- a/plugins/minimax-ai/minimax-pet/README.md +++ b/plugins/minimax-ai/minimax-pet/README.md @@ -16,6 +16,8 @@ A lightweight desktop companion for MiniMax Code, by **MiniMax**. It shows visib The pet does not run Agent turns, create independent chats, write to chat inputs, comment on every session, stop tasks, or answer questionnaires. Selecting a task only reads its details; the main window switches only when you click **打开对话** or **去回答**. +Version **0.9.1** fixes dragging the pet in the popped-out desktop window. The window position is now the pointer's offset inside the window plus the window origin, so the pet follows the pointer, a held pointer leaves the window where it is, and a move the Host refuses continues from the position the Host kept. The running animation uses the same target. + Version **0.9.0** adds two settings. **Task notifications** sets how many seconds a desktop card stays after a task finishes or fails, entered as a number and saved on the device; the default is 10 and **0** keeps the card until you close it. A card hiding never marks the task read, cards that need your answer never hide, and pointing at or focusing the cards pauses the countdown. **Language** follows the MiniMax Code interface language by default and can be pinned to 简体中文 or English; the whole panel and desktop copy switch, while conversation titles and replies stay as the Agent wrote them. Version **0.9.0** also gives the desktop window one fixed size, tall enough for the open card. Earlier versions grew the window when a card opened, which moved it on screen and made the pet jump for a frame; now opening or closing a card only shows or hides it. The area above the pet is transparent and passes clicks through to whatever is behind it. @@ -47,6 +49,7 @@ Earlier local prototypes use a different plugin ID. The Host gives `minimax-pet` - An earlier UI version was exercised on macOS in a MiniMax Code development build: three tabs, wide/narrow layouts, task reading, real session/ask activity, import/delete, and desktop round trips. Version 0.6.0 has Node/DOM tests (including the open and theme paths against a simulated Host), an isolated HTTP smoke test, and a browser check of the panel layout outside the Host. - The default atlas is checked by actual PNG decoding, frame coverage and deterministic rebuild tests. The separate octopus icon is checked by PNG decoding and visual review. On 2026-10-26, the packaged Skill was exercised with a real Agent in the macOS development build to create, validate, preview and import a custom v2 pet; all nine animation previews and the four cardinal mouse-look directions were checked. This does not replace released-client acceptance. - Version 0.9.0 has Node/DOM tests for the card timer, the preference round trip and the copy of both languages (every key the page uses must exist in both), plus a browser check of the panel and desktop window in both languages against a simulated Host. Not verified for 0.9.0: the language the real client reports on any given install, and both languages inside a real MiniMax Code window. +- Version 0.9.1 has Node tests for the desktop drag: a held pointer leaves the window where it is, a moving pointer is followed, a refused move continues from the position the Host kept, and the run direction follows the window target. Not verified for 0.9.1: dragging the pet inside a real MiniMax Code desktop window. - Not verified for 0.6.0: opening a conversation or following the theme inside a real MiniMax Code 3.1.11+ window (panel or desktop pet), any Windows GUI, and released-client installation or GUI acceptance on 3.1.6. Automated tests use synthetic data and do not prove those paths. ## Data & access diff --git a/plugins/minimax-ai/minimax-pet/README.zh-CN.md b/plugins/minimax-ai/minimax-pet/README.zh-CN.md index 939c023..965882d 100644 --- a/plugins/minimax-ai/minimax-pet/README.zh-CN.md +++ b/plugins/minimax-ai/minimax-pet/README.zh-CN.md @@ -18,6 +18,8 @@ **0.5.11** 新增可选内置宠物 **MMX 章鱼娘**,是一位橙发、白背心、棕格纹短裙的 Q 版全身章鱼娘。在“外观 → 选择宠物”中选择她即可;薄荷色机器人仍为默认宠物。她采用 v1 图集,包含九种动作,不含十六个视线方向,因此鼠标跟随对她不生效;沿用六帧、6.6 秒一轮的慢速待机,不新增权限或改变播放器。图集已通过校验。创作流程和贡献者许可见[素材说明](ARTWORK.md)。 +**0.9.1** 修复独立窗口里拖动桌宠的问题。窗口位置改为按指针在窗口内的位移加窗口原点计算:桌宠跟着指针走,按住不动时窗口停在原地,被宿主拒绝的移动会从宿主保留的位置继续。跑动方向的动画改用同一个目标位置。 + **0.9.0** 新增两项设置。“任务通知”用数字框设置桌面卡片在任务完成或异常后显示多少秒,默认 10 秒,填 0 表示一直显示到手动关闭;设置保存在本机。卡片自动收起不会把任务标记为已读,需要你回答的卡片不会自动收起,鼠标悬停或焦点停留时会暂停计时。“语言”默认跟随 MiniMax Code 的界面语言,也可以固定为简体中文或 English;面板和桌面文案整体切换,对话标题和回复保持 Agent 原文。 **0.9.0** 把桌面窗口固定成一个尺寸,本来就放得下展开的卡片。之前点开卡片会把窗口撑高,窗口在屏幕上跟着移动,桌宠会跳一下;现在开合卡片只显示或隐藏,不动窗口。桌宠上方是透明区域,点击会穿透到底下的窗口。 @@ -38,6 +40,8 @@ 任务监控使用 `agent.sessions/session/observe/cancel`,需要 `session.read` 授权。保留运行时能力和权限检查,缺少接口或关闭授权时显示不可用。“打开对话/去回答”使用 `host.navigation.open`,主题跟随使用 `host.ui.subscribe`,两者都从 MiniMax Code **3.1.11** 起提供;2026-11-10 已确认 3.1.11 的 macOS 与 Windows 安装包在两个正式 CDN 上均可下载。两项调用已对照 MiniMax Code 源码核对:打开插件已持有的会话不需要 `hostAccess` 或其他授权,必须在点击中发起,并会把主窗口带到前台。 +0.9.1 的拖拽有 Node 测试:按住不动窗口停在原地,指针移动时窗口跟随,被宿主拒绝的移动从宿主保留的位置继续,跑动方向跟随窗口目标。未在真实的 MiniMax Code 桌面窗口里验收拖拽。 + 更早的 UI 版本已在 macOS 开发版验收三页签、窄宽布局、真实任务与 ask、导入删除和桌面切换;0.6.0 运行 Node/DOM 测试(含模拟 Host 下的打开对话与主题路径)、隔离 HTTP smoke,并在 Host 之外用浏览器检查了面板布局。默认图集通过 PNG 解码、动画帧覆盖与确定性重建检查;独立的章鱼图标通过 PNG 解码和视觉检查。2026-10-26,已用 macOS 开发版中的真实 Agent 调用随包 Skill,完成自定义 v2 宠物的生成、校验、预览和导入,并检查九种动作预览与鼠标上/右/下/左四向注视。0.6.0 尚未在真实的 MiniMax Code 3.1.11+ 窗口(面板或桌面宠物)中验收打开对话和主题跟随,也未验收 Windows GUI、正式客户端安装或 3.1.6 正式版 GUI;合成数据测试不能证明这些路径。 ## 数据与能力边界 diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/index.html b/plugins/minimax-ai/minimax-pet/miniapp/client/index.html index 24c255f..838f63f 100644 --- a/plugins/minimax-ai/minimax-pet/miniapp/client/index.html +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/index.html @@ -181,7 +181,7 @@

任务连接

版本与帮助
-

MiniMax Pet 0.9.0

+

MiniMax Pet 0.9.1

diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/pet-app.js b/plugins/minimax-ai/minimax-pet/miniapp/client/pet-app.js index 81b26a2..4213a6d 100644 --- a/plugins/minimax-ai/minimax-pet/miniapp/client/pet-app.js +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/pet-app.js @@ -896,24 +896,40 @@ function onSpritePointerDown(e) { } catch (error) { console.warn('[pet] pointer_capture_failed:', errCode(error), error); } + const origin = app.winPos ? { ...app.winPos } : null; app.drag = { pointerId: e.pointerId, - startX: e.screenX, - startY: e.screenY, - win: app.winPos ? { ...app.winPos } : null, + // A popped-out window reports screenX relative to its own frame, so the + // pointer position inside the window plus the window origin is the only + // place the window can go. + grabX: e.clientX, + grabY: e.clientY, + // Window origin at grab time; null when the Host reported no position. + base: origin, + // Optimistic window origin: the position the Host accepted last. + origin: origin ? { ...origin } : null, + // Reference for the run direction: the window origin when it is known, + // otherwise the pointer position inside the window. + ref: origin ? origin.x : e.clientX, active: false, dir: null, - extreme: e.screenX, + extreme: origin ? origin.x : e.clientX, }; } function onSpritePointerMove(e) { const d = app.drag; if (!d || e.pointerId !== d.pointerId) return; - const dx = e.screenX - d.startX; - const dy = e.screenY - d.startY; + const dx = e.clientX - d.grabX; + const dy = e.clientY - d.grabY; + // The window follows the pointer, so the pointer offset inside the window + // shrinks back to the grab point. The target is measured against the window + // origin the Host holds, never against the grab position. + const target = d.origin ? { x: Math.round(d.origin.x + dx), y: Math.round(d.origin.y + dy) } : null; if (!d.active) { - if (Math.hypot(dx, dy) <= DRAG_THRESHOLD) return; + // How far the pointer travelled on screen, not inside the window. + const travel = target ? { x: target.x - d.base.x, y: target.y - d.base.y } : { x: dx, y: dy }; + if (Math.hypot(travel.x, travel.y) <= DRAG_THRESHOLD) return; d.active = true; app.dragging = true; app.oneShotToken++; @@ -922,12 +938,22 @@ function onSpritePointerMove(e) { hideQuickbar(); els.sprite.classList.add('is-dragging'); refreshInteractivity(); - if (!d.win) console.warn('[pet] drag_without_window_position: no window position yet, animation only'); + if (!d.origin) console.warn('[pet] drag_without_window_position: no window position yet, animation only'); } - updateDragDirection(d, e.screenX); - if (!d.win) return; - callBridge(() => win.update({ x: Math.round(d.win.x + dx), y: Math.round(d.win.y + dy) })).catch((error) => { + if (!target) { + // No window position: the pet still runs, but nothing can move. + updateDragDirection(d, e.clientX); + return; + } + // The window already sits where the pointer wants it: no update, no loop. + if (target.x === d.origin.x && target.y === d.origin.y) return; + updateDragDirection(d, target.x); + const accepted = d.origin; + d.origin = target; + callBridge(() => win.update({ x: target.x, y: target.y })).catch((error) => { const code = errCode(error); + // The Host refused the move and kept the window where it was. + d.origin = accepted; if (code === 'not_permitted') console.debug('[pet] drag_move_rejected: not_permitted (target is off every display)'); else console.warn('[pet] drag_move_failed:', code, error); }); @@ -947,8 +973,8 @@ function updateDragDirection(d, sx) { d.dir = 'right'; d.extreme = sx; } - } else if (Math.abs(sx - d.startX) > DIRECTION_HYSTERESIS / 2) { - d.dir = sx > d.startX ? 'right' : 'left'; + } else if (Math.abs(sx - d.ref) > DIRECTION_HYSTERESIS / 2) { + d.dir = sx > d.ref ? 'right' : 'left'; d.extreme = sx; } if (d.dir !== before) { diff --git a/sources/minimax-ai/minimax-pet/.minimax-plugin/plugin.json b/sources/minimax-ai/minimax-pet/.minimax-plugin/plugin.json index 7df6138..108d7af 100644 --- a/sources/minimax-ai/minimax-pet/.minimax-plugin/plugin.json +++ b/sources/minimax-ai/minimax-pet/.minimax-plugin/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "name": "minimax-pet", "displayName": "MiniMax Pet", - "version": "0.9.0", + "version": "0.9.1", "description": "轻量桌宠,聚合主窗口可见会话的任务状态、需要回答的提醒和最新结果,支持外观切换及图集导入。", "author": "MiniMax", "icon": "icon.png", diff --git a/sources/minimax-ai/minimax-pet/README.md b/sources/minimax-ai/minimax-pet/README.md index f1ce6fb..20427cd 100644 --- a/sources/minimax-ai/minimax-pet/README.md +++ b/sources/minimax-ai/minimax-pet/README.md @@ -16,6 +16,8 @@ A lightweight desktop companion for MiniMax Code, by **MiniMax**. It shows visib The pet does not run Agent turns, create independent chats, write to chat inputs, comment on every session, stop tasks, or answer questionnaires. Selecting a task only reads its details; the main window switches only when you click **打开对话** or **去回答**. +Version **0.9.1** fixes dragging the pet in the popped-out desktop window. The window position is now the pointer's offset inside the window plus the window origin, so the pet follows the pointer, a held pointer leaves the window where it is, and a move the Host refuses continues from the position the Host kept. The running animation uses the same target. + Version **0.9.0** adds two settings. **Task notifications** sets how many seconds a desktop card stays after a task finishes or fails, entered as a number and saved on the device; the default is 10 and **0** keeps the card until you close it. A card hiding never marks the task read, cards that need your answer never hide, and pointing at or focusing the cards pauses the countdown. **Language** follows the MiniMax Code interface language by default and can be pinned to 简体中文 or English; the whole panel and desktop copy switch, while conversation titles and replies stay as the Agent wrote them. Version **0.9.0** also gives the desktop window one fixed size, tall enough for the open card. Earlier versions grew the window when a card opened, which moved it on screen and made the pet jump for a frame; now opening or closing a card only shows or hides it. The area above the pet is transparent and passes clicks through to whatever is behind it. @@ -47,6 +49,7 @@ Earlier local prototypes use a different plugin ID. The Host gives `minimax-pet` - An earlier UI version was exercised on macOS in a MiniMax Code development build: three tabs, wide/narrow layouts, task reading, real session/ask activity, import/delete, and desktop round trips. Version 0.6.0 has Node/DOM tests (including the open and theme paths against a simulated Host), an isolated HTTP smoke test, and a browser check of the panel layout outside the Host. - The default atlas is checked by actual PNG decoding, frame coverage and deterministic rebuild tests. The separate octopus icon is checked by PNG decoding and visual review. On 2026-10-26, the packaged Skill was exercised with a real Agent in the macOS development build to create, validate, preview and import a custom v2 pet; all nine animation previews and the four cardinal mouse-look directions were checked. This does not replace released-client acceptance. - Version 0.9.0 has Node/DOM tests for the card timer, the preference round trip and the copy of both languages (every key the page uses must exist in both), plus a browser check of the panel and desktop window in both languages against a simulated Host. Not verified for 0.9.0: the language the real client reports on any given install, and both languages inside a real MiniMax Code window. +- Version 0.9.1 has Node tests for the desktop drag: a held pointer leaves the window where it is, a moving pointer is followed, a refused move continues from the position the Host kept, and the run direction follows the window target. Not verified for 0.9.1: dragging the pet inside a real MiniMax Code desktop window. - Not verified for 0.6.0: opening a conversation or following the theme inside a real MiniMax Code 3.1.11+ window (panel or desktop pet), any Windows GUI, and released-client installation or GUI acceptance on 3.1.6. Automated tests use synthetic data and do not prove those paths. ## Data & access diff --git a/sources/minimax-ai/minimax-pet/README.zh-CN.md b/sources/minimax-ai/minimax-pet/README.zh-CN.md index 939c023..965882d 100644 --- a/sources/minimax-ai/minimax-pet/README.zh-CN.md +++ b/sources/minimax-ai/minimax-pet/README.zh-CN.md @@ -18,6 +18,8 @@ **0.5.11** 新增可选内置宠物 **MMX 章鱼娘**,是一位橙发、白背心、棕格纹短裙的 Q 版全身章鱼娘。在“外观 → 选择宠物”中选择她即可;薄荷色机器人仍为默认宠物。她采用 v1 图集,包含九种动作,不含十六个视线方向,因此鼠标跟随对她不生效;沿用六帧、6.6 秒一轮的慢速待机,不新增权限或改变播放器。图集已通过校验。创作流程和贡献者许可见[素材说明](ARTWORK.md)。 +**0.9.1** 修复独立窗口里拖动桌宠的问题。窗口位置改为按指针在窗口内的位移加窗口原点计算:桌宠跟着指针走,按住不动时窗口停在原地,被宿主拒绝的移动会从宿主保留的位置继续。跑动方向的动画改用同一个目标位置。 + **0.9.0** 新增两项设置。“任务通知”用数字框设置桌面卡片在任务完成或异常后显示多少秒,默认 10 秒,填 0 表示一直显示到手动关闭;设置保存在本机。卡片自动收起不会把任务标记为已读,需要你回答的卡片不会自动收起,鼠标悬停或焦点停留时会暂停计时。“语言”默认跟随 MiniMax Code 的界面语言,也可以固定为简体中文或 English;面板和桌面文案整体切换,对话标题和回复保持 Agent 原文。 **0.9.0** 把桌面窗口固定成一个尺寸,本来就放得下展开的卡片。之前点开卡片会把窗口撑高,窗口在屏幕上跟着移动,桌宠会跳一下;现在开合卡片只显示或隐藏,不动窗口。桌宠上方是透明区域,点击会穿透到底下的窗口。 @@ -38,6 +40,8 @@ 任务监控使用 `agent.sessions/session/observe/cancel`,需要 `session.read` 授权。保留运行时能力和权限检查,缺少接口或关闭授权时显示不可用。“打开对话/去回答”使用 `host.navigation.open`,主题跟随使用 `host.ui.subscribe`,两者都从 MiniMax Code **3.1.11** 起提供;2026-11-10 已确认 3.1.11 的 macOS 与 Windows 安装包在两个正式 CDN 上均可下载。两项调用已对照 MiniMax Code 源码核对:打开插件已持有的会话不需要 `hostAccess` 或其他授权,必须在点击中发起,并会把主窗口带到前台。 +0.9.1 的拖拽有 Node 测试:按住不动窗口停在原地,指针移动时窗口跟随,被宿主拒绝的移动从宿主保留的位置继续,跑动方向跟随窗口目标。未在真实的 MiniMax Code 桌面窗口里验收拖拽。 + 更早的 UI 版本已在 macOS 开发版验收三页签、窄宽布局、真实任务与 ask、导入删除和桌面切换;0.6.0 运行 Node/DOM 测试(含模拟 Host 下的打开对话与主题路径)、隔离 HTTP smoke,并在 Host 之外用浏览器检查了面板布局。默认图集通过 PNG 解码、动画帧覆盖与确定性重建检查;独立的章鱼图标通过 PNG 解码和视觉检查。2026-10-26,已用 macOS 开发版中的真实 Agent 调用随包 Skill,完成自定义 v2 宠物的生成、校验、预览和导入,并检查九种动作预览与鼠标上/右/下/左四向注视。0.6.0 尚未在真实的 MiniMax Code 3.1.11+ 窗口(面板或桌面宠物)中验收打开对话和主题跟随,也未验收 Windows GUI、正式客户端安装或 3.1.6 正式版 GUI;合成数据测试不能证明这些路径。 ## 数据与能力边界 diff --git a/sources/minimax-ai/minimax-pet/dev/window-drag.test.mjs b/sources/minimax-ai/minimax-pet/dev/window-drag.test.mjs new file mode 100644 index 0000000..5356d28 --- /dev/null +++ b/sources/minimax-ai/minimax-pet/dev/window-drag.test.mjs @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import vm from 'node:vm'; +import test from 'node:test'; + +const source = await readFile(new URL('../miniapp/client/pet-app.js', import.meta.url), 'utf8'); +const handler = source.slice(source.indexOf('function onSpritePointerDown'), source.indexOf('function finishDrag')); + +// The Host keeps the window origin; a pointer inside the window is measured +// from that origin, so the pointer offset shrinks while the window follows it. +function harness({ reject = false } = {}) { + const calls = []; + const host = { x: 1000, y: 500 }; + const app = { + mode: 'window', pet: {}, winPos: { ...host }, drag: null, dragging: false, + oneShotToken: 0, oneShot: false, menuOpen: false, workbenchOpen: false, + }; + const context = vm.createContext({ + app, + els: { sprite: { + setPointerCapture() {}, hasPointerCapture: () => false, releasePointerCapture() {}, + classList: { add() {}, remove() {} }, + } }, + win: { + update(patch) { + calls.push(['update', patch.x, patch.y]); + if (reject) return Promise.reject(Object.assign(new Error('off screen'), { code: 'not_permitted' })); + host.x = patch.x; + host.y = patch.y; + app.winPos = { x: patch.x, y: patch.y }; + return Promise.resolve({ mode: 'window', window: { ...host, width: 280, height: 340 } }); + }, + }, + callBridge: (fn) => { try { return Promise.resolve(fn()); } catch (error) { return Promise.reject(error); } }, + errCode: (error) => error?.code || error?.message || 'result_unknown', + logError: () => () => {}, + player: { play: (name) => { calls.push(['play', name]); return Promise.resolve(); } }, + noteInteraction() {}, closeMenu() {}, hideQuickbar() {}, stopLook() {}, refreshInteractivity() {}, + DRAG_THRESHOLD: 4, DIRECTION_HYSTERESIS: 12, + console: { warn() {}, debug() {}, error() {} }, + }); + vm.runInContext(`${handler}\nglobalThis.dragApi = { onSpritePointerDown, onSpritePointerMove };`, context); + const api = context.dragApi; + // screenMode 'local' reproduces the popped-out window, where screenX is the + // pointer position inside the window instead of a screen coordinate. The + // drag must not read screenX in either mode. + const event = (type, pointer, screenMode) => { + const clientX = pointer.x - host.x; + const clientY = pointer.y - host.y; + return { + pointerId: 1, button: 0, buttons: 1, clientX, clientY, + screenX: screenMode === 'absolute' ? pointer.x : clientX, + screenY: screenMode === 'absolute' ? pointer.y : clientY, + preventDefault() {}, + }; + }; + return { + calls, app, host, + press: (pointer, screenMode) => api.onSpritePointerDown(event('pointerdown', pointer, screenMode)), + move: (pointer, screenMode) => api.onSpritePointerMove(event('pointermove', pointer, screenMode)), + updates: () => calls.filter((call) => call[0] === 'update'), + plays: () => calls.filter((call) => call[0] === 'play').map((call) => call[1]), + }; +} + +for (const screenMode of ['local', 'absolute']) { + test(`a held pointer keeps the window still (${screenMode} screenX)`, () => { + const h = harness(); + const pointer = { x: 1100, y: 700 }; + h.press(pointer, screenMode); + // The cursor moves 30px to the right; the window follows. + pointer.x = 1130; + h.move(pointer, screenMode); + assert.deepEqual(h.updates(), [['update', 1030, 500]]); + // The cursor stops. The window moved under it, so the pointer offset inside + // the window shrinks back to the grab point: the window must not move. + h.move(pointer, screenMode); + h.move(pointer, screenMode); + assert.deepEqual(h.updates(), [['update', 1030, 500]]); + assert.deepEqual(h.host, { x: 1030, y: 500 }); + }); + + test(`the window follows a moving pointer (${screenMode} screenX)`, () => { + const h = harness(); + const pointer = { x: 1100, y: 700 }; + h.press(pointer, screenMode); + pointer.x = 1130; + h.move(pointer, screenMode); + pointer.x = 1200; + h.move(pointer, screenMode); + // The window origin is the pointer position minus the grab offset. + assert.deepEqual(h.updates(), [['update', 1030, 500], ['update', 1100, 500]]); + assert.deepEqual(h.host, { x: 1100, y: 500 }); + }); + + test(`a refused move resumes from the window the Host kept (${screenMode} screenX)`, async () => { + const h = harness({ reject: true }); + const pointer = { x: 1100, y: 700 }; + h.press(pointer, screenMode); + pointer.x = 1130; + h.move(pointer, screenMode); + await Promise.resolve(); + // The Host refused the move, so the window stayed at 1000. + pointer.x = 1160; + h.move(pointer, screenMode); + assert.deepEqual(h.updates(), [['update', 1030, 500], ['update', 1060, 500]]); + }); +} + +test('the drag direction follows the window target, not the pointer inside the window', () => { + const h = harness(); + const pointer = { x: 1100, y: 700 }; + h.press(pointer, 'local'); + pointer.x = 1200; + h.move(pointer, 'local'); + // The window caught up, so the pointer offset inside the window is back to + // the grab point while the window keeps moving to the right. + h.move(pointer, 'local'); + assert.deepEqual(h.plays(), ['running-right']); +}); + +test('a movement below the threshold does not move the window', () => { + const h = harness(); + const pointer = { x: 1100, y: 700 }; + h.press(pointer, 'local'); + pointer.x = 1102; + h.move(pointer, 'local'); + assert.deepEqual(h.updates(), []); + assert.equal(h.app.dragging, false); +}); diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/index.html b/sources/minimax-ai/minimax-pet/miniapp/client/index.html index 24c255f..838f63f 100644 --- a/sources/minimax-ai/minimax-pet/miniapp/client/index.html +++ b/sources/minimax-ai/minimax-pet/miniapp/client/index.html @@ -181,7 +181,7 @@

任务连接

版本与帮助
-

MiniMax Pet 0.9.0

+

MiniMax Pet 0.9.1

diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/pet-app.js b/sources/minimax-ai/minimax-pet/miniapp/client/pet-app.js index 81b26a2..4213a6d 100644 --- a/sources/minimax-ai/minimax-pet/miniapp/client/pet-app.js +++ b/sources/minimax-ai/minimax-pet/miniapp/client/pet-app.js @@ -896,24 +896,40 @@ function onSpritePointerDown(e) { } catch (error) { console.warn('[pet] pointer_capture_failed:', errCode(error), error); } + const origin = app.winPos ? { ...app.winPos } : null; app.drag = { pointerId: e.pointerId, - startX: e.screenX, - startY: e.screenY, - win: app.winPos ? { ...app.winPos } : null, + // A popped-out window reports screenX relative to its own frame, so the + // pointer position inside the window plus the window origin is the only + // place the window can go. + grabX: e.clientX, + grabY: e.clientY, + // Window origin at grab time; null when the Host reported no position. + base: origin, + // Optimistic window origin: the position the Host accepted last. + origin: origin ? { ...origin } : null, + // Reference for the run direction: the window origin when it is known, + // otherwise the pointer position inside the window. + ref: origin ? origin.x : e.clientX, active: false, dir: null, - extreme: e.screenX, + extreme: origin ? origin.x : e.clientX, }; } function onSpritePointerMove(e) { const d = app.drag; if (!d || e.pointerId !== d.pointerId) return; - const dx = e.screenX - d.startX; - const dy = e.screenY - d.startY; + const dx = e.clientX - d.grabX; + const dy = e.clientY - d.grabY; + // The window follows the pointer, so the pointer offset inside the window + // shrinks back to the grab point. The target is measured against the window + // origin the Host holds, never against the grab position. + const target = d.origin ? { x: Math.round(d.origin.x + dx), y: Math.round(d.origin.y + dy) } : null; if (!d.active) { - if (Math.hypot(dx, dy) <= DRAG_THRESHOLD) return; + // How far the pointer travelled on screen, not inside the window. + const travel = target ? { x: target.x - d.base.x, y: target.y - d.base.y } : { x: dx, y: dy }; + if (Math.hypot(travel.x, travel.y) <= DRAG_THRESHOLD) return; d.active = true; app.dragging = true; app.oneShotToken++; @@ -922,12 +938,22 @@ function onSpritePointerMove(e) { hideQuickbar(); els.sprite.classList.add('is-dragging'); refreshInteractivity(); - if (!d.win) console.warn('[pet] drag_without_window_position: no window position yet, animation only'); + if (!d.origin) console.warn('[pet] drag_without_window_position: no window position yet, animation only'); } - updateDragDirection(d, e.screenX); - if (!d.win) return; - callBridge(() => win.update({ x: Math.round(d.win.x + dx), y: Math.round(d.win.y + dy) })).catch((error) => { + if (!target) { + // No window position: the pet still runs, but nothing can move. + updateDragDirection(d, e.clientX); + return; + } + // The window already sits where the pointer wants it: no update, no loop. + if (target.x === d.origin.x && target.y === d.origin.y) return; + updateDragDirection(d, target.x); + const accepted = d.origin; + d.origin = target; + callBridge(() => win.update({ x: target.x, y: target.y })).catch((error) => { const code = errCode(error); + // The Host refused the move and kept the window where it was. + d.origin = accepted; if (code === 'not_permitted') console.debug('[pet] drag_move_rejected: not_permitted (target is off every display)'); else console.warn('[pet] drag_move_failed:', code, error); }); @@ -947,8 +973,8 @@ function updateDragDirection(d, sx) { d.dir = 'right'; d.extreme = sx; } - } else if (Math.abs(sx - d.startX) > DIRECTION_HYSTERESIS / 2) { - d.dir = sx > d.startX ? 'right' : 'left'; + } else if (Math.abs(sx - d.ref) > DIRECTION_HYSTERESIS / 2) { + d.dir = sx > d.ref ? 'right' : 'left'; d.extreme = sx; } if (d.dir !== before) { diff --git a/sources/minimax-ai/minimax-pet/package-lock.json b/sources/minimax-ai/minimax-pet/package-lock.json index 1cb0ddb..56e49aa 100644 --- a/sources/minimax-ai/minimax-pet/package-lock.json +++ b/sources/minimax-ai/minimax-pet/package-lock.json @@ -1,12 +1,12 @@ { "name": "minimax-pet-source", - "version": "0.9.0", + "version": "0.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "minimax-pet-source", - "version": "0.9.0", + "version": "0.9.1", "devDependencies": { "esbuild": "0.25.12", "jsdom": "24.1.3", diff --git a/sources/minimax-ai/minimax-pet/package.json b/sources/minimax-ai/minimax-pet/package.json index 34fc08d..a36101a 100644 --- a/sources/minimax-ai/minimax-pet/package.json +++ b/sources/minimax-ai/minimax-pet/package.json @@ -1,6 +1,6 @@ { "name": "minimax-pet-source", - "version": "0.9.0", + "version": "0.9.1", "private": true, "type": "module", "description": "桌宠插件的开发与验证工具,不进入分发包。", From 56096b5df8ebc5f40eb40179b98333ffe3791fc9 Mon Sep 17 00:00:00 2001 From: July-X Date: Fri, 13 Nov 2026 15:41:51 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(minimax-pet):=20=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E5=8F=AA=E5=85=81=E8=AE=B8=E4=B8=80=E4=B8=AA=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=9C=A8=E9=80=94=EF=BC=8C=E8=A2=AB=E6=8B=92?= =?UTF-8?q?=E7=9A=84=E6=9B=B4=E6=96=B0=E4=B8=8D=E5=86=8D=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 #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 桌面窗口里实际拖拽,也没有实测宿主对窗口更新的 应答时序。 --- plugins/minimax-ai/minimax-pet/README.md | 4 +- .../minimax-ai/minimax-pet/README.zh-CN.md | 4 +- .../minimax-pet/miniapp/client/pet-app.js | 45 ++++++++-- sources/minimax-ai/minimax-pet/DEVELOPMENT.md | 1 + sources/minimax-ai/minimax-pet/README.md | 4 +- .../minimax-ai/minimax-pet/README.zh-CN.md | 4 +- .../minimax-pet/dev/window-drag.test.mjs | 84 +++++++++++++++---- .../minimax-pet/miniapp/client/pet-app.js | 45 ++++++++-- 8 files changed, 155 insertions(+), 36 deletions(-) diff --git a/plugins/minimax-ai/minimax-pet/README.md b/plugins/minimax-ai/minimax-pet/README.md index 20427cd..8556b25 100644 --- a/plugins/minimax-ai/minimax-pet/README.md +++ b/plugins/minimax-ai/minimax-pet/README.md @@ -16,7 +16,7 @@ A lightweight desktop companion for MiniMax Code, by **MiniMax**. It shows visib The pet does not run Agent turns, create independent chats, write to chat inputs, comment on every session, stop tasks, or answer questionnaires. Selecting a task only reads its details; the main window switches only when you click **打开对话** or **去回答**. -Version **0.9.1** fixes dragging the pet in the popped-out desktop window. The window position is now the pointer's offset inside the window plus the window origin, so the pet follows the pointer, a held pointer leaves the window where it is, and a move the Host refuses continues from the position the Host kept. The running animation uses the same target. +Version **0.9.1** fixes dragging the pet in the popped-out desktop window. The window position is now the pointer's offset inside the window plus the window origin the Host confirmed, so the pet follows the pointer, a held pointer leaves the window where it is, and a move the Host refuses continues from the position the Host kept. One window update is in flight at a time: while the Host moves the window, the latest target waits and is sent when the update returns, so a fast drag never counts a movement twice and a refused update never rolls the window back past a later accepted one. The running animation uses the same target. Version **0.9.0** adds two settings. **Task notifications** sets how many seconds a desktop card stays after a task finishes or fails, entered as a number and saved on the device; the default is 10 and **0** keeps the card until you close it. A card hiding never marks the task read, cards that need your answer never hide, and pointing at or focusing the cards pauses the countdown. **Language** follows the MiniMax Code interface language by default and can be pinned to 简体中文 or English; the whole panel and desktop copy switch, while conversation titles and replies stay as the Agent wrote them. @@ -49,7 +49,7 @@ Earlier local prototypes use a different plugin ID. The Host gives `minimax-pet` - An earlier UI version was exercised on macOS in a MiniMax Code development build: three tabs, wide/narrow layouts, task reading, real session/ask activity, import/delete, and desktop round trips. Version 0.6.0 has Node/DOM tests (including the open and theme paths against a simulated Host), an isolated HTTP smoke test, and a browser check of the panel layout outside the Host. - The default atlas is checked by actual PNG decoding, frame coverage and deterministic rebuild tests. The separate octopus icon is checked by PNG decoding and visual review. On 2026-10-26, the packaged Skill was exercised with a real Agent in the macOS development build to create, validate, preview and import a custom v2 pet; all nine animation previews and the four cardinal mouse-look directions were checked. This does not replace released-client acceptance. - Version 0.9.0 has Node/DOM tests for the card timer, the preference round trip and the copy of both languages (every key the page uses must exist in both), plus a browser check of the panel and desktop window in both languages against a simulated Host. Not verified for 0.9.0: the language the real client reports on any given install, and both languages inside a real MiniMax Code window. -- Version 0.9.1 has Node tests for the desktop drag: a held pointer leaves the window where it is, a moving pointer is followed, a refused move continues from the position the Host kept, and the run direction follows the window target. Not verified for 0.9.1: dragging the pet inside a real MiniMax Code desktop window. +- Version 0.9.1 has Node tests for the desktop drag: a held pointer leaves the window where it is, a moving pointer is followed, a refused move continues from the position the Host kept, a refused update does not roll the window back past a later accepted one, a fast drag counts every movement once, and the run direction follows the window target. Not verified for 0.9.1: dragging the pet inside a real MiniMax Code desktop window, and the timing of the Host's answer to a window update. - Not verified for 0.6.0: opening a conversation or following the theme inside a real MiniMax Code 3.1.11+ window (panel or desktop pet), any Windows GUI, and released-client installation or GUI acceptance on 3.1.6. Automated tests use synthetic data and do not prove those paths. ## Data & access diff --git a/plugins/minimax-ai/minimax-pet/README.zh-CN.md b/plugins/minimax-ai/minimax-pet/README.zh-CN.md index 965882d..8013334 100644 --- a/plugins/minimax-ai/minimax-pet/README.zh-CN.md +++ b/plugins/minimax-ai/minimax-pet/README.zh-CN.md @@ -18,7 +18,7 @@ **0.5.11** 新增可选内置宠物 **MMX 章鱼娘**,是一位橙发、白背心、棕格纹短裙的 Q 版全身章鱼娘。在“外观 → 选择宠物”中选择她即可;薄荷色机器人仍为默认宠物。她采用 v1 图集,包含九种动作,不含十六个视线方向,因此鼠标跟随对她不生效;沿用六帧、6.6 秒一轮的慢速待机,不新增权限或改变播放器。图集已通过校验。创作流程和贡献者许可见[素材说明](ARTWORK.md)。 -**0.9.1** 修复独立窗口里拖动桌宠的问题。窗口位置改为按指针在窗口内的位移加窗口原点计算:桌宠跟着指针走,按住不动时窗口停在原地,被宿主拒绝的移动会从宿主保留的位置继续。跑动方向的动画改用同一个目标位置。 +**0.9.1** 修复独立窗口里拖动桌宠的问题。窗口位置改为按指针在窗口内的位移加宿主确认的窗口原点计算:桌宠跟着指针走,按住不动时窗口停在原地,被宿主拒绝的移动会从宿主保留的位置继续。同一时刻只有一个窗口更新在途:宿主移动窗口期间,最新的目标位置等着,更新返回后再发,因此快速拖拽不会把同一段位移算两遍,被拒绝的更新也不会把窗口位置回退到更早一次成功移动之前。跑动方向的动画改用同一个目标位置。 **0.9.0** 新增两项设置。“任务通知”用数字框设置桌面卡片在任务完成或异常后显示多少秒,默认 10 秒,填 0 表示一直显示到手动关闭;设置保存在本机。卡片自动收起不会把任务标记为已读,需要你回答的卡片不会自动收起,鼠标悬停或焦点停留时会暂停计时。“语言”默认跟随 MiniMax Code 的界面语言,也可以固定为简体中文或 English;面板和桌面文案整体切换,对话标题和回复保持 Agent 原文。 @@ -40,7 +40,7 @@ 任务监控使用 `agent.sessions/session/observe/cancel`,需要 `session.read` 授权。保留运行时能力和权限检查,缺少接口或关闭授权时显示不可用。“打开对话/去回答”使用 `host.navigation.open`,主题跟随使用 `host.ui.subscribe`,两者都从 MiniMax Code **3.1.11** 起提供;2026-11-10 已确认 3.1.11 的 macOS 与 Windows 安装包在两个正式 CDN 上均可下载。两项调用已对照 MiniMax Code 源码核对:打开插件已持有的会话不需要 `hostAccess` 或其他授权,必须在点击中发起,并会把主窗口带到前台。 -0.9.1 的拖拽有 Node 测试:按住不动窗口停在原地,指针移动时窗口跟随,被宿主拒绝的移动从宿主保留的位置继续,跑动方向跟随窗口目标。未在真实的 MiniMax Code 桌面窗口里验收拖拽。 +0.9.1 的拖拽有 Node 测试:按住不动窗口停在原地,指针移动时窗口跟随,被宿主拒绝的移动从宿主保留的位置继续,被拒绝的更新不会把窗口回退到更早一次成功移动之前,快速拖拽只把同一段位移算一遍,跑动方向跟随窗口目标。未在真实的 MiniMax Code 桌面窗口里验收拖拽,也没有实测宿主对窗口更新的应答时序。 更早的 UI 版本已在 macOS 开发版验收三页签、窄宽布局、真实任务与 ask、导入删除和桌面切换;0.6.0 运行 Node/DOM 测试(含模拟 Host 下的打开对话与主题路径)、隔离 HTTP smoke,并在 Host 之外用浏览器检查了面板布局。默认图集通过 PNG 解码、动画帧覆盖与确定性重建检查;独立的章鱼图标通过 PNG 解码和视觉检查。2026-10-26,已用 macOS 开发版中的真实 Agent 调用随包 Skill,完成自定义 v2 宠物的生成、校验、预览和导入,并检查九种动作预览与鼠标上/右/下/左四向注视。0.6.0 尚未在真实的 MiniMax Code 3.1.11+ 窗口(面板或桌面宠物)中验收打开对话和主题跟随,也未验收 Windows GUI、正式客户端安装或 3.1.6 正式版 GUI;合成数据测试不能证明这些路径。 diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/pet-app.js b/plugins/minimax-ai/minimax-pet/miniapp/client/pet-app.js index 4213a6d..7599322 100644 --- a/plugins/minimax-ai/minimax-pet/miniapp/client/pet-app.js +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/pet-app.js @@ -906,8 +906,11 @@ function onSpritePointerDown(e) { grabY: e.clientY, // Window origin at grab time; null when the Host reported no position. base: origin, - // Optimistic window origin: the position the Host accepted last. + // Window origin the Host confirmed; every target is measured against it. origin: origin ? { ...origin } : null, + // Latest target not sent yet, and whether an update is already in flight. + pending: null, + sending: false, // Reference for the run direction: the window origin when it is known, // otherwise the pointer position inside the window. ref: origin ? origin.x : e.clientX, @@ -945,17 +948,43 @@ function onSpritePointerMove(e) { updateDragDirection(d, e.clientX); return; } - // The window already sits where the pointer wants it: no update, no loop. + // The pointer sits at the grab point inside the window: the window already + // is where the pointer wants it, so there is nothing to send. + if (dx === 0 && dy === 0) { + d.pending = null; + return; + } + // Keep only the latest target; one update is in flight at a time. + d.pending = target; + flushDrag(d); +} + +// One window update at a time. The Host answers with the position it holds, +// so the next target is measured against a confirmed position, and a refused +// update can never roll the window back past a later accepted one. +function flushDrag(d) { + if (d.sending || !d.pending) return; + const target = d.pending; + d.pending = null; if (target.x === d.origin.x && target.y === d.origin.y) return; + d.sending = true; updateDragDirection(d, target.x); - const accepted = d.origin; - d.origin = target; - callBridge(() => win.update({ x: target.x, y: target.y })).catch((error) => { - const code = errCode(error); + callBridge(() => win.update({ x: target.x, y: target.y })).then((state) => { + d.sending = false; + const moved = state?.window; + if (moved) { + d.origin = { x: moved.x, y: moved.y }; + app.winPos = { x: moved.x, y: moved.y }; + } else if (app.winPos) d.origin = { ...app.winPos }; + flushDrag(d); + }, (error) => { + d.sending = false; // The Host refused the move and kept the window where it was. - d.origin = accepted; + if (app.winPos) d.origin = { ...app.winPos }; + const code = errCode(error); if (code === 'not_permitted') console.debug('[pet] drag_move_rejected: not_permitted (target is off every display)'); else console.warn('[pet] drag_move_failed:', code, error); + flushDrag(d); }); } @@ -986,6 +1015,8 @@ function finishDrag(e, cancelled) { const d = app.drag; if (!d || e.pointerId !== d.pointerId) return; app.drag = null; + // The last pointer position is where the pet belongs: send it. + if (!cancelled) flushDrag(d); try { if (els.sprite.hasPointerCapture(e.pointerId)) els.sprite.releasePointerCapture(e.pointerId); } catch (error) { diff --git a/sources/minimax-ai/minimax-pet/DEVELOPMENT.md b/sources/minimax-ai/minimax-pet/DEVELOPMENT.md index b3c4c20..3b44e58 100644 --- a/sources/minimax-ai/minimax-pet/DEVELOPMENT.md +++ b/sources/minimax-ai/minimax-pet/DEVELOPMENT.md @@ -29,6 +29,7 @@ npm run check:package - Resizing the desktop pet first brings the transparent window inside the available screen area so smaller sprites remain reachable. - Click-through follows the pixels, never a whole mode: the same rule covers the pet, the bubble, the notification stack, the quick bar, the menu and the open card. An expanded card window is 300x488 around a 284x147 card, so treating "card open" as "window interactive" swallows every click in the margin. Only a drag or an open menu justify the whole window, and a card opened from the keyboard (no pointer sample yet) stays interactive. - Opening or closing the card must never change the window geometry. The window is one fixed 280 x `deskHeight(scale)` size that already fits the open card; resizing on click moves the native origin, and the previous layout is repainted at the new top-left for a frame, so the pet visibly jumps up (and left, while the card was wider). `dev/desk-interactivity.test.mjs` fails if `openWorkbench` or `closeWorkbench` touches geometry, and checks the fixed height still leaves room for the card at every pet scale. The area above the pet is transparent and forwards clicks, so a permanently tall window costs no desktop clicks. +- Dragging the pet moves the window by the pointer's offset inside the window plus the window origin the Host confirmed, never by a screen coordinate: `screenX` in the popped-out window is relative to its own frame. One `win.update` is in flight at a time; while it is, only the latest target waits, and a refused update falls back to the Host-confirmed position. `dev/window-drag.test.mjs` fails if a refused update rolls the window back past a later accepted one, or if a fast drag counts a movement twice. - Opening the card must not change the running sprite animation. Hover runs the `jumping` row; reverting to the base state on open restarts it at frame 0, which reads as a flicker on every click. The menu still takes the pet back to its base animation, because the menu is a deliberate choice. - Every user-visible string comes from `miniapp/client/i18n.mjs` through `t()`; static markup carries `data-i18n` / `data-i18n-attr`. Keys are `..`, interpolation is `{{name}}`, a missing key renders as the key itself, and `dev/i18n.test.mjs` fails when a key the client uses is missing from either language. Conversation titles, replies and error text from the Host are data and stay in the language the Agent wrote them. - Text created once when a view is built (segmented labels, compact-view buttons) must be re-applied on a language change, or it keeps the language of the moment it was built. diff --git a/sources/minimax-ai/minimax-pet/README.md b/sources/minimax-ai/minimax-pet/README.md index 20427cd..8556b25 100644 --- a/sources/minimax-ai/minimax-pet/README.md +++ b/sources/minimax-ai/minimax-pet/README.md @@ -16,7 +16,7 @@ A lightweight desktop companion for MiniMax Code, by **MiniMax**. It shows visib The pet does not run Agent turns, create independent chats, write to chat inputs, comment on every session, stop tasks, or answer questionnaires. Selecting a task only reads its details; the main window switches only when you click **打开对话** or **去回答**. -Version **0.9.1** fixes dragging the pet in the popped-out desktop window. The window position is now the pointer's offset inside the window plus the window origin, so the pet follows the pointer, a held pointer leaves the window where it is, and a move the Host refuses continues from the position the Host kept. The running animation uses the same target. +Version **0.9.1** fixes dragging the pet in the popped-out desktop window. The window position is now the pointer's offset inside the window plus the window origin the Host confirmed, so the pet follows the pointer, a held pointer leaves the window where it is, and a move the Host refuses continues from the position the Host kept. One window update is in flight at a time: while the Host moves the window, the latest target waits and is sent when the update returns, so a fast drag never counts a movement twice and a refused update never rolls the window back past a later accepted one. The running animation uses the same target. Version **0.9.0** adds two settings. **Task notifications** sets how many seconds a desktop card stays after a task finishes or fails, entered as a number and saved on the device; the default is 10 and **0** keeps the card until you close it. A card hiding never marks the task read, cards that need your answer never hide, and pointing at or focusing the cards pauses the countdown. **Language** follows the MiniMax Code interface language by default and can be pinned to 简体中文 or English; the whole panel and desktop copy switch, while conversation titles and replies stay as the Agent wrote them. @@ -49,7 +49,7 @@ Earlier local prototypes use a different plugin ID. The Host gives `minimax-pet` - An earlier UI version was exercised on macOS in a MiniMax Code development build: three tabs, wide/narrow layouts, task reading, real session/ask activity, import/delete, and desktop round trips. Version 0.6.0 has Node/DOM tests (including the open and theme paths against a simulated Host), an isolated HTTP smoke test, and a browser check of the panel layout outside the Host. - The default atlas is checked by actual PNG decoding, frame coverage and deterministic rebuild tests. The separate octopus icon is checked by PNG decoding and visual review. On 2026-10-26, the packaged Skill was exercised with a real Agent in the macOS development build to create, validate, preview and import a custom v2 pet; all nine animation previews and the four cardinal mouse-look directions were checked. This does not replace released-client acceptance. - Version 0.9.0 has Node/DOM tests for the card timer, the preference round trip and the copy of both languages (every key the page uses must exist in both), plus a browser check of the panel and desktop window in both languages against a simulated Host. Not verified for 0.9.0: the language the real client reports on any given install, and both languages inside a real MiniMax Code window. -- Version 0.9.1 has Node tests for the desktop drag: a held pointer leaves the window where it is, a moving pointer is followed, a refused move continues from the position the Host kept, and the run direction follows the window target. Not verified for 0.9.1: dragging the pet inside a real MiniMax Code desktop window. +- Version 0.9.1 has Node tests for the desktop drag: a held pointer leaves the window where it is, a moving pointer is followed, a refused move continues from the position the Host kept, a refused update does not roll the window back past a later accepted one, a fast drag counts every movement once, and the run direction follows the window target. Not verified for 0.9.1: dragging the pet inside a real MiniMax Code desktop window, and the timing of the Host's answer to a window update. - Not verified for 0.6.0: opening a conversation or following the theme inside a real MiniMax Code 3.1.11+ window (panel or desktop pet), any Windows GUI, and released-client installation or GUI acceptance on 3.1.6. Automated tests use synthetic data and do not prove those paths. ## Data & access diff --git a/sources/minimax-ai/minimax-pet/README.zh-CN.md b/sources/minimax-ai/minimax-pet/README.zh-CN.md index 965882d..8013334 100644 --- a/sources/minimax-ai/minimax-pet/README.zh-CN.md +++ b/sources/minimax-ai/minimax-pet/README.zh-CN.md @@ -18,7 +18,7 @@ **0.5.11** 新增可选内置宠物 **MMX 章鱼娘**,是一位橙发、白背心、棕格纹短裙的 Q 版全身章鱼娘。在“外观 → 选择宠物”中选择她即可;薄荷色机器人仍为默认宠物。她采用 v1 图集,包含九种动作,不含十六个视线方向,因此鼠标跟随对她不生效;沿用六帧、6.6 秒一轮的慢速待机,不新增权限或改变播放器。图集已通过校验。创作流程和贡献者许可见[素材说明](ARTWORK.md)。 -**0.9.1** 修复独立窗口里拖动桌宠的问题。窗口位置改为按指针在窗口内的位移加窗口原点计算:桌宠跟着指针走,按住不动时窗口停在原地,被宿主拒绝的移动会从宿主保留的位置继续。跑动方向的动画改用同一个目标位置。 +**0.9.1** 修复独立窗口里拖动桌宠的问题。窗口位置改为按指针在窗口内的位移加宿主确认的窗口原点计算:桌宠跟着指针走,按住不动时窗口停在原地,被宿主拒绝的移动会从宿主保留的位置继续。同一时刻只有一个窗口更新在途:宿主移动窗口期间,最新的目标位置等着,更新返回后再发,因此快速拖拽不会把同一段位移算两遍,被拒绝的更新也不会把窗口位置回退到更早一次成功移动之前。跑动方向的动画改用同一个目标位置。 **0.9.0** 新增两项设置。“任务通知”用数字框设置桌面卡片在任务完成或异常后显示多少秒,默认 10 秒,填 0 表示一直显示到手动关闭;设置保存在本机。卡片自动收起不会把任务标记为已读,需要你回答的卡片不会自动收起,鼠标悬停或焦点停留时会暂停计时。“语言”默认跟随 MiniMax Code 的界面语言,也可以固定为简体中文或 English;面板和桌面文案整体切换,对话标题和回复保持 Agent 原文。 @@ -40,7 +40,7 @@ 任务监控使用 `agent.sessions/session/observe/cancel`,需要 `session.read` 授权。保留运行时能力和权限检查,缺少接口或关闭授权时显示不可用。“打开对话/去回答”使用 `host.navigation.open`,主题跟随使用 `host.ui.subscribe`,两者都从 MiniMax Code **3.1.11** 起提供;2026-11-10 已确认 3.1.11 的 macOS 与 Windows 安装包在两个正式 CDN 上均可下载。两项调用已对照 MiniMax Code 源码核对:打开插件已持有的会话不需要 `hostAccess` 或其他授权,必须在点击中发起,并会把主窗口带到前台。 -0.9.1 的拖拽有 Node 测试:按住不动窗口停在原地,指针移动时窗口跟随,被宿主拒绝的移动从宿主保留的位置继续,跑动方向跟随窗口目标。未在真实的 MiniMax Code 桌面窗口里验收拖拽。 +0.9.1 的拖拽有 Node 测试:按住不动窗口停在原地,指针移动时窗口跟随,被宿主拒绝的移动从宿主保留的位置继续,被拒绝的更新不会把窗口回退到更早一次成功移动之前,快速拖拽只把同一段位移算一遍,跑动方向跟随窗口目标。未在真实的 MiniMax Code 桌面窗口里验收拖拽,也没有实测宿主对窗口更新的应答时序。 更早的 UI 版本已在 macOS 开发版验收三页签、窄宽布局、真实任务与 ask、导入删除和桌面切换;0.6.0 运行 Node/DOM 测试(含模拟 Host 下的打开对话与主题路径)、隔离 HTTP smoke,并在 Host 之外用浏览器检查了面板布局。默认图集通过 PNG 解码、动画帧覆盖与确定性重建检查;独立的章鱼图标通过 PNG 解码和视觉检查。2026-10-26,已用 macOS 开发版中的真实 Agent 调用随包 Skill,完成自定义 v2 宠物的生成、校验、预览和导入,并检查九种动作预览与鼠标上/右/下/左四向注视。0.6.0 尚未在真实的 MiniMax Code 3.1.11+ 窗口(面板或桌面宠物)中验收打开对话和主题跟随,也未验收 Windows GUI、正式客户端安装或 3.1.6 正式版 GUI;合成数据测试不能证明这些路径。 diff --git a/sources/minimax-ai/minimax-pet/dev/window-drag.test.mjs b/sources/minimax-ai/minimax-pet/dev/window-drag.test.mjs index 5356d28..55f2d3a 100644 --- a/sources/minimax-ai/minimax-pet/dev/window-drag.test.mjs +++ b/sources/minimax-ai/minimax-pet/dev/window-drag.test.mjs @@ -8,9 +8,10 @@ const handler = source.slice(source.indexOf('function onSpritePointerDown'), sou // The Host keeps the window origin; a pointer inside the window is measured // from that origin, so the pointer offset shrinks while the window follows it. -function harness({ reject = false } = {}) { +function harness({ failFirst = 0 } = {}) { const calls = []; const host = { x: 1000, y: 500 }; + let failed = 0; const app = { mode: 'window', pet: {}, winPos: { ...host }, drag: null, dragging: false, oneShotToken: 0, oneShot: false, menuOpen: false, workbenchOpen: false, @@ -24,11 +25,17 @@ function harness({ reject = false } = {}) { win: { update(patch) { calls.push(['update', patch.x, patch.y]); - if (reject) return Promise.reject(Object.assign(new Error('off screen'), { code: 'not_permitted' })); - host.x = patch.x; - host.y = patch.y; - app.winPos = { x: patch.x, y: patch.y }; - return Promise.resolve({ mode: 'window', window: { ...host, width: 280, height: 340 } }); + if (failed < failFirst) { + failed += 1; + return Promise.reject(Object.assign(new Error('off screen'), { code: 'not_permitted' })); + } + // The Host moves the window and answers with the position it holds. + return Promise.resolve().then(() => { + host.x = patch.x; + host.y = patch.y; + app.winPos = { x: patch.x, y: patch.y }; + return { mode: 'window', window: { ...host, width: 280, height: 340 } }; + }); }, }, callBridge: (fn) => { try { return Promise.resolve(fn()); } catch (error) { return Promise.reject(error); } }, @@ -60,71 +67,120 @@ function harness({ reject = false } = {}) { move: (pointer, screenMode) => api.onSpritePointerMove(event('pointermove', pointer, screenMode)), updates: () => calls.filter((call) => call[0] === 'update'), plays: () => calls.filter((call) => call[0] === 'play').map((call) => call[1]), + // Let the Host answer, and let the answer send the next update. + settle: async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }, }; } for (const screenMode of ['local', 'absolute']) { - test(`a held pointer keeps the window still (${screenMode} screenX)`, () => { + test(`a held pointer keeps the window still (${screenMode} screenX)`, async () => { const h = harness(); const pointer = { x: 1100, y: 700 }; h.press(pointer, screenMode); // The cursor moves 30px to the right; the window follows. pointer.x = 1130; h.move(pointer, screenMode); - assert.deepEqual(h.updates(), [['update', 1030, 500]]); + await h.settle(); // The cursor stops. The window moved under it, so the pointer offset inside // the window shrinks back to the grab point: the window must not move. h.move(pointer, screenMode); - h.move(pointer, screenMode); + await h.settle(); assert.deepEqual(h.updates(), [['update', 1030, 500]]); assert.deepEqual(h.host, { x: 1030, y: 500 }); }); - test(`the window follows a moving pointer (${screenMode} screenX)`, () => { + test(`the window follows a moving pointer (${screenMode} screenX)`, async () => { const h = harness(); const pointer = { x: 1100, y: 700 }; h.press(pointer, screenMode); pointer.x = 1130; h.move(pointer, screenMode); + await h.settle(); pointer.x = 1200; h.move(pointer, screenMode); + await h.settle(); // The window origin is the pointer position minus the grab offset. assert.deepEqual(h.updates(), [['update', 1030, 500], ['update', 1100, 500]]); assert.deepEqual(h.host, { x: 1100, y: 500 }); }); test(`a refused move resumes from the window the Host kept (${screenMode} screenX)`, async () => { - const h = harness({ reject: true }); + const h = harness({ failFirst: Infinity }); const pointer = { x: 1100, y: 700 }; h.press(pointer, screenMode); pointer.x = 1130; h.move(pointer, screenMode); - await Promise.resolve(); + await h.settle(); // The Host refused the move, so the window stayed at 1000. pointer.x = 1160; h.move(pointer, screenMode); + await h.settle(); assert.deepEqual(h.updates(), [['update', 1030, 500], ['update', 1060, 500]]); }); } -test('the drag direction follows the window target, not the pointer inside the window', () => { +test('the drag direction follows the window target, not the pointer inside the window', async () => { const h = harness(); const pointer = { x: 1100, y: 700 }; h.press(pointer, 'local'); pointer.x = 1200; h.move(pointer, 'local'); + await h.settle(); // The window caught up, so the pointer offset inside the window is back to // the grab point while the window keeps moving to the right. h.move(pointer, 'local'); + await h.settle(); assert.deepEqual(h.plays(), ['running-right']); }); -test('a movement below the threshold does not move the window', () => { +test('a movement below the threshold does not move the window', async () => { const h = harness(); const pointer = { x: 1100, y: 700 }; h.press(pointer, 'local'); pointer.x = 1102; h.move(pointer, 'local'); + await h.settle(); assert.deepEqual(h.updates(), []); assert.equal(h.app.dragging, false); }); + +test('a refused update does not roll the origin back past a later accepted one', async () => { + const h = harness({ failFirst: 1 }); + const pointer = { x: 1100, y: 700 }; + h.press(pointer, 'local'); + // The second move arrives while the first update is still in flight, so the + // refused answer comes back after a later update was already accepted. + pointer.x = 1130; + h.move(pointer, 'local'); + pointer.x = 1190; + h.move(pointer, 'local'); + await h.settle(); + // The refused update did not move the window; the accepted one did. + assert.deepEqual(h.updates(), [['update', 1030, 500], ['update', 1090, 500]]); + pointer.x = 1170; + h.move(pointer, 'local'); + await h.settle(); + // The next target is measured against the position the Host holds (1090), + // not against the one the refused update rolled back to (1000). + assert.deepEqual(h.updates(), [['update', 1030, 500], ['update', 1090, 500], ['update', 1070, 500]]); + assert.deepEqual(h.host, { x: 1070, y: 500 }); +}); + +test('a fast drag counts a movement that has not landed yet only once', async () => { + const h = harness(); + const pointer = { x: 1100, y: 700 }; + h.press(pointer, 'local'); + // The second move arrives before the Host applied the first one. + pointer.x = 1130; + h.move(pointer, 'local'); + pointer.x = 1160; + h.move(pointer, 'local'); + await h.settle(); + // One update is in flight at a time, and the window lands on the pointer + // instead of past it. + assert.deepEqual(h.updates(), [['update', 1030, 500], ['update', 1060, 500]]); + assert.deepEqual(h.host, { x: 1060, y: 500 }); +}); diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/pet-app.js b/sources/minimax-ai/minimax-pet/miniapp/client/pet-app.js index 4213a6d..7599322 100644 --- a/sources/minimax-ai/minimax-pet/miniapp/client/pet-app.js +++ b/sources/minimax-ai/minimax-pet/miniapp/client/pet-app.js @@ -906,8 +906,11 @@ function onSpritePointerDown(e) { grabY: e.clientY, // Window origin at grab time; null when the Host reported no position. base: origin, - // Optimistic window origin: the position the Host accepted last. + // Window origin the Host confirmed; every target is measured against it. origin: origin ? { ...origin } : null, + // Latest target not sent yet, and whether an update is already in flight. + pending: null, + sending: false, // Reference for the run direction: the window origin when it is known, // otherwise the pointer position inside the window. ref: origin ? origin.x : e.clientX, @@ -945,17 +948,43 @@ function onSpritePointerMove(e) { updateDragDirection(d, e.clientX); return; } - // The window already sits where the pointer wants it: no update, no loop. + // The pointer sits at the grab point inside the window: the window already + // is where the pointer wants it, so there is nothing to send. + if (dx === 0 && dy === 0) { + d.pending = null; + return; + } + // Keep only the latest target; one update is in flight at a time. + d.pending = target; + flushDrag(d); +} + +// One window update at a time. The Host answers with the position it holds, +// so the next target is measured against a confirmed position, and a refused +// update can never roll the window back past a later accepted one. +function flushDrag(d) { + if (d.sending || !d.pending) return; + const target = d.pending; + d.pending = null; if (target.x === d.origin.x && target.y === d.origin.y) return; + d.sending = true; updateDragDirection(d, target.x); - const accepted = d.origin; - d.origin = target; - callBridge(() => win.update({ x: target.x, y: target.y })).catch((error) => { - const code = errCode(error); + callBridge(() => win.update({ x: target.x, y: target.y })).then((state) => { + d.sending = false; + const moved = state?.window; + if (moved) { + d.origin = { x: moved.x, y: moved.y }; + app.winPos = { x: moved.x, y: moved.y }; + } else if (app.winPos) d.origin = { ...app.winPos }; + flushDrag(d); + }, (error) => { + d.sending = false; // The Host refused the move and kept the window where it was. - d.origin = accepted; + if (app.winPos) d.origin = { ...app.winPos }; + const code = errCode(error); if (code === 'not_permitted') console.debug('[pet] drag_move_rejected: not_permitted (target is off every display)'); else console.warn('[pet] drag_move_failed:', code, error); + flushDrag(d); }); } @@ -986,6 +1015,8 @@ function finishDrag(e, cancelled) { const d = app.drag; if (!d || e.pointerId !== d.pointerId) return; app.drag = null; + // The last pointer position is where the pet belongs: send it. + if (!cancelled) flushDrag(d); try { if (els.sprite.hasPointerCapture(e.pointerId)) els.sprite.releasePointerCapture(e.pointerId); } catch (error) { From d779e2e7ee019200a161abecfb99f7ba460b3c3b Mon Sep 17 00:00:00 2001 From: July-X Date: Fri, 13 Nov 2026 17:10:03 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(minimax-pet):=20=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E6=94=B9=E6=8C=89=E6=8C=87=E9=92=88=E5=9C=A8?= =?UTF-8?q?=E5=B1=8F=E5=B9=95=E4=B8=8A=E7=9A=84=E4=BD=8D=E7=BD=AE=E7=AE=97?= =?UTF-8?q?=EF=BC=8C=E5=AE=BF=E4=B8=BB=E5=B7=B2=E7=A7=BB=E5=8A=A8=E8=80=8C?= =?UTF-8?q?=E5=BA=94=E7=AD=94=E6=9C=AA=E5=9B=9E=E6=97=B6=E4=B8=8D=E5=9B=9E?= =?UTF-8?q?=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 #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 桌面窗口里实际拖拽,也没有实测宿主对窗口更新的 应答时序。 --- plugins/minimax-ai/minimax-pet/README.md | 4 +- .../minimax-ai/minimax-pet/README.zh-CN.md | 4 +- .../minimax-pet/miniapp/client/pet-app.js | 47 +++++++--- sources/minimax-ai/minimax-pet/DEVELOPMENT.md | 2 +- sources/minimax-ai/minimax-pet/README.md | 4 +- .../minimax-ai/minimax-pet/README.zh-CN.md | 4 +- .../minimax-pet/dev/window-drag.test.mjs | 87 +++++++++++++------ .../minimax-pet/miniapp/client/pet-app.js | 47 +++++++--- 8 files changed, 136 insertions(+), 63 deletions(-) diff --git a/plugins/minimax-ai/minimax-pet/README.md b/plugins/minimax-ai/minimax-pet/README.md index 8556b25..d7dc060 100644 --- a/plugins/minimax-ai/minimax-pet/README.md +++ b/plugins/minimax-ai/minimax-pet/README.md @@ -16,7 +16,7 @@ A lightweight desktop companion for MiniMax Code, by **MiniMax**. It shows visib The pet does not run Agent turns, create independent chats, write to chat inputs, comment on every session, stop tasks, or answer questionnaires. Selecting a task only reads its details; the main window switches only when you click **打开对话** or **去回答**. -Version **0.9.1** fixes dragging the pet in the popped-out desktop window. The window position is now the pointer's offset inside the window plus the window origin the Host confirmed, so the pet follows the pointer, a held pointer leaves the window where it is, and a move the Host refuses continues from the position the Host kept. One window update is in flight at a time: while the Host moves the window, the latest target waits and is sent when the update returns, so a fast drag never counts a movement twice and a refused update never rolls the window back past a later accepted one. The running animation uses the same target. +Version **0.9.1** fixes dragging the pet in the popped-out desktop window. The window now goes where the pointer is, minus the offset it was grabbed at. The pointer is measured inside the window, so its position is read against the frame the event was measured in: the origin the Host confirmed, or the origin the Host already moved to while the answer is still pending. One window update is in flight at a time; while it is, the latest target waits and is sent when the update returns. The pet follows the pointer, a held pointer leaves the window where it is, a refused move continues from the position the Host kept, and a refused update never rolls the window back past a later accepted one. The running animation uses the same target. Version **0.9.0** adds two settings. **Task notifications** sets how many seconds a desktop card stays after a task finishes or fails, entered as a number and saved on the device; the default is 10 and **0** keeps the card until you close it. A card hiding never marks the task read, cards that need your answer never hide, and pointing at or focusing the cards pauses the countdown. **Language** follows the MiniMax Code interface language by default and can be pinned to 简体中文 or English; the whole panel and desktop copy switch, while conversation titles and replies stay as the Agent wrote them. @@ -49,7 +49,7 @@ Earlier local prototypes use a different plugin ID. The Host gives `minimax-pet` - An earlier UI version was exercised on macOS in a MiniMax Code development build: three tabs, wide/narrow layouts, task reading, real session/ask activity, import/delete, and desktop round trips. Version 0.6.0 has Node/DOM tests (including the open and theme paths against a simulated Host), an isolated HTTP smoke test, and a browser check of the panel layout outside the Host. - The default atlas is checked by actual PNG decoding, frame coverage and deterministic rebuild tests. The separate octopus icon is checked by PNG decoding and visual review. On 2026-10-26, the packaged Skill was exercised with a real Agent in the macOS development build to create, validate, preview and import a custom v2 pet; all nine animation previews and the four cardinal mouse-look directions were checked. This does not replace released-client acceptance. - Version 0.9.0 has Node/DOM tests for the card timer, the preference round trip and the copy of both languages (every key the page uses must exist in both), plus a browser check of the panel and desktop window in both languages against a simulated Host. Not verified for 0.9.0: the language the real client reports on any given install, and both languages inside a real MiniMax Code window. -- Version 0.9.1 has Node tests for the desktop drag: a held pointer leaves the window where it is, a moving pointer is followed, a refused move continues from the position the Host kept, a refused update does not roll the window back past a later accepted one, a fast drag counts every movement once, and the run direction follows the window target. Not verified for 0.9.1: dragging the pet inside a real MiniMax Code desktop window, and the timing of the Host's answer to a window update. +- Version 0.9.1 has Node tests for the desktop drag: a held pointer leaves the window where it is, a moving pointer is followed, a refused move continues from the position the Host kept, a refused update does not roll the window back past a later accepted one, a fast drag counts every movement once, a pointermove that arrives while the Host is still moving the window does not step the window back, and the run direction follows the window target. Not verified for 0.9.1: dragging the pet inside a real MiniMax Code desktop window, and the timing of the Host's answer to a window update. - Not verified for 0.6.0: opening a conversation or following the theme inside a real MiniMax Code 3.1.11+ window (panel or desktop pet), any Windows GUI, and released-client installation or GUI acceptance on 3.1.6. Automated tests use synthetic data and do not prove those paths. ## Data & access diff --git a/plugins/minimax-ai/minimax-pet/README.zh-CN.md b/plugins/minimax-ai/minimax-pet/README.zh-CN.md index 8013334..4b12d8d 100644 --- a/plugins/minimax-ai/minimax-pet/README.zh-CN.md +++ b/plugins/minimax-ai/minimax-pet/README.zh-CN.md @@ -18,7 +18,7 @@ **0.5.11** 新增可选内置宠物 **MMX 章鱼娘**,是一位橙发、白背心、棕格纹短裙的 Q 版全身章鱼娘。在“外观 → 选择宠物”中选择她即可;薄荷色机器人仍为默认宠物。她采用 v1 图集,包含九种动作,不含十六个视线方向,因此鼠标跟随对她不生效;沿用六帧、6.6 秒一轮的慢速待机,不新增权限或改变播放器。图集已通过校验。创作流程和贡献者许可见[素材说明](ARTWORK.md)。 -**0.9.1** 修复独立窗口里拖动桌宠的问题。窗口位置改为按指针在窗口内的位移加宿主确认的窗口原点计算:桌宠跟着指针走,按住不动时窗口停在原地,被宿主拒绝的移动会从宿主保留的位置继续。同一时刻只有一个窗口更新在途:宿主移动窗口期间,最新的目标位置等着,更新返回后再发,因此快速拖拽不会把同一段位移算两遍,被拒绝的更新也不会把窗口位置回退到更早一次成功移动之前。跑动方向的动画改用同一个目标位置。 +**0.9.1** 修复独立窗口里拖动桌宠的问题。窗口改为跟着指针走,减去抓取时的偏移。指针是在窗口内测量的,所以它的位置要按事件测量时所在的那个窗口原点来读:宿主确认的原点,或者宿主已经移动到、但还没应答的那个原点。同一时刻只有一个窗口更新在途,期间只保留最新的目标位置,更新返回后再发。桌宠跟着指针走,按住不动时窗口停在原地,被宿主拒绝的移动会从宿主保留的位置继续,被拒绝的更新也不会把窗口位置回退到更早一次成功移动之前。跑动方向的动画改用同一个目标位置。 **0.9.0** 新增两项设置。“任务通知”用数字框设置桌面卡片在任务完成或异常后显示多少秒,默认 10 秒,填 0 表示一直显示到手动关闭;设置保存在本机。卡片自动收起不会把任务标记为已读,需要你回答的卡片不会自动收起,鼠标悬停或焦点停留时会暂停计时。“语言”默认跟随 MiniMax Code 的界面语言,也可以固定为简体中文或 English;面板和桌面文案整体切换,对话标题和回复保持 Agent 原文。 @@ -40,7 +40,7 @@ 任务监控使用 `agent.sessions/session/observe/cancel`,需要 `session.read` 授权。保留运行时能力和权限检查,缺少接口或关闭授权时显示不可用。“打开对话/去回答”使用 `host.navigation.open`,主题跟随使用 `host.ui.subscribe`,两者都从 MiniMax Code **3.1.11** 起提供;2026-11-10 已确认 3.1.11 的 macOS 与 Windows 安装包在两个正式 CDN 上均可下载。两项调用已对照 MiniMax Code 源码核对:打开插件已持有的会话不需要 `hostAccess` 或其他授权,必须在点击中发起,并会把主窗口带到前台。 -0.9.1 的拖拽有 Node 测试:按住不动窗口停在原地,指针移动时窗口跟随,被宿主拒绝的移动从宿主保留的位置继续,被拒绝的更新不会把窗口回退到更早一次成功移动之前,快速拖拽只把同一段位移算一遍,跑动方向跟随窗口目标。未在真实的 MiniMax Code 桌面窗口里验收拖拽,也没有实测宿主对窗口更新的应答时序。 +0.9.1 的拖拽有 Node 测试:按住不动窗口停在原地,指针移动时窗口跟随,被宿主拒绝的移动从宿主保留的位置继续,被拒绝的更新不会把窗口回退到更早一次成功移动之前,快速拖拽只把同一段位移算一遍,宿主还在移动窗口时到达的 pointermove 不会让窗口往回退一步,跑动方向跟随窗口目标。未在真实的 MiniMax Code 桌面窗口里验收拖拽,也没有实测宿主对窗口更新的应答时序。 更早的 UI 版本已在 macOS 开发版验收三页签、窄宽布局、真实任务与 ask、导入删除和桌面切换;0.6.0 运行 Node/DOM 测试(含模拟 Host 下的打开对话与主题路径)、隔离 HTTP smoke,并在 Host 之外用浏览器检查了面板布局。默认图集通过 PNG 解码、动画帧覆盖与确定性重建检查;独立的章鱼图标通过 PNG 解码和视觉检查。2026-10-26,已用 macOS 开发版中的真实 Agent 调用随包 Skill,完成自定义 v2 宠物的生成、校验、预览和导入,并检查九种动作预览与鼠标上/右/下/左四向注视。0.6.0 尚未在真实的 MiniMax Code 3.1.11+ 窗口(面板或桌面宠物)中验收打开对话和主题跟随,也未验收 Windows GUI、正式客户端安装或 3.1.6 正式版 GUI;合成数据测试不能证明这些路径。 diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/pet-app.js b/plugins/minimax-ai/minimax-pet/miniapp/client/pet-app.js index 7599322..c0b4c31 100644 --- a/plugins/minimax-ai/minimax-pet/miniapp/client/pet-app.js +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/pet-app.js @@ -899,15 +899,18 @@ function onSpritePointerDown(e) { const origin = app.winPos ? { ...app.winPos } : null; app.drag = { pointerId: e.pointerId, - // A popped-out window reports screenX relative to its own frame, so the - // pointer position inside the window plus the window origin is the only - // place the window can go. + // The pointer is measured inside the window, so the window goes where the + // pointer is, minus the offset it was grabbed at. grabX: e.clientX, grabY: e.clientY, // Window origin at grab time; null when the Host reported no position. base: origin, - // Window origin the Host confirmed; every target is measured against it. + // Window origin the Host confirmed, and the target of the request in + // flight. The Host can move the window before it answers the request. origin: origin ? { ...origin } : null, + inflight: null, + // Pointer position on screen, tracked across events. + pointer: origin ? { x: origin.x + e.clientX, y: origin.y + e.clientY } : null, // Latest target not sent yet, and whether an update is already in flight. pending: null, sending: false, @@ -925,10 +928,7 @@ function onSpritePointerMove(e) { if (!d || e.pointerId !== d.pointerId) return; const dx = e.clientX - d.grabX; const dy = e.clientY - d.grabY; - // The window follows the pointer, so the pointer offset inside the window - // shrinks back to the grab point. The target is measured against the window - // origin the Host holds, never against the grab position. - const target = d.origin ? { x: Math.round(d.origin.x + dx), y: Math.round(d.origin.y + dy) } : null; + const target = d.origin ? dragTarget(d, e) : null; if (!d.active) { // How far the pointer travelled on screen, not inside the window. const travel = target ? { x: target.x - d.base.x, y: target.y - d.base.y } : { x: dx, y: dy }; @@ -948,17 +948,33 @@ function onSpritePointerMove(e) { updateDragDirection(d, e.clientX); return; } - // The pointer sits at the grab point inside the window: the window already - // is where the pointer wants it, so there is nothing to send. - if (dx === 0 && dy === 0) { - d.pending = null; - return; - } // Keep only the latest target; one update is in flight at a time. d.pending = target; flushDrag(d); } +// Where the window belongs: the pointer position minus the grab offset. The +// pointer is measured inside the window, so its position needs the origin of +// the frame it was measured in: the origin the Host confirmed, or the target +// the Host already moved to while the answer is still pending. The pointer +// moves a few pixels between events, a window move is bigger than that, so +// the pointer itself tells which frame the event used. +function dragTarget(d, e) { + const frames = d.inflight ? [d.origin, d.inflight] : [d.origin]; + let pointer = null; + let nearest = Infinity; + for (const frame of frames) { + const p = { x: frame.x + e.clientX, y: frame.y + e.clientY }; + const moved = d.pointer ? Math.hypot(p.x - d.pointer.x, p.y - d.pointer.y) : 0; + if (moved < nearest) { + nearest = moved; + pointer = p; + } + } + d.pointer = pointer; + return { x: Math.round(pointer.x - d.grabX), y: Math.round(pointer.y - d.grabY) }; +} + // One window update at a time. The Host answers with the position it holds, // so the next target is measured against a confirmed position, and a refused // update can never roll the window back past a later accepted one. @@ -968,9 +984,11 @@ function flushDrag(d) { d.pending = null; if (target.x === d.origin.x && target.y === d.origin.y) return; d.sending = true; + d.inflight = target; updateDragDirection(d, target.x); callBridge(() => win.update({ x: target.x, y: target.y })).then((state) => { d.sending = false; + d.inflight = null; const moved = state?.window; if (moved) { d.origin = { x: moved.x, y: moved.y }; @@ -979,6 +997,7 @@ function flushDrag(d) { flushDrag(d); }, (error) => { d.sending = false; + d.inflight = null; // The Host refused the move and kept the window where it was. if (app.winPos) d.origin = { ...app.winPos }; const code = errCode(error); diff --git a/sources/minimax-ai/minimax-pet/DEVELOPMENT.md b/sources/minimax-ai/minimax-pet/DEVELOPMENT.md index 3b44e58..ad41858 100644 --- a/sources/minimax-ai/minimax-pet/DEVELOPMENT.md +++ b/sources/minimax-ai/minimax-pet/DEVELOPMENT.md @@ -29,7 +29,7 @@ npm run check:package - Resizing the desktop pet first brings the transparent window inside the available screen area so smaller sprites remain reachable. - Click-through follows the pixels, never a whole mode: the same rule covers the pet, the bubble, the notification stack, the quick bar, the menu and the open card. An expanded card window is 300x488 around a 284x147 card, so treating "card open" as "window interactive" swallows every click in the margin. Only a drag or an open menu justify the whole window, and a card opened from the keyboard (no pointer sample yet) stays interactive. - Opening or closing the card must never change the window geometry. The window is one fixed 280 x `deskHeight(scale)` size that already fits the open card; resizing on click moves the native origin, and the previous layout is repainted at the new top-left for a frame, so the pet visibly jumps up (and left, while the card was wider). `dev/desk-interactivity.test.mjs` fails if `openWorkbench` or `closeWorkbench` touches geometry, and checks the fixed height still leaves room for the card at every pet scale. The area above the pet is transparent and forwards clicks, so a permanently tall window costs no desktop clicks. -- Dragging the pet moves the window by the pointer's offset inside the window plus the window origin the Host confirmed, never by a screen coordinate: `screenX` in the popped-out window is relative to its own frame. One `win.update` is in flight at a time; while it is, only the latest target waits, and a refused update falls back to the Host-confirmed position. `dev/window-drag.test.mjs` fails if a refused update rolls the window back past a later accepted one, or if a fast drag counts a movement twice. +- Dragging the pet moves the window to where the pointer is, minus the offset it was grabbed at, never by a screen coordinate: `screenX` in the popped-out window is relative to its own frame. The pointer is measured inside the window, so its position is read against the frame the event was measured in: the origin the Host confirmed, or the origin the Host already moved to while the answer is still pending. One `win.update` is in flight at a time; while it is, only the latest target waits, and a refused update falls back to the Host-confirmed position. `dev/window-drag.test.mjs` fails if a refused update rolls the window back past a later accepted one, if a fast drag counts a movement twice, or if a pointermove that arrives while the Host is moving the window steps the window back. - Opening the card must not change the running sprite animation. Hover runs the `jumping` row; reverting to the base state on open restarts it at frame 0, which reads as a flicker on every click. The menu still takes the pet back to its base animation, because the menu is a deliberate choice. - Every user-visible string comes from `miniapp/client/i18n.mjs` through `t()`; static markup carries `data-i18n` / `data-i18n-attr`. Keys are `..`, interpolation is `{{name}}`, a missing key renders as the key itself, and `dev/i18n.test.mjs` fails when a key the client uses is missing from either language. Conversation titles, replies and error text from the Host are data and stay in the language the Agent wrote them. - Text created once when a view is built (segmented labels, compact-view buttons) must be re-applied on a language change, or it keeps the language of the moment it was built. diff --git a/sources/minimax-ai/minimax-pet/README.md b/sources/minimax-ai/minimax-pet/README.md index 8556b25..d7dc060 100644 --- a/sources/minimax-ai/minimax-pet/README.md +++ b/sources/minimax-ai/minimax-pet/README.md @@ -16,7 +16,7 @@ A lightweight desktop companion for MiniMax Code, by **MiniMax**. It shows visib The pet does not run Agent turns, create independent chats, write to chat inputs, comment on every session, stop tasks, or answer questionnaires. Selecting a task only reads its details; the main window switches only when you click **打开对话** or **去回答**. -Version **0.9.1** fixes dragging the pet in the popped-out desktop window. The window position is now the pointer's offset inside the window plus the window origin the Host confirmed, so the pet follows the pointer, a held pointer leaves the window where it is, and a move the Host refuses continues from the position the Host kept. One window update is in flight at a time: while the Host moves the window, the latest target waits and is sent when the update returns, so a fast drag never counts a movement twice and a refused update never rolls the window back past a later accepted one. The running animation uses the same target. +Version **0.9.1** fixes dragging the pet in the popped-out desktop window. The window now goes where the pointer is, minus the offset it was grabbed at. The pointer is measured inside the window, so its position is read against the frame the event was measured in: the origin the Host confirmed, or the origin the Host already moved to while the answer is still pending. One window update is in flight at a time; while it is, the latest target waits and is sent when the update returns. The pet follows the pointer, a held pointer leaves the window where it is, a refused move continues from the position the Host kept, and a refused update never rolls the window back past a later accepted one. The running animation uses the same target. Version **0.9.0** adds two settings. **Task notifications** sets how many seconds a desktop card stays after a task finishes or fails, entered as a number and saved on the device; the default is 10 and **0** keeps the card until you close it. A card hiding never marks the task read, cards that need your answer never hide, and pointing at or focusing the cards pauses the countdown. **Language** follows the MiniMax Code interface language by default and can be pinned to 简体中文 or English; the whole panel and desktop copy switch, while conversation titles and replies stay as the Agent wrote them. @@ -49,7 +49,7 @@ Earlier local prototypes use a different plugin ID. The Host gives `minimax-pet` - An earlier UI version was exercised on macOS in a MiniMax Code development build: three tabs, wide/narrow layouts, task reading, real session/ask activity, import/delete, and desktop round trips. Version 0.6.0 has Node/DOM tests (including the open and theme paths against a simulated Host), an isolated HTTP smoke test, and a browser check of the panel layout outside the Host. - The default atlas is checked by actual PNG decoding, frame coverage and deterministic rebuild tests. The separate octopus icon is checked by PNG decoding and visual review. On 2026-10-26, the packaged Skill was exercised with a real Agent in the macOS development build to create, validate, preview and import a custom v2 pet; all nine animation previews and the four cardinal mouse-look directions were checked. This does not replace released-client acceptance. - Version 0.9.0 has Node/DOM tests for the card timer, the preference round trip and the copy of both languages (every key the page uses must exist in both), plus a browser check of the panel and desktop window in both languages against a simulated Host. Not verified for 0.9.0: the language the real client reports on any given install, and both languages inside a real MiniMax Code window. -- Version 0.9.1 has Node tests for the desktop drag: a held pointer leaves the window where it is, a moving pointer is followed, a refused move continues from the position the Host kept, a refused update does not roll the window back past a later accepted one, a fast drag counts every movement once, and the run direction follows the window target. Not verified for 0.9.1: dragging the pet inside a real MiniMax Code desktop window, and the timing of the Host's answer to a window update. +- Version 0.9.1 has Node tests for the desktop drag: a held pointer leaves the window where it is, a moving pointer is followed, a refused move continues from the position the Host kept, a refused update does not roll the window back past a later accepted one, a fast drag counts every movement once, a pointermove that arrives while the Host is still moving the window does not step the window back, and the run direction follows the window target. Not verified for 0.9.1: dragging the pet inside a real MiniMax Code desktop window, and the timing of the Host's answer to a window update. - Not verified for 0.6.0: opening a conversation or following the theme inside a real MiniMax Code 3.1.11+ window (panel or desktop pet), any Windows GUI, and released-client installation or GUI acceptance on 3.1.6. Automated tests use synthetic data and do not prove those paths. ## Data & access diff --git a/sources/minimax-ai/minimax-pet/README.zh-CN.md b/sources/minimax-ai/minimax-pet/README.zh-CN.md index 8013334..4b12d8d 100644 --- a/sources/minimax-ai/minimax-pet/README.zh-CN.md +++ b/sources/minimax-ai/minimax-pet/README.zh-CN.md @@ -18,7 +18,7 @@ **0.5.11** 新增可选内置宠物 **MMX 章鱼娘**,是一位橙发、白背心、棕格纹短裙的 Q 版全身章鱼娘。在“外观 → 选择宠物”中选择她即可;薄荷色机器人仍为默认宠物。她采用 v1 图集,包含九种动作,不含十六个视线方向,因此鼠标跟随对她不生效;沿用六帧、6.6 秒一轮的慢速待机,不新增权限或改变播放器。图集已通过校验。创作流程和贡献者许可见[素材说明](ARTWORK.md)。 -**0.9.1** 修复独立窗口里拖动桌宠的问题。窗口位置改为按指针在窗口内的位移加宿主确认的窗口原点计算:桌宠跟着指针走,按住不动时窗口停在原地,被宿主拒绝的移动会从宿主保留的位置继续。同一时刻只有一个窗口更新在途:宿主移动窗口期间,最新的目标位置等着,更新返回后再发,因此快速拖拽不会把同一段位移算两遍,被拒绝的更新也不会把窗口位置回退到更早一次成功移动之前。跑动方向的动画改用同一个目标位置。 +**0.9.1** 修复独立窗口里拖动桌宠的问题。窗口改为跟着指针走,减去抓取时的偏移。指针是在窗口内测量的,所以它的位置要按事件测量时所在的那个窗口原点来读:宿主确认的原点,或者宿主已经移动到、但还没应答的那个原点。同一时刻只有一个窗口更新在途,期间只保留最新的目标位置,更新返回后再发。桌宠跟着指针走,按住不动时窗口停在原地,被宿主拒绝的移动会从宿主保留的位置继续,被拒绝的更新也不会把窗口位置回退到更早一次成功移动之前。跑动方向的动画改用同一个目标位置。 **0.9.0** 新增两项设置。“任务通知”用数字框设置桌面卡片在任务完成或异常后显示多少秒,默认 10 秒,填 0 表示一直显示到手动关闭;设置保存在本机。卡片自动收起不会把任务标记为已读,需要你回答的卡片不会自动收起,鼠标悬停或焦点停留时会暂停计时。“语言”默认跟随 MiniMax Code 的界面语言,也可以固定为简体中文或 English;面板和桌面文案整体切换,对话标题和回复保持 Agent 原文。 @@ -40,7 +40,7 @@ 任务监控使用 `agent.sessions/session/observe/cancel`,需要 `session.read` 授权。保留运行时能力和权限检查,缺少接口或关闭授权时显示不可用。“打开对话/去回答”使用 `host.navigation.open`,主题跟随使用 `host.ui.subscribe`,两者都从 MiniMax Code **3.1.11** 起提供;2026-11-10 已确认 3.1.11 的 macOS 与 Windows 安装包在两个正式 CDN 上均可下载。两项调用已对照 MiniMax Code 源码核对:打开插件已持有的会话不需要 `hostAccess` 或其他授权,必须在点击中发起,并会把主窗口带到前台。 -0.9.1 的拖拽有 Node 测试:按住不动窗口停在原地,指针移动时窗口跟随,被宿主拒绝的移动从宿主保留的位置继续,被拒绝的更新不会把窗口回退到更早一次成功移动之前,快速拖拽只把同一段位移算一遍,跑动方向跟随窗口目标。未在真实的 MiniMax Code 桌面窗口里验收拖拽,也没有实测宿主对窗口更新的应答时序。 +0.9.1 的拖拽有 Node 测试:按住不动窗口停在原地,指针移动时窗口跟随,被宿主拒绝的移动从宿主保留的位置继续,被拒绝的更新不会把窗口回退到更早一次成功移动之前,快速拖拽只把同一段位移算一遍,宿主还在移动窗口时到达的 pointermove 不会让窗口往回退一步,跑动方向跟随窗口目标。未在真实的 MiniMax Code 桌面窗口里验收拖拽,也没有实测宿主对窗口更新的应答时序。 更早的 UI 版本已在 macOS 开发版验收三页签、窄宽布局、真实任务与 ask、导入删除和桌面切换;0.6.0 运行 Node/DOM 测试(含模拟 Host 下的打开对话与主题路径)、隔离 HTTP smoke,并在 Host 之外用浏览器检查了面板布局。默认图集通过 PNG 解码、动画帧覆盖与确定性重建检查;独立的章鱼图标通过 PNG 解码和视觉检查。2026-10-26,已用 macOS 开发版中的真实 Agent 调用随包 Skill,完成自定义 v2 宠物的生成、校验、预览和导入,并检查九种动作预览与鼠标上/右/下/左四向注视。0.6.0 尚未在真实的 MiniMax Code 3.1.11+ 窗口(面板或桌面宠物)中验收打开对话和主题跟随,也未验收 Windows GUI、正式客户端安装或 3.1.6 正式版 GUI;合成数据测试不能证明这些路径。 diff --git a/sources/minimax-ai/minimax-pet/dev/window-drag.test.mjs b/sources/minimax-ai/minimax-pet/dev/window-drag.test.mjs index 55f2d3a..1a3e5d1 100644 --- a/sources/minimax-ai/minimax-pet/dev/window-drag.test.mjs +++ b/sources/minimax-ai/minimax-pet/dev/window-drag.test.mjs @@ -12,6 +12,7 @@ function harness({ failFirst = 0 } = {}) { const calls = []; const host = { x: 1000, y: 500 }; let failed = 0; + let request = null; const app = { mode: 'window', pet: {}, winPos: { ...host }, drag: null, dragging: false, oneShotToken: 0, oneShot: false, menuOpen: false, workbenchOpen: false, @@ -25,17 +26,7 @@ function harness({ failFirst = 0 } = {}) { win: { update(patch) { calls.push(['update', patch.x, patch.y]); - if (failed < failFirst) { - failed += 1; - return Promise.reject(Object.assign(new Error('off screen'), { code: 'not_permitted' })); - } - // The Host moves the window and answers with the position it holds. - return Promise.resolve().then(() => { - host.x = patch.x; - host.y = patch.y; - app.winPos = { x: patch.x, y: patch.y }; - return { mode: 'window', window: { ...host, width: 280, height: 340 } }; - }); + return new Promise((resolve, reject) => { request = { patch, resolve, reject }; }); }, }, callBridge: (fn) => { try { return Promise.resolve(fn()); } catch (error) { return Promise.reject(error); } }, @@ -61,15 +52,39 @@ function harness({ failFirst = 0 } = {}) { preventDefault() {}, }; }; + // The Host moves the window at the next animation frame and answers the + // request a moment later, so a pointermove can land in between. + function applyMove() { + if (!request) return; + if (failed < failFirst) { + failed += 1; + const refused = request; + request = null; + refused.reject(Object.assign(new Error('off screen'), { code: 'not_permitted' })); + return; + } + host.x = request.patch.x; + host.y = request.patch.y; + app.winPos = { x: host.x, y: host.y }; + } + function answer() { + if (!request) return; + const answered = request; + request = null; + answered.resolve({ mode: 'window', window: { ...host, width: 280, height: 340 } }); + } return { calls, app, host, press: (pointer, screenMode) => api.onSpritePointerDown(event('pointerdown', pointer, screenMode)), move: (pointer, screenMode) => api.onSpritePointerMove(event('pointermove', pointer, screenMode)), updates: () => calls.filter((call) => call[0] === 'update'), plays: () => calls.filter((call) => call[0] === 'play').map((call) => call[1]), - // Let the Host answer, and let the answer send the next update. - settle: async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + applyMove, + answer, + // One Host frame: the window moves, then the request is answered. + advance: async () => { + applyMove(); + answer(); await new Promise((resolve) => setTimeout(resolve, 0)); }, }; @@ -83,11 +98,11 @@ for (const screenMode of ['local', 'absolute']) { // The cursor moves 30px to the right; the window follows. pointer.x = 1130; h.move(pointer, screenMode); - await h.settle(); + await h.advance(); // The cursor stops. The window moved under it, so the pointer offset inside // the window shrinks back to the grab point: the window must not move. h.move(pointer, screenMode); - await h.settle(); + await h.advance(); assert.deepEqual(h.updates(), [['update', 1030, 500]]); assert.deepEqual(h.host, { x: 1030, y: 500 }); }); @@ -98,10 +113,10 @@ for (const screenMode of ['local', 'absolute']) { h.press(pointer, screenMode); pointer.x = 1130; h.move(pointer, screenMode); - await h.settle(); + await h.advance(); pointer.x = 1200; h.move(pointer, screenMode); - await h.settle(); + await h.advance(); // The window origin is the pointer position minus the grab offset. assert.deepEqual(h.updates(), [['update', 1030, 500], ['update', 1100, 500]]); assert.deepEqual(h.host, { x: 1100, y: 500 }); @@ -113,11 +128,11 @@ for (const screenMode of ['local', 'absolute']) { h.press(pointer, screenMode); pointer.x = 1130; h.move(pointer, screenMode); - await h.settle(); + await h.advance(); // The Host refused the move, so the window stayed at 1000. pointer.x = 1160; h.move(pointer, screenMode); - await h.settle(); + await h.advance(); assert.deepEqual(h.updates(), [['update', 1030, 500], ['update', 1060, 500]]); }); } @@ -128,11 +143,11 @@ test('the drag direction follows the window target, not the pointer inside the w h.press(pointer, 'local'); pointer.x = 1200; h.move(pointer, 'local'); - await h.settle(); + await h.advance(); // The window caught up, so the pointer offset inside the window is back to // the grab point while the window keeps moving to the right. h.move(pointer, 'local'); - await h.settle(); + await h.advance(); assert.deepEqual(h.plays(), ['running-right']); }); @@ -142,7 +157,7 @@ test('a movement below the threshold does not move the window', async () => { h.press(pointer, 'local'); pointer.x = 1102; h.move(pointer, 'local'); - await h.settle(); + await h.advance(); assert.deepEqual(h.updates(), []); assert.equal(h.app.dragging, false); }); @@ -157,15 +172,16 @@ test('a refused update does not roll the origin back past a later accepted one', h.move(pointer, 'local'); pointer.x = 1190; h.move(pointer, 'local'); - await h.settle(); + await h.advance(); // The refused update did not move the window; the accepted one did. assert.deepEqual(h.updates(), [['update', 1030, 500], ['update', 1090, 500]]); pointer.x = 1170; h.move(pointer, 'local'); - await h.settle(); + await h.advance(); // The next target is measured against the position the Host holds (1090), // not against the one the refused update rolled back to (1000). assert.deepEqual(h.updates(), [['update', 1030, 500], ['update', 1090, 500], ['update', 1070, 500]]); + await h.advance(); assert.deepEqual(h.host, { x: 1070, y: 500 }); }); @@ -178,9 +194,28 @@ test('a fast drag counts a movement that has not landed yet only once', async () h.move(pointer, 'local'); pointer.x = 1160; h.move(pointer, 'local'); - await h.settle(); + await h.advance(); + await h.advance(); // One update is in flight at a time, and the window lands on the pointer // instead of past it. assert.deepEqual(h.updates(), [['update', 1030, 500], ['update', 1060, 500]]); assert.deepEqual(h.host, { x: 1060, y: 500 }); }); + +test('a pointermove that arrives while the Host moves the window does not step it back', async () => { + const h = harness(); + const pointer = { x: 1100, y: 700 }; + h.press(pointer, 'local'); + pointer.x = 1130; + h.move(pointer, 'local'); + // The Host moved the window to 1030 but has not answered the request yet. + h.applyMove(); + pointer.x = 1140; + h.move(pointer, 'local'); + await h.advance(); + // The pointer is measured against the window the Host already moved to, so + // the next target is 1040, not 1010. + assert.deepEqual(h.updates(), [['update', 1030, 500], ['update', 1040, 500]]); + await h.advance(); + assert.deepEqual(h.host, { x: 1040, y: 500 }); +}); diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/pet-app.js b/sources/minimax-ai/minimax-pet/miniapp/client/pet-app.js index 7599322..c0b4c31 100644 --- a/sources/minimax-ai/minimax-pet/miniapp/client/pet-app.js +++ b/sources/minimax-ai/minimax-pet/miniapp/client/pet-app.js @@ -899,15 +899,18 @@ function onSpritePointerDown(e) { const origin = app.winPos ? { ...app.winPos } : null; app.drag = { pointerId: e.pointerId, - // A popped-out window reports screenX relative to its own frame, so the - // pointer position inside the window plus the window origin is the only - // place the window can go. + // The pointer is measured inside the window, so the window goes where the + // pointer is, minus the offset it was grabbed at. grabX: e.clientX, grabY: e.clientY, // Window origin at grab time; null when the Host reported no position. base: origin, - // Window origin the Host confirmed; every target is measured against it. + // Window origin the Host confirmed, and the target of the request in + // flight. The Host can move the window before it answers the request. origin: origin ? { ...origin } : null, + inflight: null, + // Pointer position on screen, tracked across events. + pointer: origin ? { x: origin.x + e.clientX, y: origin.y + e.clientY } : null, // Latest target not sent yet, and whether an update is already in flight. pending: null, sending: false, @@ -925,10 +928,7 @@ function onSpritePointerMove(e) { if (!d || e.pointerId !== d.pointerId) return; const dx = e.clientX - d.grabX; const dy = e.clientY - d.grabY; - // The window follows the pointer, so the pointer offset inside the window - // shrinks back to the grab point. The target is measured against the window - // origin the Host holds, never against the grab position. - const target = d.origin ? { x: Math.round(d.origin.x + dx), y: Math.round(d.origin.y + dy) } : null; + const target = d.origin ? dragTarget(d, e) : null; if (!d.active) { // How far the pointer travelled on screen, not inside the window. const travel = target ? { x: target.x - d.base.x, y: target.y - d.base.y } : { x: dx, y: dy }; @@ -948,17 +948,33 @@ function onSpritePointerMove(e) { updateDragDirection(d, e.clientX); return; } - // The pointer sits at the grab point inside the window: the window already - // is where the pointer wants it, so there is nothing to send. - if (dx === 0 && dy === 0) { - d.pending = null; - return; - } // Keep only the latest target; one update is in flight at a time. d.pending = target; flushDrag(d); } +// Where the window belongs: the pointer position minus the grab offset. The +// pointer is measured inside the window, so its position needs the origin of +// the frame it was measured in: the origin the Host confirmed, or the target +// the Host already moved to while the answer is still pending. The pointer +// moves a few pixels between events, a window move is bigger than that, so +// the pointer itself tells which frame the event used. +function dragTarget(d, e) { + const frames = d.inflight ? [d.origin, d.inflight] : [d.origin]; + let pointer = null; + let nearest = Infinity; + for (const frame of frames) { + const p = { x: frame.x + e.clientX, y: frame.y + e.clientY }; + const moved = d.pointer ? Math.hypot(p.x - d.pointer.x, p.y - d.pointer.y) : 0; + if (moved < nearest) { + nearest = moved; + pointer = p; + } + } + d.pointer = pointer; + return { x: Math.round(pointer.x - d.grabX), y: Math.round(pointer.y - d.grabY) }; +} + // One window update at a time. The Host answers with the position it holds, // so the next target is measured against a confirmed position, and a refused // update can never roll the window back past a later accepted one. @@ -968,9 +984,11 @@ function flushDrag(d) { d.pending = null; if (target.x === d.origin.x && target.y === d.origin.y) return; d.sending = true; + d.inflight = target; updateDragDirection(d, target.x); callBridge(() => win.update({ x: target.x, y: target.y })).then((state) => { d.sending = false; + d.inflight = null; const moved = state?.window; if (moved) { d.origin = { x: moved.x, y: moved.y }; @@ -979,6 +997,7 @@ function flushDrag(d) { flushDrag(d); }, (error) => { d.sending = false; + d.inflight = null; // The Host refused the move and kept the window where it was. if (app.winPos) d.origin = { ...app.winPos }; const code = errCode(error); From 9634e7591dee8e02fcad9d8e0c53da6f5791d1ca Mon Sep 17 00:00:00 2001 From: July-X Date: Fri, 13 Nov 2026 18:23:01 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(minimax-pet):=20=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E6=94=B9=E7=94=A8=E3=80=8C=E5=AE=BF=E4=B8=BB?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E5=8E=9F=E7=82=B9=20+=20=E6=8C=87=E9=92=88?= =?UTF-8?q?=E5=81=8F=E7=A7=BB=E3=80=8D=EF=BC=8C=E5=9C=A8=E9=80=94=E5=8F=AA?= =?UTF-8?q?=E8=AE=B0=E6=8C=87=E9=92=88=E5=9D=90=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 维护者指出 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 桌面窗口里实际拖拽,也没有实测宿主对窗口更新 的应答时序;维护者要的屏幕录像还没提供。 --- plugins/minimax-ai/minimax-pet/README.md | 4 +- .../minimax-ai/minimax-pet/README.zh-CN.md | 4 +- .../minimax-pet/miniapp/client/pet-app.js | 96 +++++++++---------- sources/minimax-ai/minimax-pet/README.md | 4 +- .../minimax-ai/minimax-pet/README.zh-CN.md | 4 +- .../minimax-pet/dev/window-drag.test.mjs | 69 ++++++++++--- .../minimax-pet/miniapp/client/pet-app.js | 96 +++++++++---------- 7 files changed, 154 insertions(+), 123 deletions(-) diff --git a/plugins/minimax-ai/minimax-pet/README.md b/plugins/minimax-ai/minimax-pet/README.md index d7dc060..3aa36f1 100644 --- a/plugins/minimax-ai/minimax-pet/README.md +++ b/plugins/minimax-ai/minimax-pet/README.md @@ -16,7 +16,7 @@ A lightweight desktop companion for MiniMax Code, by **MiniMax**. It shows visib The pet does not run Agent turns, create independent chats, write to chat inputs, comment on every session, stop tasks, or answer questionnaires. Selecting a task only reads its details; the main window switches only when you click **打开对话** or **去回答**. -Version **0.9.1** fixes dragging the pet in the popped-out desktop window. The window now goes where the pointer is, minus the offset it was grabbed at. The pointer is measured inside the window, so its position is read against the frame the event was measured in: the origin the Host confirmed, or the origin the Host already moved to while the answer is still pending. One window update is in flight at a time; while it is, the latest target waits and is sent when the update returns. The pet follows the pointer, a held pointer leaves the window where it is, a refused move continues from the position the Host kept, and a refused update never rolls the window back past a later accepted one. The running animation uses the same target. +Version **0.9.1** fixes dragging the pet in the popped-out desktop window. The window now goes where the pointer is, minus the offset it was grabbed at. The pointer is measured inside the window, so the target is the origin the Host confirmed plus the pointer offset from the grab point. While an update is in flight only the latest pointer position is kept; when the Host answers, the target is recomputed from the confirmed origin. One window update is in flight at a time, and the release waits for the update in flight before the final position goes out. The pet follows the pointer, a held pointer leaves the window where it is, a refused move continues from the position the Host kept, and a refused update never rolls the window back past a later accepted one. The running animation uses the same target. Version **0.9.0** adds two settings. **Task notifications** sets how many seconds a desktop card stays after a task finishes or fails, entered as a number and saved on the device; the default is 10 and **0** keeps the card until you close it. A card hiding never marks the task read, cards that need your answer never hide, and pointing at or focusing the cards pauses the countdown. **Language** follows the MiniMax Code interface language by default and can be pinned to 简体中文 or English; the whole panel and desktop copy switch, while conversation titles and replies stay as the Agent wrote them. @@ -49,7 +49,7 @@ Earlier local prototypes use a different plugin ID. The Host gives `minimax-pet` - An earlier UI version was exercised on macOS in a MiniMax Code development build: three tabs, wide/narrow layouts, task reading, real session/ask activity, import/delete, and desktop round trips. Version 0.6.0 has Node/DOM tests (including the open and theme paths against a simulated Host), an isolated HTTP smoke test, and a browser check of the panel layout outside the Host. - The default atlas is checked by actual PNG decoding, frame coverage and deterministic rebuild tests. The separate octopus icon is checked by PNG decoding and visual review. On 2026-10-26, the packaged Skill was exercised with a real Agent in the macOS development build to create, validate, preview and import a custom v2 pet; all nine animation previews and the four cardinal mouse-look directions were checked. This does not replace released-client acceptance. - Version 0.9.0 has Node/DOM tests for the card timer, the preference round trip and the copy of both languages (every key the page uses must exist in both), plus a browser check of the panel and desktop window in both languages against a simulated Host. Not verified for 0.9.0: the language the real client reports on any given install, and both languages inside a real MiniMax Code window. -- Version 0.9.1 has Node tests for the desktop drag: a held pointer leaves the window where it is, a moving pointer is followed, a refused move continues from the position the Host kept, a refused update does not roll the window back past a later accepted one, a fast drag counts every movement once, a pointermove that arrives while the Host is still moving the window does not step the window back, and the run direction follows the window target. Not verified for 0.9.1: dragging the pet inside a real MiniMax Code desktop window, and the timing of the Host's answer to a window update. +- Version 0.9.1 has Node tests for the desktop drag: a held pointer leaves the window where it is, a moving pointer is followed, a refused move continues from the position the Host kept, a refused update does not roll the window back past a later accepted one, a fast drag counts every movement once, a pointermove that arrives while the Host is still moving the window does not step the window back, a pending move of one or two pointer steps keeps the target on the pointer, the release waits for the update in flight before the final position, and the run direction follows the window target. Not verified for 0.9.1: dragging the pet inside a real MiniMax Code desktop window, and the timing of the Host's answer to a window update. - Not verified for 0.6.0: opening a conversation or following the theme inside a real MiniMax Code 3.1.11+ window (panel or desktop pet), any Windows GUI, and released-client installation or GUI acceptance on 3.1.6. Automated tests use synthetic data and do not prove those paths. ## Data & access diff --git a/plugins/minimax-ai/minimax-pet/README.zh-CN.md b/plugins/minimax-ai/minimax-pet/README.zh-CN.md index 4b12d8d..603ca26 100644 --- a/plugins/minimax-ai/minimax-pet/README.zh-CN.md +++ b/plugins/minimax-ai/minimax-pet/README.zh-CN.md @@ -18,7 +18,7 @@ **0.5.11** 新增可选内置宠物 **MMX 章鱼娘**,是一位橙发、白背心、棕格纹短裙的 Q 版全身章鱼娘。在“外观 → 选择宠物”中选择她即可;薄荷色机器人仍为默认宠物。她采用 v1 图集,包含九种动作,不含十六个视线方向,因此鼠标跟随对她不生效;沿用六帧、6.6 秒一轮的慢速待机,不新增权限或改变播放器。图集已通过校验。创作流程和贡献者许可见[素材说明](ARTWORK.md)。 -**0.9.1** 修复独立窗口里拖动桌宠的问题。窗口改为跟着指针走,减去抓取时的偏移。指针是在窗口内测量的,所以它的位置要按事件测量时所在的那个窗口原点来读:宿主确认的原点,或者宿主已经移动到、但还没应答的那个原点。同一时刻只有一个窗口更新在途,期间只保留最新的目标位置,更新返回后再发。桌宠跟着指针走,按住不动时窗口停在原地,被宿主拒绝的移动会从宿主保留的位置继续,被拒绝的更新也不会把窗口位置回退到更早一次成功移动之前。跑动方向的动画改用同一个目标位置。 +**0.9.1** 修复独立窗口里拖动桌宠的问题。窗口改为跟着指针走,减去抓取时的偏移。指针是在窗口内测量的,所以目标位置 = 宿主确认的窗口原点 + 指针相对抓取点的偏移。窗口更新在途期间只记录最新的指针坐标,宿主应答后再用确认的原点重算目标位置。同一时刻只有一个窗口更新在途,抬手时等在途的那次更新结束,再把最终位置发出去。桌宠跟着指针走,按住不动时窗口停在原地,被宿主拒绝的移动会从宿主保留的位置继续,被拒绝的更新也不会把窗口位置回退到更早一次成功移动之前。跑动方向的动画改用同一个目标位置。 **0.9.0** 新增两项设置。“任务通知”用数字框设置桌面卡片在任务完成或异常后显示多少秒,默认 10 秒,填 0 表示一直显示到手动关闭;设置保存在本机。卡片自动收起不会把任务标记为已读,需要你回答的卡片不会自动收起,鼠标悬停或焦点停留时会暂停计时。“语言”默认跟随 MiniMax Code 的界面语言,也可以固定为简体中文或 English;面板和桌面文案整体切换,对话标题和回复保持 Agent 原文。 @@ -40,7 +40,7 @@ 任务监控使用 `agent.sessions/session/observe/cancel`,需要 `session.read` 授权。保留运行时能力和权限检查,缺少接口或关闭授权时显示不可用。“打开对话/去回答”使用 `host.navigation.open`,主题跟随使用 `host.ui.subscribe`,两者都从 MiniMax Code **3.1.11** 起提供;2026-11-10 已确认 3.1.11 的 macOS 与 Windows 安装包在两个正式 CDN 上均可下载。两项调用已对照 MiniMax Code 源码核对:打开插件已持有的会话不需要 `hostAccess` 或其他授权,必须在点击中发起,并会把主窗口带到前台。 -0.9.1 的拖拽有 Node 测试:按住不动窗口停在原地,指针移动时窗口跟随,被宿主拒绝的移动从宿主保留的位置继续,被拒绝的更新不会把窗口回退到更早一次成功移动之前,快速拖拽只把同一段位移算一遍,宿主还在移动窗口时到达的 pointermove 不会让窗口往回退一步,跑动方向跟随窗口目标。未在真实的 MiniMax Code 桌面窗口里验收拖拽,也没有实测宿主对窗口更新的应答时序。 +0.9.1 的拖拽有 Node 测试:按住不动窗口停在原地,指针移动时窗口跟随,被宿主拒绝的移动从宿主保留的位置继续,被拒绝的更新不会把窗口回退到更早一次成功移动之前,快速拖拽只把同一段位移算一遍,宿主还在移动窗口时到达的 pointermove 不会让窗口往回退一步,在途位移等于一个或两个指针步长时目标位置仍落在指针上,抬手时等在途更新结束再发最终位置,跑动方向跟随窗口目标。未在真实的 MiniMax Code 桌面窗口里验收拖拽,也没有实测宿主对窗口更新的应答时序。 更早的 UI 版本已在 macOS 开发版验收三页签、窄宽布局、真实任务与 ask、导入删除和桌面切换;0.6.0 运行 Node/DOM 测试(含模拟 Host 下的打开对话与主题路径)、隔离 HTTP smoke,并在 Host 之外用浏览器检查了面板布局。默认图集通过 PNG 解码、动画帧覆盖与确定性重建检查;独立的章鱼图标通过 PNG 解码和视觉检查。2026-10-26,已用 macOS 开发版中的真实 Agent 调用随包 Skill,完成自定义 v2 宠物的生成、校验、预览和导入,并检查九种动作预览与鼠标上/右/下/左四向注视。0.6.0 尚未在真实的 MiniMax Code 3.1.11+ 窗口(面板或桌面宠物)中验收打开对话和主题跟随,也未验收 Windows GUI、正式客户端安装或 3.1.6 正式版 GUI;合成数据测试不能证明这些路径。 diff --git a/plugins/minimax-ai/minimax-pet/miniapp/client/pet-app.js b/plugins/minimax-ai/minimax-pet/miniapp/client/pet-app.js index c0b4c31..3d056b6 100644 --- a/plugins/minimax-ai/minimax-pet/miniapp/client/pet-app.js +++ b/plugins/minimax-ai/minimax-pet/miniapp/client/pet-app.js @@ -899,20 +899,19 @@ function onSpritePointerDown(e) { const origin = app.winPos ? { ...app.winPos } : null; app.drag = { pointerId: e.pointerId, - // The pointer is measured inside the window, so the window goes where the - // pointer is, minus the offset it was grabbed at. + // The pointer is measured inside the window, so the window follows the + // pointer offset from the grab point. grabX: e.clientX, grabY: e.clientY, - // Window origin at grab time; null when the Host reported no position. - base: origin, - // Window origin the Host confirmed, and the target of the request in - // flight. The Host can move the window before it answers the request. + // Latest client coordinates of the pointer. + pointer: { x: e.clientX, y: e.clientY }, + // How many pointer events arrived; the request in flight remembers the + // count it was built from. + moves: 0, + sentMoves: -1, + // Window origin the Host confirmed; null when it reported no position. origin: origin ? { ...origin } : null, - inflight: null, - // Pointer position on screen, tracked across events. - pointer: origin ? { x: origin.x + e.clientX, y: origin.y + e.clientY } : null, - // Latest target not sent yet, and whether an update is already in flight. - pending: null, + // Whether an update is in flight. sending: false, // Reference for the run direction: the window origin when it is known, // otherwise the pointer position inside the window. @@ -926,12 +925,13 @@ function onSpritePointerDown(e) { function onSpritePointerMove(e) { const d = app.drag; if (!d || e.pointerId !== d.pointerId) return; - const dx = e.clientX - d.grabX; - const dy = e.clientY - d.grabY; - const target = d.origin ? dragTarget(d, e) : null; + // While an update is in flight only the latest pointer position is kept: + // the target is recomputed from the confirmed origin when the Host answers. + d.pointer = { x: e.clientX, y: e.clientY }; + d.moves += 1; if (!d.active) { // How far the pointer travelled on screen, not inside the window. - const travel = target ? { x: target.x - d.base.x, y: target.y - d.base.y } : { x: dx, y: dy }; + const travel = { x: e.clientX - d.grabX, y: e.clientY - d.grabY }; if (Math.hypot(travel.x, travel.y) <= DRAG_THRESHOLD) return; d.active = true; app.dragging = true; @@ -943,67 +943,53 @@ function onSpritePointerMove(e) { refreshInteractivity(); if (!d.origin) console.warn('[pet] drag_without_window_position: no window position yet, animation only'); } - if (!target) { + if (!d.origin) { // No window position: the pet still runs, but nothing can move. updateDragDirection(d, e.clientX); return; } - // Keep only the latest target; one update is in flight at a time. - d.pending = target; flushDrag(d); } -// Where the window belongs: the pointer position minus the grab offset. The -// pointer is measured inside the window, so its position needs the origin of -// the frame it was measured in: the origin the Host confirmed, or the target -// the Host already moved to while the answer is still pending. The pointer -// moves a few pixels between events, a window move is bigger than that, so -// the pointer itself tells which frame the event used. -function dragTarget(d, e) { - const frames = d.inflight ? [d.origin, d.inflight] : [d.origin]; - let pointer = null; - let nearest = Infinity; - for (const frame of frames) { - const p = { x: frame.x + e.clientX, y: frame.y + e.clientY }; - const moved = d.pointer ? Math.hypot(p.x - d.pointer.x, p.y - d.pointer.y) : 0; - if (moved < nearest) { - nearest = moved; - pointer = p; - } - } - d.pointer = pointer; - return { x: Math.round(pointer.x - d.grabX), y: Math.round(pointer.y - d.grabY) }; +// Where the window belongs: the origin the Host confirmed plus the pointer +// offset from the grab point. The Host answers with the position it holds, so +// the target is recomputed from the confirmed origin whenever it changes. +function dragTarget(d) { + return { + x: Math.round(d.origin.x + (d.pointer.x - d.grabX)), + y: Math.round(d.origin.y + (d.pointer.y - d.grabY)), + }; } -// One window update at a time. The Host answers with the position it holds, -// so the next target is measured against a confirmed position, and a refused -// update can never roll the window back past a later accepted one. +// One window update at a time. While it is in flight the pointer keeps +// moving, so the target is recomputed from the confirmed origin when the Host +// answers, and a refused update never rolls the window back past a later +// accepted one. function flushDrag(d) { - if (d.sending || !d.pending) return; - const target = d.pending; - d.pending = null; + if (d.sending || !d.origin) return; + const target = dragTarget(d); if (target.x === d.origin.x && target.y === d.origin.y) return; d.sending = true; - d.inflight = target; - updateDragDirection(d, target.x); + d.sentMoves = d.moves; + if (app.drag === d) updateDragDirection(d, target.x); callBridge(() => win.update({ x: target.x, y: target.y })).then((state) => { d.sending = false; - d.inflight = null; const moved = state?.window; if (moved) { d.origin = { x: moved.x, y: moved.y }; app.winPos = { x: moved.x, y: moved.y }; } else if (app.winPos) d.origin = { ...app.winPos }; - flushDrag(d); + // A pointer event that arrived after the request means the pointer may + // have moved: recompute the target from the confirmed origin. + if (d.moves !== d.sentMoves) flushDrag(d); }, (error) => { d.sending = false; - d.inflight = null; // The Host refused the move and kept the window where it was. if (app.winPos) d.origin = { ...app.winPos }; const code = errCode(error); if (code === 'not_permitted') console.debug('[pet] drag_move_rejected: not_permitted (target is off every display)'); else console.warn('[pet] drag_move_failed:', code, error); - flushDrag(d); + if (d.moves !== d.sentMoves) flushDrag(d); }); } @@ -1034,8 +1020,14 @@ function finishDrag(e, cancelled) { const d = app.drag; if (!d || e.pointerId !== d.pointerId) return; app.drag = null; - // The last pointer position is where the pet belongs: send it. - if (!cancelled) flushDrag(d); + if (!cancelled) { + // The release position is the most accurate one: the pointer may have + // moved since the last pointermove. While an update is in flight the + // final position goes out once the Host answers it. + d.pointer = { x: e.clientX, y: e.clientY }; + d.moves += 1; + flushDrag(d); + } try { if (els.sprite.hasPointerCapture(e.pointerId)) els.sprite.releasePointerCapture(e.pointerId); } catch (error) { diff --git a/sources/minimax-ai/minimax-pet/README.md b/sources/minimax-ai/minimax-pet/README.md index d7dc060..3aa36f1 100644 --- a/sources/minimax-ai/minimax-pet/README.md +++ b/sources/minimax-ai/minimax-pet/README.md @@ -16,7 +16,7 @@ A lightweight desktop companion for MiniMax Code, by **MiniMax**. It shows visib The pet does not run Agent turns, create independent chats, write to chat inputs, comment on every session, stop tasks, or answer questionnaires. Selecting a task only reads its details; the main window switches only when you click **打开对话** or **去回答**. -Version **0.9.1** fixes dragging the pet in the popped-out desktop window. The window now goes where the pointer is, minus the offset it was grabbed at. The pointer is measured inside the window, so its position is read against the frame the event was measured in: the origin the Host confirmed, or the origin the Host already moved to while the answer is still pending. One window update is in flight at a time; while it is, the latest target waits and is sent when the update returns. The pet follows the pointer, a held pointer leaves the window where it is, a refused move continues from the position the Host kept, and a refused update never rolls the window back past a later accepted one. The running animation uses the same target. +Version **0.9.1** fixes dragging the pet in the popped-out desktop window. The window now goes where the pointer is, minus the offset it was grabbed at. The pointer is measured inside the window, so the target is the origin the Host confirmed plus the pointer offset from the grab point. While an update is in flight only the latest pointer position is kept; when the Host answers, the target is recomputed from the confirmed origin. One window update is in flight at a time, and the release waits for the update in flight before the final position goes out. The pet follows the pointer, a held pointer leaves the window where it is, a refused move continues from the position the Host kept, and a refused update never rolls the window back past a later accepted one. The running animation uses the same target. Version **0.9.0** adds two settings. **Task notifications** sets how many seconds a desktop card stays after a task finishes or fails, entered as a number and saved on the device; the default is 10 and **0** keeps the card until you close it. A card hiding never marks the task read, cards that need your answer never hide, and pointing at or focusing the cards pauses the countdown. **Language** follows the MiniMax Code interface language by default and can be pinned to 简体中文 or English; the whole panel and desktop copy switch, while conversation titles and replies stay as the Agent wrote them. @@ -49,7 +49,7 @@ Earlier local prototypes use a different plugin ID. The Host gives `minimax-pet` - An earlier UI version was exercised on macOS in a MiniMax Code development build: three tabs, wide/narrow layouts, task reading, real session/ask activity, import/delete, and desktop round trips. Version 0.6.0 has Node/DOM tests (including the open and theme paths against a simulated Host), an isolated HTTP smoke test, and a browser check of the panel layout outside the Host. - The default atlas is checked by actual PNG decoding, frame coverage and deterministic rebuild tests. The separate octopus icon is checked by PNG decoding and visual review. On 2026-10-26, the packaged Skill was exercised with a real Agent in the macOS development build to create, validate, preview and import a custom v2 pet; all nine animation previews and the four cardinal mouse-look directions were checked. This does not replace released-client acceptance. - Version 0.9.0 has Node/DOM tests for the card timer, the preference round trip and the copy of both languages (every key the page uses must exist in both), plus a browser check of the panel and desktop window in both languages against a simulated Host. Not verified for 0.9.0: the language the real client reports on any given install, and both languages inside a real MiniMax Code window. -- Version 0.9.1 has Node tests for the desktop drag: a held pointer leaves the window where it is, a moving pointer is followed, a refused move continues from the position the Host kept, a refused update does not roll the window back past a later accepted one, a fast drag counts every movement once, a pointermove that arrives while the Host is still moving the window does not step the window back, and the run direction follows the window target. Not verified for 0.9.1: dragging the pet inside a real MiniMax Code desktop window, and the timing of the Host's answer to a window update. +- Version 0.9.1 has Node tests for the desktop drag: a held pointer leaves the window where it is, a moving pointer is followed, a refused move continues from the position the Host kept, a refused update does not roll the window back past a later accepted one, a fast drag counts every movement once, a pointermove that arrives while the Host is still moving the window does not step the window back, a pending move of one or two pointer steps keeps the target on the pointer, the release waits for the update in flight before the final position, and the run direction follows the window target. Not verified for 0.9.1: dragging the pet inside a real MiniMax Code desktop window, and the timing of the Host's answer to a window update. - Not verified for 0.6.0: opening a conversation or following the theme inside a real MiniMax Code 3.1.11+ window (panel or desktop pet), any Windows GUI, and released-client installation or GUI acceptance on 3.1.6. Automated tests use synthetic data and do not prove those paths. ## Data & access diff --git a/sources/minimax-ai/minimax-pet/README.zh-CN.md b/sources/minimax-ai/minimax-pet/README.zh-CN.md index 4b12d8d..603ca26 100644 --- a/sources/minimax-ai/minimax-pet/README.zh-CN.md +++ b/sources/minimax-ai/minimax-pet/README.zh-CN.md @@ -18,7 +18,7 @@ **0.5.11** 新增可选内置宠物 **MMX 章鱼娘**,是一位橙发、白背心、棕格纹短裙的 Q 版全身章鱼娘。在“外观 → 选择宠物”中选择她即可;薄荷色机器人仍为默认宠物。她采用 v1 图集,包含九种动作,不含十六个视线方向,因此鼠标跟随对她不生效;沿用六帧、6.6 秒一轮的慢速待机,不新增权限或改变播放器。图集已通过校验。创作流程和贡献者许可见[素材说明](ARTWORK.md)。 -**0.9.1** 修复独立窗口里拖动桌宠的问题。窗口改为跟着指针走,减去抓取时的偏移。指针是在窗口内测量的,所以它的位置要按事件测量时所在的那个窗口原点来读:宿主确认的原点,或者宿主已经移动到、但还没应答的那个原点。同一时刻只有一个窗口更新在途,期间只保留最新的目标位置,更新返回后再发。桌宠跟着指针走,按住不动时窗口停在原地,被宿主拒绝的移动会从宿主保留的位置继续,被拒绝的更新也不会把窗口位置回退到更早一次成功移动之前。跑动方向的动画改用同一个目标位置。 +**0.9.1** 修复独立窗口里拖动桌宠的问题。窗口改为跟着指针走,减去抓取时的偏移。指针是在窗口内测量的,所以目标位置 = 宿主确认的窗口原点 + 指针相对抓取点的偏移。窗口更新在途期间只记录最新的指针坐标,宿主应答后再用确认的原点重算目标位置。同一时刻只有一个窗口更新在途,抬手时等在途的那次更新结束,再把最终位置发出去。桌宠跟着指针走,按住不动时窗口停在原地,被宿主拒绝的移动会从宿主保留的位置继续,被拒绝的更新也不会把窗口位置回退到更早一次成功移动之前。跑动方向的动画改用同一个目标位置。 **0.9.0** 新增两项设置。“任务通知”用数字框设置桌面卡片在任务完成或异常后显示多少秒,默认 10 秒,填 0 表示一直显示到手动关闭;设置保存在本机。卡片自动收起不会把任务标记为已读,需要你回答的卡片不会自动收起,鼠标悬停或焦点停留时会暂停计时。“语言”默认跟随 MiniMax Code 的界面语言,也可以固定为简体中文或 English;面板和桌面文案整体切换,对话标题和回复保持 Agent 原文。 @@ -40,7 +40,7 @@ 任务监控使用 `agent.sessions/session/observe/cancel`,需要 `session.read` 授权。保留运行时能力和权限检查,缺少接口或关闭授权时显示不可用。“打开对话/去回答”使用 `host.navigation.open`,主题跟随使用 `host.ui.subscribe`,两者都从 MiniMax Code **3.1.11** 起提供;2026-11-10 已确认 3.1.11 的 macOS 与 Windows 安装包在两个正式 CDN 上均可下载。两项调用已对照 MiniMax Code 源码核对:打开插件已持有的会话不需要 `hostAccess` 或其他授权,必须在点击中发起,并会把主窗口带到前台。 -0.9.1 的拖拽有 Node 测试:按住不动窗口停在原地,指针移动时窗口跟随,被宿主拒绝的移动从宿主保留的位置继续,被拒绝的更新不会把窗口回退到更早一次成功移动之前,快速拖拽只把同一段位移算一遍,宿主还在移动窗口时到达的 pointermove 不会让窗口往回退一步,跑动方向跟随窗口目标。未在真实的 MiniMax Code 桌面窗口里验收拖拽,也没有实测宿主对窗口更新的应答时序。 +0.9.1 的拖拽有 Node 测试:按住不动窗口停在原地,指针移动时窗口跟随,被宿主拒绝的移动从宿主保留的位置继续,被拒绝的更新不会把窗口回退到更早一次成功移动之前,快速拖拽只把同一段位移算一遍,宿主还在移动窗口时到达的 pointermove 不会让窗口往回退一步,在途位移等于一个或两个指针步长时目标位置仍落在指针上,抬手时等在途更新结束再发最终位置,跑动方向跟随窗口目标。未在真实的 MiniMax Code 桌面窗口里验收拖拽,也没有实测宿主对窗口更新的应答时序。 更早的 UI 版本已在 macOS 开发版验收三页签、窄宽布局、真实任务与 ask、导入删除和桌面切换;0.6.0 运行 Node/DOM 测试(含模拟 Host 下的打开对话与主题路径)、隔离 HTTP smoke,并在 Host 之外用浏览器检查了面板布局。默认图集通过 PNG 解码、动画帧覆盖与确定性重建检查;独立的章鱼图标通过 PNG 解码和视觉检查。2026-10-26,已用 macOS 开发版中的真实 Agent 调用随包 Skill,完成自定义 v2 宠物的生成、校验、预览和导入,并检查九种动作预览与鼠标上/右/下/左四向注视。0.6.0 尚未在真实的 MiniMax Code 3.1.11+ 窗口(面板或桌面宠物)中验收打开对话和主题跟随,也未验收 Windows GUI、正式客户端安装或 3.1.6 正式版 GUI;合成数据测试不能证明这些路径。 diff --git a/sources/minimax-ai/minimax-pet/dev/window-drag.test.mjs b/sources/minimax-ai/minimax-pet/dev/window-drag.test.mjs index 1a3e5d1..1d6f0d5 100644 --- a/sources/minimax-ai/minimax-pet/dev/window-drag.test.mjs +++ b/sources/minimax-ai/minimax-pet/dev/window-drag.test.mjs @@ -4,7 +4,7 @@ import vm from 'node:vm'; import test from 'node:test'; const source = await readFile(new URL('../miniapp/client/pet-app.js', import.meta.url), 'utf8'); -const handler = source.slice(source.indexOf('function onSpritePointerDown'), source.indexOf('function finishDrag')); +const handler = source.slice(source.indexOf('function onSpritePointerDown'), source.indexOf('function cancelDrag')); // The Host keeps the window origin; a pointer inside the window is measured // from that origin, so the pointer offset shrinks while the window follows it. @@ -28,16 +28,19 @@ function harness({ failFirst = 0 } = {}) { calls.push(['update', patch.x, patch.y]); return new Promise((resolve, reject) => { request = { patch, resolve, reject }; }); }, + getState() { return { mode: 'window', window: { ...host, width: 280, height: 340 } }; }, }, callBridge: (fn) => { try { return Promise.resolve(fn()); } catch (error) { return Promise.reject(error); } }, errCode: (error) => error?.code || error?.message || 'result_unknown', logError: () => () => {}, player: { play: (name) => { calls.push(['play', name]); return Promise.resolve(); } }, noteInteraction() {}, closeMenu() {}, hideQuickbar() {}, stopLook() {}, refreshInteractivity() {}, + playOneShot() {}, desiredAnimation: () => ({ state: 'idle' }), openWorkbench() {}, applyActivity() {}, + onWindowState() {}, DRAG_THRESHOLD: 4, DIRECTION_HYSTERESIS: 12, console: { warn() {}, debug() {}, error() {} }, }); - vm.runInContext(`${handler}\nglobalThis.dragApi = { onSpritePointerDown, onSpritePointerMove };`, context); + vm.runInContext(`${handler}\nglobalThis.dragApi = { onSpritePointerDown, onSpritePointerMove, finishDrag };`, context); const api = context.dragApi; // screenMode 'local' reproduces the popped-out window, where screenX is the // pointer position inside the window instead of a screen coordinate. The @@ -46,7 +49,7 @@ function harness({ failFirst = 0 } = {}) { const clientX = pointer.x - host.x; const clientY = pointer.y - host.y; return { - pointerId: 1, button: 0, buttons: 1, clientX, clientY, + pointerId: 1, button: 0, buttons: type === 'pointerup' ? 0 : 1, clientX, clientY, screenX: screenMode === 'absolute' ? pointer.x : clientX, screenY: screenMode === 'absolute' ? pointer.y : clientY, preventDefault() {}, @@ -77,6 +80,7 @@ function harness({ failFirst = 0 } = {}) { calls, app, host, press: (pointer, screenMode) => api.onSpritePointerDown(event('pointerdown', pointer, screenMode)), move: (pointer, screenMode) => api.onSpritePointerMove(event('pointermove', pointer, screenMode)), + release: (pointer, screenMode) => api.finishDrag(event('pointerup', pointer, screenMode), false), updates: () => calls.filter((call) => call[0] === 'update'), plays: () => calls.filter((call) => call[0] === 'play').map((call) => call[1]), applyMove, @@ -175,6 +179,9 @@ test('a refused update does not roll the origin back past a later accepted one', await h.advance(); // The refused update did not move the window; the accepted one did. assert.deepEqual(h.updates(), [['update', 1030, 500], ['update', 1090, 500]]); + // The Host applied the accepted update at the next frame, so the pointer is + // measured against the window it already moved. + h.applyMove(); pointer.x = 1170; h.move(pointer, 'local'); await h.advance(); @@ -189,9 +196,12 @@ test('a fast drag counts a movement that has not landed yet only once', async () const h = harness(); const pointer = { x: 1100, y: 700 }; h.press(pointer, 'local'); - // The second move arrives before the Host applied the first one. pointer.x = 1130; h.move(pointer, 'local'); + // The Host moved the window to 1030 but has not answered the request yet. + h.applyMove(); + // The second move arrives before the answer, measured against the window + // the Host already moved. pointer.x = 1160; h.move(pointer, 'local'); await h.advance(); @@ -202,20 +212,57 @@ test('a fast drag counts a movement that has not landed yet only once', async () assert.deepEqual(h.host, { x: 1060, y: 500 }); }); -test('a pointermove that arrives while the Host moves the window does not step it back', async () => { +test('a pending move of one pointer step keeps the target on the pointer', async () => { + const h = harness(); + const pointer = { x: 1100, y: 700 }; + h.press(pointer, 'local'); + // s = 10: the update in flight moves the window by one pointer step. + pointer.x = 1110; + h.move(pointer, 'local'); + h.applyMove(); + // v = 10: the pointer moves one more step while the answer is pending. + pointer.x = 1120; + h.move(pointer, 'local'); + await h.advance(); + await h.advance(); + // The confirmed origin plus the pointer offset is the only target that + // holds when the pending move and the pointer step are the same size. + assert.deepEqual(h.updates(), [['update', 1010, 500], ['update', 1020, 500]]); + assert.deepEqual(h.host, { x: 1020, y: 500 }); +}); + +test('a pending move of two pointer steps keeps the target on the pointer', async () => { + const h = harness(); + const pointer = { x: 1100, y: 700 }; + h.press(pointer, 'local'); + // s = 20: the update in flight moves the window by two pointer steps. + pointer.x = 1120; + h.move(pointer, 'local'); + h.applyMove(); + // v = 10: the pointer moves one step while the answer is pending. + pointer.x = 1130; + h.move(pointer, 'local'); + await h.advance(); + await h.advance(); + assert.deepEqual(h.updates(), [['update', 1020, 500], ['update', 1030, 500]]); + assert.deepEqual(h.host, { x: 1030, y: 500 }); +}); + +test('the release waits for the update in flight before the final position', async () => { const h = harness(); const pointer = { x: 1100, y: 700 }; h.press(pointer, 'local'); pointer.x = 1130; h.move(pointer, 'local'); - // The Host moved the window to 1030 but has not answered the request yet. h.applyMove(); - pointer.x = 1140; + pointer.x = 1160; h.move(pointer, 'local'); + // The release happens while the update is still in flight: nothing goes out + // yet, the final position waits for the answer. + h.release(pointer, 'local'); + assert.deepEqual(h.updates(), [['update', 1030, 500]]); await h.advance(); - // The pointer is measured against the window the Host already moved to, so - // the next target is 1040, not 1010. - assert.deepEqual(h.updates(), [['update', 1030, 500], ['update', 1040, 500]]); await h.advance(); - assert.deepEqual(h.host, { x: 1040, y: 500 }); + assert.deepEqual(h.updates(), [['update', 1030, 500], ['update', 1060, 500]]); + assert.deepEqual(h.host, { x: 1060, y: 500 }); }); diff --git a/sources/minimax-ai/minimax-pet/miniapp/client/pet-app.js b/sources/minimax-ai/minimax-pet/miniapp/client/pet-app.js index c0b4c31..3d056b6 100644 --- a/sources/minimax-ai/minimax-pet/miniapp/client/pet-app.js +++ b/sources/minimax-ai/minimax-pet/miniapp/client/pet-app.js @@ -899,20 +899,19 @@ function onSpritePointerDown(e) { const origin = app.winPos ? { ...app.winPos } : null; app.drag = { pointerId: e.pointerId, - // The pointer is measured inside the window, so the window goes where the - // pointer is, minus the offset it was grabbed at. + // The pointer is measured inside the window, so the window follows the + // pointer offset from the grab point. grabX: e.clientX, grabY: e.clientY, - // Window origin at grab time; null when the Host reported no position. - base: origin, - // Window origin the Host confirmed, and the target of the request in - // flight. The Host can move the window before it answers the request. + // Latest client coordinates of the pointer. + pointer: { x: e.clientX, y: e.clientY }, + // How many pointer events arrived; the request in flight remembers the + // count it was built from. + moves: 0, + sentMoves: -1, + // Window origin the Host confirmed; null when it reported no position. origin: origin ? { ...origin } : null, - inflight: null, - // Pointer position on screen, tracked across events. - pointer: origin ? { x: origin.x + e.clientX, y: origin.y + e.clientY } : null, - // Latest target not sent yet, and whether an update is already in flight. - pending: null, + // Whether an update is in flight. sending: false, // Reference for the run direction: the window origin when it is known, // otherwise the pointer position inside the window. @@ -926,12 +925,13 @@ function onSpritePointerDown(e) { function onSpritePointerMove(e) { const d = app.drag; if (!d || e.pointerId !== d.pointerId) return; - const dx = e.clientX - d.grabX; - const dy = e.clientY - d.grabY; - const target = d.origin ? dragTarget(d, e) : null; + // While an update is in flight only the latest pointer position is kept: + // the target is recomputed from the confirmed origin when the Host answers. + d.pointer = { x: e.clientX, y: e.clientY }; + d.moves += 1; if (!d.active) { // How far the pointer travelled on screen, not inside the window. - const travel = target ? { x: target.x - d.base.x, y: target.y - d.base.y } : { x: dx, y: dy }; + const travel = { x: e.clientX - d.grabX, y: e.clientY - d.grabY }; if (Math.hypot(travel.x, travel.y) <= DRAG_THRESHOLD) return; d.active = true; app.dragging = true; @@ -943,67 +943,53 @@ function onSpritePointerMove(e) { refreshInteractivity(); if (!d.origin) console.warn('[pet] drag_without_window_position: no window position yet, animation only'); } - if (!target) { + if (!d.origin) { // No window position: the pet still runs, but nothing can move. updateDragDirection(d, e.clientX); return; } - // Keep only the latest target; one update is in flight at a time. - d.pending = target; flushDrag(d); } -// Where the window belongs: the pointer position minus the grab offset. The -// pointer is measured inside the window, so its position needs the origin of -// the frame it was measured in: the origin the Host confirmed, or the target -// the Host already moved to while the answer is still pending. The pointer -// moves a few pixels between events, a window move is bigger than that, so -// the pointer itself tells which frame the event used. -function dragTarget(d, e) { - const frames = d.inflight ? [d.origin, d.inflight] : [d.origin]; - let pointer = null; - let nearest = Infinity; - for (const frame of frames) { - const p = { x: frame.x + e.clientX, y: frame.y + e.clientY }; - const moved = d.pointer ? Math.hypot(p.x - d.pointer.x, p.y - d.pointer.y) : 0; - if (moved < nearest) { - nearest = moved; - pointer = p; - } - } - d.pointer = pointer; - return { x: Math.round(pointer.x - d.grabX), y: Math.round(pointer.y - d.grabY) }; +// Where the window belongs: the origin the Host confirmed plus the pointer +// offset from the grab point. The Host answers with the position it holds, so +// the target is recomputed from the confirmed origin whenever it changes. +function dragTarget(d) { + return { + x: Math.round(d.origin.x + (d.pointer.x - d.grabX)), + y: Math.round(d.origin.y + (d.pointer.y - d.grabY)), + }; } -// One window update at a time. The Host answers with the position it holds, -// so the next target is measured against a confirmed position, and a refused -// update can never roll the window back past a later accepted one. +// One window update at a time. While it is in flight the pointer keeps +// moving, so the target is recomputed from the confirmed origin when the Host +// answers, and a refused update never rolls the window back past a later +// accepted one. function flushDrag(d) { - if (d.sending || !d.pending) return; - const target = d.pending; - d.pending = null; + if (d.sending || !d.origin) return; + const target = dragTarget(d); if (target.x === d.origin.x && target.y === d.origin.y) return; d.sending = true; - d.inflight = target; - updateDragDirection(d, target.x); + d.sentMoves = d.moves; + if (app.drag === d) updateDragDirection(d, target.x); callBridge(() => win.update({ x: target.x, y: target.y })).then((state) => { d.sending = false; - d.inflight = null; const moved = state?.window; if (moved) { d.origin = { x: moved.x, y: moved.y }; app.winPos = { x: moved.x, y: moved.y }; } else if (app.winPos) d.origin = { ...app.winPos }; - flushDrag(d); + // A pointer event that arrived after the request means the pointer may + // have moved: recompute the target from the confirmed origin. + if (d.moves !== d.sentMoves) flushDrag(d); }, (error) => { d.sending = false; - d.inflight = null; // The Host refused the move and kept the window where it was. if (app.winPos) d.origin = { ...app.winPos }; const code = errCode(error); if (code === 'not_permitted') console.debug('[pet] drag_move_rejected: not_permitted (target is off every display)'); else console.warn('[pet] drag_move_failed:', code, error); - flushDrag(d); + if (d.moves !== d.sentMoves) flushDrag(d); }); } @@ -1034,8 +1020,14 @@ function finishDrag(e, cancelled) { const d = app.drag; if (!d || e.pointerId !== d.pointerId) return; app.drag = null; - // The last pointer position is where the pet belongs: send it. - if (!cancelled) flushDrag(d); + if (!cancelled) { + // The release position is the most accurate one: the pointer may have + // moved since the last pointermove. While an update is in flight the + // final position goes out once the Host answers it. + d.pointer = { x: e.clientX, y: e.clientY }; + d.moves += 1; + flushDrag(d); + } try { if (els.sprite.hasPointerCapture(e.pointerId)) els.sprite.releasePointerCapture(e.pointerId); } catch (error) {