You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/10 02:34:00 UTC

(maka) 02/02: fix(desktop): stabilize WorkHub fold motion without changing layout

This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/workhub-progress-input
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 09be211d494c98bd8e56e75e4ccf4bcabef1b194
Author: Wang <m4...@gmail.com>
AuthorDate: Thu Sep 10 10:33:50 2026 +0800

    fix(desktop): stabilize WorkHub fold motion without changing layout
    
    Generated-by: OpenAI Codex
---
 apps/desktop/e2e/workhub-layout.spec.ts              | 20 +++++++++++++++++++-
 .../src/main/__tests__/workhub-presentation.test.ts  |  5 ++++-
 apps/desktop/src/main/workhub-presentation.ts        |  6 +++++-
 .../renderer/features/workhub/ui/workhub-root.tsx    | 20 ++++++++++++++++++++
 apps/desktop/src/renderer/styles/workhub.css         |  6 ++++++
 5 files changed, 54 insertions(+), 3 deletions(-)

diff --git a/apps/desktop/e2e/workhub-layout.spec.ts b/apps/desktop/e2e/workhub-layout.spec.ts
index ad2b050257..fb799c552d 100644
--- a/apps/desktop/e2e/workhub-layout.spec.ts
+++ b/apps/desktop/e2e/workhub-layout.spec.ts
@@ -252,7 +252,25 @@ test('WorkHub uses its coordination model and shared attachment composer', async
   expect(await workhub.evaluate(() => innerHeight)).toBe(longInputHeight);
   await editor.fill('Keep this draft while folding the conversation.');
   await expect.poll(() => workhub.evaluate(() => innerHeight)).toBe(compactHeight);
-  await workhub.getByRole('button', { name: /展开对话|Expand conversation/ }).click();
+  // Sample the real native resize, including repeated folds. The composer
+  // must stay inside the window while its original 12px gutter interpolates.
+  for (const expanded of [true, false, true]) {
+    const motion = await workhub.getByRole('button', { name: expanded ? /展开对话|Expand conversation/ : /收起对话|Collapse conversation/ }).evaluate((button) => new Promise<{ bottom: number; left: number }[]>((resolve) => {
+      const frames: { bottom: number; left: number }[] = [];
+      const started = performance.now();
+      const sample = () => {
+        const rect = document.querySelector('.workHubComposerSurface')!.getBoundingClientRect();
+        frames.push({ bottom: innerHeight - rect.bottom, left: rect.left });
+        if (performance.now() - started < 500) requestAnimationFrame(sample);
+        else resolve(frames);
+      };
+      (button as HTMLButtonElement).click();
+      requestAnimationFrame(sample);
+    }));
+    expect(motion.every(({ bottom, left }) => bottom >= -0.5 && bottom <= 12.5 && left >= -0.5 && left <= 12.5)).toBe(true);
+    expect(motion.some(({ bottom }) => bottom > 0.5 && bottom < 11.5)).toBe(true);
+    expect(motion.at(-1)!.bottom).toBeCloseTo(expanded ? 12 : 0);
+  }
   await expect(workhub.locator('.workHubHistory')).toBeVisible();
   await expect.poll(() => workhub.evaluate(() => window.innerHeight)).toBe(expandedHeight);
   await expect.poll(floatingBottom).toBe(anchoredBottom);
diff --git a/apps/desktop/src/main/__tests__/workhub-presentation.test.ts b/apps/desktop/src/main/__tests__/workhub-presentation.test.ts
index 91dea1bc0b..6bb419b91f 100644
--- a/apps/desktop/src/main/__tests__/workhub-presentation.test.ts
+++ b/apps/desktop/src/main/__tests__/workhub-presentation.test.ts
@@ -130,7 +130,9 @@ async function harness(animate = false, displayFrequency = 60) {
   runInNewContext(output.outputFiles[0]!.text, {
     module, exports: module.exports, console, process, URL, Error,
     performance: { now: () => now },
-    setTimeout: (callback: () => void, delay: number) => { timers.set(++timerId, { at: now + delay, callback }); return timerId; },
+    // Native timers truncate fractional delays, so callbacks can precede a
+    // display deadline. Model that instead of an ideal fractional clock.
+    setTimeout: (callback: () => void, delay: number) => { timers.set(++timerId, { at: now + Math.max(1, Math.floor(delay)), callback }); return timerId; },
     clearTimeout: (id: number) => timers.delete(id),
     require: (name: string) => name === 'electron' ? {
       BrowserWindow: FakeWindow, WebContentsView: FakeView,
@@ -618,6 +620,7 @@ test('native resize callbacks do not submit duplicate view bounds and follow dis
   const before = view.boundsUpdates.length;
   h.advance(9);
   assert.ok(view.boundsUpdates.length > before, 'a 120Hz display gets its next animation frame before 16ms');
+  assert.equal(view.boundsUpdates.length - before, 1, 'an early timer must not submit a second resize for the same display frame');
   h.advance(411);
   assert.equal(h.windows[1]!.bounds.height, 720);
   for (let index = 1; index < view.boundsUpdates.length; index++) {
diff --git a/apps/desktop/src/main/workhub-presentation.ts b/apps/desktop/src/main/workhub-presentation.ts
index 304285508b..78718329d1 100644
--- a/apps/desktop/src/main/workhub-presentation.ts
+++ b/apps/desktop/src/main/workhub-presentation.ts
@@ -161,6 +161,7 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) {
     const started = performance.now();
     const frequency = screen.getDisplayMatching(bounds).displayFrequency;
     const frameDuration = 1000 / (Number.isFinite(frequency) && frequency > 0 ? frequency : 60);
+    let frame = 0;
     let previous = initial;
     const tick = () => {
       if (disposed || window.isDestroyed() || floating !== window || placement !== 'floating') {
@@ -185,7 +186,10 @@ export function createWorkHubPresentation(deps: WorkHubPresentationDeps) {
         // Keep frame deadlines independent of native resize work; do not add
         // another full frame's delay after every setBounds/resize callback.
         const elapsed = performance.now() - started;
-        const nextFrame = Math.min(RESIZE_DURATION, (Math.floor(elapsed / frameDuration) + 1) * frameDuration);
+        // Timers can fire just before their deadline. Always advance the frame
+        // index so an early callback cannot submit two native resizes per frame.
+        frame = Math.max(frame + 1, Math.floor(elapsed / frameDuration) + 1);
+        const nextFrame = Math.min(RESIZE_DURATION, frame * frameDuration);
         resizeTimer = setTimeout(tick, Math.max(1, nextFrame - elapsed));
       }
       else cancelFloatingAnimation();
diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx
index 5be98e92ec..bda68d5c4d 100644
--- a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx
+++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx
@@ -90,6 +90,26 @@ export function WorkHubRoot() {
   const editingProgress = progress && editingProgressRequest === presentation.progressRequest;
   const floating = presentation?.placement === 'floating';
   const showConversation = !progress && (!floating || conversationExpanded);
+  const dockMotion = useRef<{ floating: boolean; progress: boolean; expanded: boolean; padding: Keyframe; animation?: Animation }>(undefined);
+  useLayoutEffect(() => {
+    const dock = composerSurface.current?.parentElement?.parentElement;
+    if (!dock) return;
+    const readPadding = (): Keyframe => {
+      const style = getComputedStyle(dock);
+      return { paddingInlineStart: style.paddingInlineStart, paddingInlineEnd: style.paddingInlineEnd, paddingBottom: style.paddingBottom };
+    };
+    const previous = dockMotion.current;
+    const from = previous?.animation?.playState === 'running' ? readPadding() : previous?.padding;
+    previous?.animation?.cancel();
+    const padding = readPadding();
+    // Animate only a live expand/collapse. Summoning a parked renderer must
+    // paint its final gutter immediately; interrupted motion resumes in place.
+    const animation = floating && previous?.floating && !progress && !previous.progress && previous.expanded !== showConversation && from && !window.matchMedia('(prefers-reduced-motion: reduce)').matches
+      ? dock.animate([from, padding], { duration: 420, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' })
+      : undefined;
+    dockMotion.current = { floating, progress, expanded: showConversation, padding, animation };
+  }, [floating, progress, showConversation]);
+  useEffect(() => () => dockMotion.current?.animation?.cancel(), []);
   const editProgress = () => {
     if (progress) setEditingProgressRequest(presentation.progressRequest);
   };
diff --git a/apps/desktop/src/renderer/styles/workhub.css b/apps/desktop/src/renderer/styles/workhub.css
index 5591569493..e583485bbc 100644
--- a/apps/desktop/src/renderer/styles/workhub.css
+++ b/apps/desktop/src/renderer/styles/workhub.css
@@ -190,6 +190,12 @@
   .workHubLive[data-placement='floating'][data-conversation-expanded='false']::before { opacity: 1; }
   .workHubLive[data-placement='floating'] .workhub-anchor-rail { display: none; }
   .workHubLive[data-placement='floating'] .maka-chat-layout { background: transparent; }
+  /* Keep the sticky dock bottom-aligned even before the native window has
+     grown enough to fit its composer, 32px scroll button and 12px gaps. */
+  .workHubLive[data-placement='floating'][data-conversation-expanded='true'] > .maka-chat-layout {
+    position: absolute; bottom: 0; width: 100%; height: calc(100% - 32px);
+    min-height: calc(var(--workhub-composer-height, 96px) + 56px);
+  }
   .workHubLive[data-placement='floating'] .maka-composer {
     padding: 0;
     --_chat-composer-radius: 20px;


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org