From 6572edb40ef2ca507d09d0145161258e9127fcb1 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 7 Sep 2026 18:11:35 +0800 Subject: [PATCH] fix(desktop): isolate tail-button repaint from composer --- apps/desktop/src/renderer/styles/composer.css | 12 +++++ apps/desktop/stories/app-shell.stories.tsx | 48 +++++++++++++++++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css index 1e7af4adcf..d3c7db42fe 100644 --- a/apps/desktop/src/renderer/styles/composer.css +++ b/apps/desktop/src/renderer/styles/composer.css @@ -40,6 +40,18 @@ max-width: var(--maka-reading-measure); } +/* Reaching the transcript tail fades the scroll-to-bottom control while the + composer below it stays still. Both otherwise share the dock's filtered + compositing surface, so Chromium may re-raster the thin context-gauge paths + for that opacity transition: their DOM box does not move, but the icon + visibly hops by a device pixel. Keep the fading control in its own small + paint/compositing boundary. `astryx-chat-layout-scroll-button` is Astryx's + published theme class; the child is its fixed 32px transition container. */ +.astryx-chat-layout-scroll-button > div { + contain: layout style paint; + will-change: opacity, transform; +} + .maka-composer-queue { width: min(var(--maka-reading-measure), calc(100% - (2 * var(--space-6)))); max-width: var(--maka-reading-measure); diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 8c12078423..d2ee7cc8b9 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -2071,7 +2071,13 @@ const HISTORY_BATCH = 4; const HISTORY_BATCHES_AVAILABLE = 8; /** A settled transcript with a turn the play function can make arrive. */ -function SettledTranscriptHarness({ turns }: { turns: number }) { +function SettledTranscriptHarness({ + turns, + composer, +}: { + turns: number; + composer?: Partial; +}) { const [extra, setExtra] = useState(0); useEffect(() => { appendTurn = () => setExtra((count) => count + 1); @@ -2079,7 +2085,7 @@ function SettledTranscriptHarness({ turns }: { turns: number }) { appendTurn = undefined; }; }, []); - return ; + return ; } /** The history seam is two props: `hasOlderHistory`, and a loader that prepends. */ @@ -2135,9 +2141,31 @@ export const TailFollowsGrowthOutsideTurns: Story = { }; export const ReaderScrolledUpIsNotPulledBack: Story = { - render: () => , + render: () => ( + + ), play: async () => { const root = tailScroller(); + const contextGauge = document.querySelector( + 'button[aria-label="打开用量追踪"]', + ); + const scrollButtonPaintBoundary = dockButton().parentElement; + if (!contextGauge?.querySelector('svg') || !scrollButtonPaintBoundary) { + throw new Error('the context gauge or scroll-button paint boundary is missing'); + } + const boundaryStyle = getComputedStyle(scrollButtonPaintBoundary); + // Chromium canonicalizes `layout style paint` to the equivalent `content`. + expect(boundaryStyle.contain).toBe('content'); + expect(boundaryStyle.willChange).toContain('opacity'); await waitFor(() => expect(tailMetrics().distance).toBeLessThanOrEqual(4)); root.scrollTop -= 500; @@ -2169,6 +2197,20 @@ export const ReaderScrolledUpIsNotPulledBack: Story = { JSON.stringify({ anchorTurnId, anchorTop, afterTop, ...tailMetrics() }), ).toBeLessThanOrEqual(4); }); + + // Returning to the tail fades the dock button. That transition must not + // re-raster the context gauge on the same compositing surface (#4973). + // Geometry alone cannot see a device-pixel repaint, so the boundary above + // pins the causal contract; these samples separately ensure the fix never + // turns into a real footer movement. + const iconTops: number[] = []; + root.scrollTop = root.scrollHeight; + root.dispatchEvent(new Event('scroll')); + for (let frame = 0; frame < 16; frame += 1) { + await painted(1); + iconTops.push(contextGauge.querySelector('svg')!.getBoundingClientRect().top); + } + expect(Math.max(...iconTops) - Math.min(...iconTops)).toBeLessThanOrEqual(0.25); }, };