mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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:
parent
390b03c455
commit
936b407a72
2 changed files with 93 additions and 10 deletions
72
apps/desktop/src/components/chat/expandable-block.test.tsx
Normal file
72
apps/desktop/src/components/chat/expandable-block.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue