diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b335ab6d0..77645be91b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,7 +53,7 @@ Bots can be pinned as pixel Window Companions that play their replies, binding o - Bot settings can export and inspect one verified complete `.botharness-backup`, including retained custom/deleted Memory, current attachments, model templates and independent plans, and the canonical purge checkpoint; the installed local command restores only into a new stopped environment ([#886](https://github.com/BotHarness/DeepSeekBot/issues/886), [guide](docs/settings.md#complete-environment-backup-and-restore)). -- Window Companion bubbles follow the displayed pixel bust's head through tilt, stretch and movement, retain an origin connector when cards move to avoid overlap or viewport edges, and keep text upright; resize updates the clamped character and bubble origin together, while image and unsupported-rig fallbacks use their visible box top ([#1177](https://github.com/BotHarness/DeepSeekBot/issues/1177)). +- Window Companion bubbles follow the displayed pixel bust's head through tilt, stretch and movement, retain an origin connector when cards move to avoid overlap or viewport edges, and keep text upright; resize, dragging, keyboard movement and reduced-motion release update the character and bubble origin together, while image and unsupported-rig fallbacks use their visible box top ([#1177](https://github.com/BotHarness/DeepSeekBot/issues/1177), [#1248](https://github.com/BotHarness/DeepSeekBot/issues/1248)). - WeChat identity enablement and native typing can inherit independent global defaults or retain Bot-specific overrides; existing choices are preserved on upgrade, and unsupported group/Thread settings stay hidden ([#912](https://github.com/BotHarness/BotHarness/issues/912), [guide](docs/wechat-connection.md)). diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index 1494d2cf81..20e75a9bbc 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -53,7 +53,7 @@ Bot 可以作为像素窗口伙伴固定在窗口里播放回复;绑定一个 - Bot 设置可导出、检查一个完整且已验证的 `.botharness-backup`,包含自定义及已删除身份保留的 Memory、当前附件、模型模板与独立计划,以及真实清除检查点;安装包提供的本地命令仅恢复至新的停止环境([#886](https://github.com/BotHarness/DeepSeekBot/issues/886), [guide](docs/settings.md#complete-environment-backup-and-restore))。 -- 窗口伙伴气泡随像素半身的头部倾斜、拉伸与移动定位;卡片因避让或视口限位偏移时保留来源连线,文字保持直立;窗口缩小时人物与连线锚点同步限位,图片与不支持的形象使用可见盒子顶部锚点([#1177](https://github.com/BotHarness/DeepSeekBot/issues/1177))。 +- 窗口伙伴气泡随像素半身的头部倾斜、拉伸与移动定位;卡片因避让或视口限位偏移时保留来源连线,文字保持直立;窗口缩放、拖拽、键盘移动与减少动效下的松手均同步更新人物和连线锚点,图片与不支持的形象使用可见盒子顶部锚点([#1177](https://github.com/BotHarness/DeepSeekBot/issues/1177)、[#1248](https://github.com/BotHarness/DeepSeekBot/issues/1248))。 - 微信身份启停与原生输入状态可分别继承全局默认值或保留 Bot 自定义;升级保留现有选择,界面不显示未支持的群聊/Thread 设置([#912](https://github.com/BotHarness/BotHarness/issues/912),[指南](docs/wechat-connection.md))。 diff --git a/docs/assets/pr/1248-companion-release-anchor/README.md b/docs/assets/pr/1248-companion-release-anchor/README.md new file mode 100644 index 0000000000..f680d67d60 --- /dev/null +++ b/docs/assets/pr/1248-companion-release-anchor/README.md @@ -0,0 +1,27 @@ +# Direct-motion anchor synchronization + +Follow-up #1248 to merged #1183 and parent #1177. Real DSH 0.2.0-rc.1, formal pixel-avatar0.8.0, Chinese,1559×865. Baseline runtime4dbaa; corrected runtimefbb29752. The latter has an identical complete tracked tree to follow-up426167c9 from merged main85e9dfbe. New evidence commits do not change runtime code. + +## Actual reduced-motion release + +| Before, settled | After, settled | +| ------------------------------------ | -------------------------------------- | +| ![](release-before-settled-dark.png) | ![](reduced-released-reading-dark.png) | + +These PNGs do not show the transient defect. A read-only QA observer compares the actual displayed head top-center and SVG connector origin in the first rAF callback after a trusted native pointerup. Before: character bottom865 but connector still points to screen y32 instead of769 (737px error). After: both origins match at y769 (0px). Both later120-sample reading/keyboard/theme/release workloads pass, illustrating why settled checks alone missed the defect. A mounted public View regression independently fails before the fix with300px error and passes after, without executing a subsequent animation frame. + +[Continuous baseline recording](release-before.webm) · [Continuous corrected recording](release-after.webm) · [Sanitized qualification](qualification.json) + +## Corrected reading and keyboard movement + +| Light reading, genuine tool cover during initial reply | Light after native arrow keys | Dark reading | +| ------------------------------------------------------ | ------------------------------- | ----------------------------- | +| ![](reduced-reading-light.png) | ![](reduced-keyboard-light.png) | ![](reduced-reading-dark.png) | + +Two exact new Bot-authored replies are independently checked against canonical Channel messages. Normal public character focus retains the reading list; native pause controls stop autonomous walking. Both original absent Avatar Appearance/image fields remain unchanged. All four phases stay in one document; cards remain upright and within the viewport. Console inspection finds0JavaScript errors and all owned test pages/browser are closed. + +## Boundaries and preserved failures + +Same Profile, identities, history, viewport, theme and scenario; reply marker and sidebar unread count differ. The first baseline setup timed out waiting for visible cards despite real replies. That attempt is retained privately. The next baseline adds live-stream and outside-focus preconditions, so recovery does not prove its cause. It exposes the737px first-release failure; the corrected run then passes the same scenario. + +Recordings are continuous silent official MCP output, container-remuxed only. No synthetic reply/SSE/card injection, interpolation, dubbed audio or invented first-frame image. Sampling does not prove all compositor frames, performance, held/reversing drag, moving-hover/grace or genuine background behavior. Those broader #1177/#1167 gates remain separate. Quiet-idle presentation belongs to independent #1173. diff --git a/docs/assets/pr/1248-companion-release-anchor/qualification.json b/docs/assets/pr/1248-companion-release-anchor/qualification.json new file mode 100644 index 0000000000..675f27922a --- /dev/null +++ b/docs/assets/pr/1248-companion-release-anchor/qualification.json @@ -0,0 +1,120 @@ +{ + "baselineRuntime": "4dbaa0ad07e1d00b9e3d51ad4e2427552bfa509c", + "correctedRuntime": "fbb29752abb56c02ece77c9e99c4c68285ec6fb7", + "equivalentFollowupCommit": "426167c98efe09c2f9c1bbe85f0d8ea0aba02979", + "followupBase": "85e9dfbee4e966049eea4a5e847b114764008e80", + "originalIntegratedMain": "66d2767a065af49afb999f3792d8754a50fc66ff", + "formalPixelAvatar": "0.8.0", + "viewport": [1559, 865], + "locale": "zh-CN", + "themes": ["light", "dark"], + "before": { + "phases": [ + { + "label": "light-reduced-reading", + "samples": 30, + "maxOriginErrorPx": 0, + "allCardsUpright": true + }, + { + "label": "keyboard-moved-reduced", + "samples": 30, + "maxOriginErrorPx": 0, + "allCardsUpright": true + }, + { + "label": "dark-reduced-reading", + "samples": 30, + "maxOriginErrorPx": 0, + "allCardsUpright": true + }, + { + "label": "native-reduced-release-reading", + "samples": 30, + "maxOriginErrorPx": 0, + "allCardsUpright": true + } + ], + "firstObservedFrameAfterRelease": { + "policy": "reduce", + "phase": "rest", + "reading": "true", + "characterBottom": 865, + "viewportHeight": 865, + "expectedScreenPoint": { + "x": 745.484375, + "y": 769 + }, + "connectorScreenPoint": { + "x": 745.484375, + "y": 32 + }, + "originErrorPx": 737, + "visibleCards": 2, + "actualTrustedPointerup": true + }, + "closedOwnedBrowser": true + }, + "after": { + "phases": [ + { + "label": "light-reduced-reading", + "samples": 30, + "maxOriginErrorPx": 0, + "allCardsUpright": true + }, + { + "label": "keyboard-moved-reduced", + "samples": 30, + "maxOriginErrorPx": 0, + "allCardsUpright": true + }, + { + "label": "dark-reduced-reading", + "samples": 30, + "maxOriginErrorPx": 0, + "allCardsUpright": true + }, + { + "label": "native-reduced-release-reading", + "samples": 30, + "maxOriginErrorPx": 0, + "allCardsUpright": true + } + ], + "firstObservedFrameAfterRelease": { + "policy": "reduce", + "phase": "rest", + "reading": "true", + "characterBottom": 865, + "viewportHeight": 865, + "expectedScreenPoint": { + "x": 745.484375, + "y": 769 + }, + "connectorScreenPoint": { + "x": 745.484375, + "y": 769 + }, + "originErrorPx": 0, + "visibleCards": 2, + "actualTrustedPointerup": true + }, + "exactCanonicalBotReplies": 2, + "originalAppearanceUnchanged": true, + "sameDocument": true, + "consoleErrors": 0, + "closedOwnedBrowser": true + }, + "limits": [ + "Native official MCP drag/release and keyboard operations; two newly generated canonical Bot replies, no synthetic presentation, replies or SSE.", + "Normal public character focus retains reading cards; walking paused through native controls; OS reduced-motion preference selected at browser startup.", + "Dark before/after PNGs are settled states and do not show the transient failure. The rAF observer catches the first observed callback after trusted pointerup, not every compositor frame.", + "Same Profile, identities, history, viewport, theme and scenario; reply marker and sidebar unread count differ. Initial light capture includes a genuine transient tool cover.", + "Before: 120 later samples passed despite a 737px first-release mismatch. After: 120 samples plus the first release observation pass. This is bounded geometry qualification, not performance or proof about unsampled frames.", + "An earlier initial-setup attempt timed out waiting for two visible cards. Its native replies and failed capture are retained privately; no root cause is claimed. The next baseline attempt added both live-stream preconditions and outside focus before arrival, so recovery does not establish which change mattered.", + "Before runtime 4dbaa was merged by PR1183 while the correction was being prepared. The correction was moved to a dedicated follow-up from that squash merge; complete tracked trees fbb29752 and 426167c9 are identical.", + "Continuous silent official MCP recordings, remux only; no audio, frame interpolation or invented first-frame screenshot.", + "Held/reversing drag, moving hover/grace and genuine background qualification remain under1177; performance remains under1167. This fix does not qualify those scenarios." + ] +} diff --git a/docs/assets/pr/1248-companion-release-anchor/reduced-keyboard-light.png b/docs/assets/pr/1248-companion-release-anchor/reduced-keyboard-light.png new file mode 100644 index 0000000000..a4968a5f00 Binary files /dev/null and b/docs/assets/pr/1248-companion-release-anchor/reduced-keyboard-light.png differ diff --git a/docs/assets/pr/1248-companion-release-anchor/reduced-reading-dark.png b/docs/assets/pr/1248-companion-release-anchor/reduced-reading-dark.png new file mode 100644 index 0000000000..8ec42cc37f Binary files /dev/null and b/docs/assets/pr/1248-companion-release-anchor/reduced-reading-dark.png differ diff --git a/docs/assets/pr/1248-companion-release-anchor/reduced-reading-light.png b/docs/assets/pr/1248-companion-release-anchor/reduced-reading-light.png new file mode 100644 index 0000000000..638da021a6 Binary files /dev/null and b/docs/assets/pr/1248-companion-release-anchor/reduced-reading-light.png differ diff --git a/docs/assets/pr/1248-companion-release-anchor/reduced-released-reading-dark.png b/docs/assets/pr/1248-companion-release-anchor/reduced-released-reading-dark.png new file mode 100644 index 0000000000..d7d58bb47e Binary files /dev/null and b/docs/assets/pr/1248-companion-release-anchor/reduced-released-reading-dark.png differ diff --git a/docs/assets/pr/1248-companion-release-anchor/release-after.webm b/docs/assets/pr/1248-companion-release-anchor/release-after.webm new file mode 100644 index 0000000000..63d27363e3 Binary files /dev/null and b/docs/assets/pr/1248-companion-release-anchor/release-after.webm differ diff --git a/docs/assets/pr/1248-companion-release-anchor/release-before-settled-dark.png b/docs/assets/pr/1248-companion-release-anchor/release-before-settled-dark.png new file mode 100644 index 0000000000..427fd7deac Binary files /dev/null and b/docs/assets/pr/1248-companion-release-anchor/release-before-settled-dark.png differ diff --git a/docs/assets/pr/1248-companion-release-anchor/release-before.webm b/docs/assets/pr/1248-companion-release-anchor/release-before.webm new file mode 100644 index 0000000000..e976980c0c Binary files /dev/null and b/docs/assets/pr/1248-companion-release-anchor/release-before.webm differ diff --git a/packages/client/src/client/window-companion-view.tsx b/packages/client/src/client/window-companion-view.tsx index 476aabe91f..fe4d2b4278 100644 --- a/packages/client/src/client/window-companion-view.tsx +++ b/packages/client/src/client/window-companion-view.tsx @@ -65,6 +65,7 @@ export function WindowCompanionView({ latest.current = view; const [motion] = useState(() => new CompanionMotion()); const [point, setPoint] = useState(motion.point); + const presentPoint = useRef<(next: CompanionPoint) => void>(setPoint); const displayedPoint = useRef(point); displayedPoint.current = point; const anchor = useRef(undefined); @@ -119,7 +120,7 @@ export function WindowCompanionView({ if (!pointer.current || pointer.current.id !== event.pointerId || motion.point.phase !== 'drag') return; pointer.current = undefined; - setPoint(motion.release(performance.now(), reducedMotion(), true)); + presentPoint.current(motion.release(performance.now(), reducedMotion(), true)); persistPosition(); }; const leave = (): void => { @@ -196,6 +197,10 @@ export function WindowCompanionView({ } } }; + presentPoint.current = (next) => { + placeBubble(next, anchor.current?.read()); + setPoint(next); + }; const measure = () => { const box = node.getBoundingClientRect(); const width = box.width; @@ -219,8 +224,7 @@ export function WindowCompanionView({ drag.originX = next.x; drag.originY = next.y; } - setPoint(next); - placeBubble(next, anchor.current?.read()); + presentPoint.current(next); }; const tick = (now: number) => { frame = 0; @@ -272,7 +276,8 @@ export function WindowCompanionView({ const target = node.querySelector('.bh-companion'); if (target) observer?.observe(target); const policy = new MutationObserver(() => { - if (reducedMotion()) setPoint(motion.advance(0, true, false, direction.current)); + if (reducedMotion()) + presentPoint.current(motion.advance(0, true, false, direction.current)); }); policy.observe(document.documentElement, { attributes: true, @@ -291,6 +296,7 @@ export function WindowCompanionView({ if (exit.current !== undefined) clearTimeout(exit.current); if (clickReset.current !== undefined) clearTimeout(clickReset.current); pointer.current = undefined; + presentPoint.current = setPoint; if (latest.current.selection) bubbleOwner.remove(latest.current.selection.botId); root.current = null; }; @@ -592,7 +598,9 @@ export function WindowCompanionView({ } if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); - setPoint(motion.move(motion.point.x + (event.key === 'ArrowLeft' ? -24 : 24))); + presentPoint.current( + motion.move(motion.point.x + (event.key === 'ArrowLeft' ? -24 : 24)), + ); persistPosition(); } }} @@ -610,7 +618,7 @@ export function WindowCompanionView({ originY: motion.point.y, moved: false, }; - setPoint(motion.grab(performance.now(), reducedMotion())); + presentPoint.current(motion.grab(performance.now(), reducedMotion())); }} onPointerMove={(event) => { const drag = pointer.current; @@ -621,7 +629,7 @@ export function WindowCompanionView({ drag.lastY = event.clientY; drag.moved ||= Math.hypot(dx, dy) > 6; if (!drag.moved) return; - setPoint( + presentPoint.current( motion.drag(drag.originX + dx, drag.originY + dy, performance.now(), reducedMotion()), ); }} @@ -630,7 +638,7 @@ export function WindowCompanionView({ if (!drag || drag.id !== event.pointerId) return; event.currentTarget.releasePointerCapture(event.pointerId); if (drag.moved) { - setPoint(motion.release(performance.now(), reducedMotion())); + presentPoint.current(motion.release(performance.now(), reducedMotion())); persistPosition(); event.preventDefault(); clickReset.current = setTimeout(() => { @@ -639,7 +647,7 @@ export function WindowCompanionView({ }, 0); } else { pointer.current = undefined; - setPoint(motion.release(performance.now(), reducedMotion(), true)); + presentPoint.current(motion.release(performance.now(), reducedMotion(), true)); } }} onPointerCancel={cancelDrag} diff --git a/packages/client/test/window-companion-view.test.ts b/packages/client/test/window-companion-view.test.ts index 75024e228b..4066e60f70 100644 --- a/packages/client/test/window-companion-view.test.ts +++ b/packages/client/test/window-companion-view.test.ts @@ -39,7 +39,7 @@ vi.mock('@deepseek-ai/dsh-client-ui-primitives', () => ({ IconNewChatOutlineRegular: () => null, })); -it('projects the owning Avatar screen point into an offset overlay and remeasures it after resize', async () => { +it('keeps the Avatar tether aligned through resize and the first reduced-motion release commit', async () => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); const frames = new Map(); let frameId = 0; @@ -115,6 +115,29 @@ it('projects the owning Avatar screen point into an offset overlay and remeasure expect(node.querySelector('.bh-companion-tether path')?.getAttribute('d')).toMatch( /^M 264 404 /u, ); + await act(() => { + document.documentElement.dataset['botharnessMotion'] = 'reduce'; + }); + const character = node.querySelector('.bh-companion-character')!; + character.setPointerCapture = vi.fn(); + character.releasePointerCapture = vi.fn(); + const pointer = (type: string, x: number, y: number) => { + const event = new MouseEvent(type, { bubbles: true, clientX: x, clientY: y, button: 0 }); + Object.defineProperty(event, 'pointerId', { value: 1 }); + character.dispatchEvent(event); + }; + await act(() => pointer('pointerdown', 264, 450)); + await act(() => pointer('pointermove', 164, 150)); + await act(frame); + expect(node.querySelector('.bh-companion')?.style.bottom).toBe('300px'); + expect(node.querySelector('.bh-companion-tether path')?.getAttribute('d')).toMatch( + /^M 164 104 /u, + ); + await act(() => pointer('pointerup', 164, 150)); + expect(node.querySelector('.bh-companion')?.style.bottom).toBe('0px'); + expect(node.querySelector('.bh-companion-tether path')?.getAttribute('d')).toMatch( + /^M 164 404 /u, + ); const visibility = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true); await act(() => document.dispatchEvent(new Event('visibilitychange'))); expect(frames.size).toBe(0); @@ -125,6 +148,7 @@ it('projects the owning Avatar screen point into an offset overlay and remeasure node.remove(); measurement.mockRestore(); vi.unstubAllGlobals(); + delete document.documentElement.dataset['botharnessMotion']; } });