From 936b407a724c56452773dccf04dc892dace5605d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 23:23:39 -0500 Subject: [PATCH] fix(desktop): free code-block scrollbar & last-line selection from the toggle overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ExpandableBlock` used a single full-width `absolute inset-x-0 bottom-0` button that was BOTH the fade overflow cue and the expand/collapse control. It sat on top of the whole 28px bottom strip, so it swallowed: - the horizontal scrollbar of a wide code block (couldn't scroll sideways), and - pointer/drag events on the block's last line (couldn't select/copy the tail). Split the two responsibilities: the full-width fade stays as a pure `pointer-events-none` cue, and the only clickable target is a compact toggle pinned to the right edge (`pointer-events-auto`), clear of the draggable scrollbar track. The inner container gains `overflow-x-auto` so wide code gets a working scrollbar. Applies uniformly to code cards and the plain-text fallback — no per-call prop needed. Regression test asserts the pointer-events contract (fade `pointer-events-none` + full-width, toggle `pointer-events-auto` + right-pinned, not `inset-x-0`) and that the toggle still flips `aria-expanded`. Supersedes #69558, #69428 Fixes #69168 Co-authored-by: alelpoan Co-authored-by: BerneYue --- .../components/chat/expandable-block.test.tsx | 72 +++++++++++++++++++ .../src/components/chat/expandable-block.tsx | 31 +++++--- 2 files changed, 93 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/components/chat/expandable-block.test.tsx diff --git a/apps/desktop/src/components/chat/expandable-block.test.tsx b/apps/desktop/src/components/chat/expandable-block.test.tsx new file mode 100644 index 000000000000..1fd23d0c8525 --- /dev/null +++ b/apps/desktop/src/components/chat/expandable-block.test.tsx @@ -0,0 +1,72 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { ExpandableBlock } from './expandable-block' + +// jsdom has no ResizeObserver and reports scrollHeight === 0, so the block +// never flips to `overflowing` on its own. Stub RO to fire immediately and +// force a tall scrollHeight on the observed node so the toggle mounts. +class TestResizeObserver { + constructor(private readonly callback: ResizeObserverCallback) {} + + observe(target: Element) { + Object.defineProperty(target, 'scrollHeight', { configurable: true, value: 400 }) + this.callback([{ target } as ResizeObserverEntry], this as unknown as ResizeObserver) + } + + unobserve() {} + disconnect() {} +} + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('ExpandableBlock', () => { + it('lets horizontal scroll through and keeps the last line selectable', () => { + vi.stubGlobal('ResizeObserver', TestResizeObserver) + + const { container } = render( + +
{'const x = 1\n'.repeat(20)}
+
+ ) + + const inner = container.querySelector('[data-testid="content"]')!.parentElement! + const toggle = screen.getByRole('button', { name: /expand|collapse/i }) + const fade = toggle.parentElement! + + // Inner container allows horizontal scroll so wide code gets a scrollbar. + expect(inner.className).toContain('overflow-x-auto') + + // The full-width fade is a pure cue: it spans the bottom edge but must not + // intercept pointer events, so the scrollbar drag and text selection on the + // last line pass through to the content underneath. + expect(fade.className).toContain('pointer-events-none') + expect(fade.className).toContain('inset-x-0') + + // Only the compact toggle is clickable, and it is pinned to the right edge + // rather than spanning the full width (the old bug). + expect(toggle.className).toContain('pointer-events-auto') + expect(toggle.className).toContain('w-9') + expect(toggle.className).not.toContain('inset-x-0') + }) + + it('still toggles expanded state when the compact control is clicked', () => { + vi.stubGlobal('ResizeObserver', TestResizeObserver) + + render( + +
{'line\n'.repeat(20)}
+
+ ) + + const toggle = screen.getByRole('button', { name: 'Expand' }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + + fireEvent.click(toggle) + + expect(screen.getByRole('button', { name: 'Collapse' }).getAttribute('aria-expanded')).toBe('true') + }) +}) diff --git a/apps/desktop/src/components/chat/expandable-block.tsx b/apps/desktop/src/components/chat/expandable-block.tsx index 92d2ab08c830..f4a8e1965298 100644 --- a/apps/desktop/src/components/chat/expandable-block.tsx +++ b/apps/desktop/src/components/chat/expandable-block.tsx @@ -31,19 +31,30 @@ export function ExpandableBlock({ children, className }: ExpandableBlockProps) { return (
-
+
{children}
{overflowing && ( - + // The fade is a pure overflow cue and must not intercept pointer events: + // it spans the full bottom edge (over the horizontal scrollbar of a wide + // code block AND the block's last line), so making it clickable killed + // both sideways scrolling and text selection. Keep the fade + // `pointer-events-none` and pin the only clickable target — a compact + // toggle — to the right edge, clear of the draggable scrollbar track. +
+ +
)}
)