fix(desktop): free code-block scrollbar & last-line selection from the toggle overlay

`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 <alelpoan@proton.me>
Co-authored-by: BerneYue <xiongyue_hnu@163.com>
This commit is contained in:
Brooklyn Nicholson 2026-07-22 23:23:39 -05:00
parent 390b03c455
commit 936b407a72
2 changed files with 93 additions and 10 deletions

View file

@ -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(
<ExpandableBlock>
<pre data-testid="content">{'const x = 1\n'.repeat(20)}</pre>
</ExpandableBlock>
)
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(
<ExpandableBlock>
<pre data-testid="content">{'line\n'.repeat(20)}</pre>
</ExpandableBlock>
)
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')
})
})

View file

@ -31,19 +31,30 @@ export function ExpandableBlock({ children, className }: ExpandableBlockProps) {
return (
<div className="relative">
<div className={cn('overflow-y-auto', expanded ? 'max-h-[40dvh]' : 'max-h-[7.5rem]', className)} ref={innerRef}>
<div
className={cn('overflow-y-auto overflow-x-auto', expanded ? 'max-h-[40dvh]' : 'max-h-[7.5rem]', className)}
ref={innerRef}
>
{children}
</div>
{overflowing && (
<button
aria-expanded={expanded}
aria-label={expanded ? 'Collapse' : 'Expand'}
className="absolute inset-x-0 bottom-0 flex h-7 cursor-pointer items-end justify-center bg-linear-to-t from-(--ui-chat-surface-background) to-transparent pb-1 text-muted-foreground/70 transition-colors hover:text-foreground"
onClick={() => setExpanded(v => !v)}
type="button"
>
<ChevronDown className={cn('size-3.5 transition-transform', expanded && 'rotate-180')} />
</button>
// 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.
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex h-7 justify-end bg-linear-to-t from-(--ui-chat-surface-background) to-transparent">
<button
aria-expanded={expanded}
aria-label={expanded ? 'Collapse' : 'Expand'}
className="pointer-events-auto flex h-7 w-9 cursor-pointer items-end justify-center pb-1 text-muted-foreground/70 transition-colors hover:text-foreground"
onClick={() => setExpanded(v => !v)}
type="button"
>
<ChevronDown className={cn('size-3.5 transition-transform', expanded && 'rotate-180')} />
</button>
</div>
)}
</div>
)