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. +
+ +
)}
)