mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(native-chat): open the message rail panel on the current message (#21143)
* fix(native-chat): open the message rail panel on the current message
The rail's hover panel mounts fresh at scrollTop 0 every time it opens, so
in a long thread it showed the top of the conversation instead of where the
reader actually is. It already knew which row was current — activeId drives
the highlight — it just never scrolled to it.
Attach a ref to the current row that calls scrollIntoView({ block: 'nearest' }).
Radix unmounts popover content on close, so ref attachment is the open edge;
it also re-fires when a different row goes active under an open panel.
* fix(native-chat): keep current rail item focused
* fix(native-chat): resync rail after list changes
* fix(native-chat): own focus across retained rail opens
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NativeChatMessageRail } from './NativeChatMessageRail'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -12,6 +12,31 @@ const items = Array.from({ length: 3 }, (_, index) => ({
|
||||
slotIndex: index,
|
||||
hasImages: false
|
||||
}))
|
||||
const overflowItems = Array.from({ length: 20 }, (_, index) => ({
|
||||
id: `overflow-prompt-${index}`,
|
||||
text: `Overflow prompt ${index}`,
|
||||
slotIndex: index,
|
||||
hasImages: false
|
||||
}))
|
||||
|
||||
function retainClosingPopover(): ReturnType<typeof vi.spyOn> {
|
||||
const getStyle = window.getComputedStyle.bind(window)
|
||||
return vi.spyOn(window, 'getComputedStyle').mockImplementation((element, ...args) => {
|
||||
const style = getStyle(element, ...args)
|
||||
if (element.getAttribute('data-slot') !== 'popover-content') {
|
||||
return style
|
||||
}
|
||||
return new Proxy(style, {
|
||||
get: (target, property) =>
|
||||
property === 'animationName'
|
||||
? element.getAttribute('data-state') === 'closed'
|
||||
? 'exit'
|
||||
: 'enter'
|
||||
: // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy trap passes CSSStyleDeclaration properties through unchanged.
|
||||
Reflect.get(target, property)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('message rail interaction', () => {
|
||||
it('opens from the keyboard, reaches prompts, jumps, and restores focus', async () => {
|
||||
@@ -19,7 +44,12 @@ describe('message rail interaction', () => {
|
||||
const select = vi.fn()
|
||||
render(
|
||||
<NativeChatMessageRail
|
||||
rail={{ items, ticks: items, activeId: items[1].id, visible: true }}
|
||||
rail={{
|
||||
items: overflowItems,
|
||||
ticks: overflowItems,
|
||||
activeId: overflowItems[12].id,
|
||||
visible: true
|
||||
}}
|
||||
scrollRef={{ current: document.createElement('div') }}
|
||||
onSelect={select}
|
||||
/>
|
||||
@@ -29,12 +59,12 @@ describe('message rail interaction', () => {
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
await user.keyboard('{Enter}')
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Prompt 0' }))
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole('button', { name: 'Overflow prompt 12' })
|
||||
)
|
||||
)
|
||||
await user.tab()
|
||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Prompt 1' }))
|
||||
await user.keyboard('{Enter}')
|
||||
expect(select).toHaveBeenCalledWith(items[1])
|
||||
expect(select).toHaveBeenCalledWith(overflowItems[12])
|
||||
await waitFor(() => expect(document.activeElement).toBe(trigger))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
await user.keyboard('{Enter}')
|
||||
@@ -43,6 +73,117 @@ describe('message rail interaction', () => {
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('focuses the current prompt when a hover preview becomes interactive', async () => {
|
||||
render(
|
||||
<NativeChatMessageRail
|
||||
rail={{
|
||||
items: overflowItems,
|
||||
ticks: overflowItems,
|
||||
activeId: overflowItems[12].id,
|
||||
visible: true
|
||||
}}
|
||||
scrollRef={{ current: document.createElement('div') }}
|
||||
onSelect={vi.fn()}
|
||||
/>
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: 'Your messages' })
|
||||
fireEvent.pointerEnter(trigger, { pointerType: 'mouse' })
|
||||
await screen.findByRole('dialog')
|
||||
fireEvent.click(trigger)
|
||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Overflow prompt 12' }))
|
||||
})
|
||||
|
||||
it('focuses the first prompt on direct open when no prompt is current', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<NativeChatMessageRail
|
||||
rail={{ items: overflowItems, ticks: overflowItems, activeId: null, visible: true }}
|
||||
scrollRef={{ current: document.createElement('div') }}
|
||||
onSelect={vi.fn()}
|
||||
/>
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: 'Your messages' })
|
||||
trigger.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Overflow prompt 0' }))
|
||||
)
|
||||
})
|
||||
|
||||
it('refocuses the current prompt when closed content is reopened before unmount', async () => {
|
||||
const styleSpy = retainClosingPopover()
|
||||
const user = userEvent.setup()
|
||||
try {
|
||||
render(
|
||||
<NativeChatMessageRail
|
||||
rail={{
|
||||
items: overflowItems,
|
||||
ticks: overflowItems,
|
||||
activeId: overflowItems[12].id,
|
||||
visible: true
|
||||
}}
|
||||
scrollRef={{ current: document.createElement('div') }}
|
||||
onSelect={vi.fn()}
|
||||
/>
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: 'Your messages' })
|
||||
trigger.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
document.querySelector('[data-slot="popover-content"]')?.getAttribute('data-state')
|
||||
).toBe('closed')
|
||||
)
|
||||
|
||||
trigger.focus()
|
||||
fireEvent.click(trigger)
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole('button', { name: 'Overflow prompt 12' })
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
styleSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves interactive focus when the current prompt changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = render(
|
||||
<NativeChatMessageRail
|
||||
rail={{
|
||||
items: overflowItems,
|
||||
ticks: overflowItems,
|
||||
activeId: overflowItems[12].id,
|
||||
visible: true
|
||||
}}
|
||||
scrollRef={{ current: document.createElement('div') }}
|
||||
onSelect={vi.fn()}
|
||||
/>
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: 'Your messages' })
|
||||
trigger.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
const focusedPrompt = screen.getByRole('button', { name: 'Overflow prompt 12' })
|
||||
expect(document.activeElement).toBe(focusedPrompt)
|
||||
|
||||
rerender(
|
||||
<NativeChatMessageRail
|
||||
rail={{
|
||||
items: overflowItems,
|
||||
ticks: overflowItems,
|
||||
activeId: overflowItems[13].id,
|
||||
visible: true
|
||||
}}
|
||||
scrollRef={{ current: document.createElement('div') }}
|
||||
onSelect={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(document.activeElement).toBe(focusedPrompt)
|
||||
})
|
||||
|
||||
it('keeps focus in the transcript while a hover preview opens and closes', async () => {
|
||||
render(
|
||||
<>
|
||||
@@ -82,4 +223,112 @@ describe('message rail interaction', () => {
|
||||
fireEvent.wheel(screen.getByRole('button', { name: 'Your messages' }), { deltaY: 7, deltaMode })
|
||||
expect(element.scrollTop).toBe(expected)
|
||||
})
|
||||
|
||||
// happy-dom has no layout, so these pin which row the panel scrolls to, not
|
||||
// the resulting offset. The offset itself only exists in a real browser.
|
||||
describe('opening position', () => {
|
||||
const scrolled: Element[] = []
|
||||
let scrollIntoView: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
scrolled.length = 0
|
||||
scrollIntoView = vi
|
||||
.spyOn(Element.prototype, 'scrollIntoView')
|
||||
.mockImplementation(function mockScrollIntoView(this: Element) {
|
||||
scrolled.push(this)
|
||||
})
|
||||
})
|
||||
afterEach(() => scrollIntoView.mockRestore())
|
||||
|
||||
it('scrolls the panel to the message the reader is on', async () => {
|
||||
render(
|
||||
<NativeChatMessageRail
|
||||
rail={{ items, ticks: items, activeId: items[2].id, visible: true }}
|
||||
scrollRef={{ current: document.createElement('div') }}
|
||||
onSelect={vi.fn()}
|
||||
/>
|
||||
)
|
||||
fireEvent.pointerEnter(screen.getByRole('button', { name: 'Your messages' }), {
|
||||
pointerType: 'mouse'
|
||||
})
|
||||
await screen.findByRole('dialog')
|
||||
expect(scrolled).toEqual([screen.getByRole('button', { name: 'Prompt 2' })])
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
|
||||
})
|
||||
|
||||
it('rechecks the current row when messages are inserted before it', async () => {
|
||||
const { rerender } = render(
|
||||
<NativeChatMessageRail
|
||||
rail={{ items, ticks: items, activeId: items[2].id, visible: true }}
|
||||
scrollRef={{ current: document.createElement('div') }}
|
||||
onSelect={vi.fn()}
|
||||
/>
|
||||
)
|
||||
fireEvent.pointerEnter(screen.getByRole('button', { name: 'Your messages' }), {
|
||||
pointerType: 'mouse'
|
||||
})
|
||||
await screen.findByRole('dialog')
|
||||
scrolled.length = 0
|
||||
|
||||
const shiftedItems = [
|
||||
{ id: 'older-prompt', text: 'Older prompt', slotIndex: 0, hasImages: false },
|
||||
...items.map((item) => ({ ...item, slotIndex: item.slotIndex + 1 }))
|
||||
]
|
||||
rerender(
|
||||
<NativeChatMessageRail
|
||||
rail={{ items: shiftedItems, ticks: shiftedItems, activeId: items[2].id, visible: true }}
|
||||
scrollRef={{ current: document.createElement('div') }}
|
||||
onSelect={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(scrolled).toEqual([screen.getByRole('button', { name: 'Prompt 2' })])
|
||||
})
|
||||
|
||||
it('rechecks the current row when the same number of messages is reordered', async () => {
|
||||
const { rerender } = render(
|
||||
<NativeChatMessageRail
|
||||
rail={{ items, ticks: items, activeId: items[2].id, visible: true }}
|
||||
scrollRef={{ current: document.createElement('div') }}
|
||||
onSelect={vi.fn()}
|
||||
/>
|
||||
)
|
||||
fireEvent.pointerEnter(screen.getByRole('button', { name: 'Your messages' }), {
|
||||
pointerType: 'mouse'
|
||||
})
|
||||
await screen.findByRole('dialog')
|
||||
scrolled.length = 0
|
||||
|
||||
const reorderedItems = [items[2], items[0], items[1]]
|
||||
rerender(
|
||||
<NativeChatMessageRail
|
||||
rail={{
|
||||
items: reorderedItems,
|
||||
ticks: reorderedItems,
|
||||
activeId: items[2].id,
|
||||
visible: true
|
||||
}}
|
||||
scrollRef={{ current: document.createElement('div') }}
|
||||
onSelect={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(scrolled).toEqual([screen.getByRole('button', { name: 'Prompt 2' })])
|
||||
})
|
||||
|
||||
it('leaves the panel alone when no message is lit', async () => {
|
||||
render(
|
||||
<NativeChatMessageRail
|
||||
rail={{ items, ticks: items, activeId: null, visible: true }}
|
||||
scrollRef={{ current: document.createElement('div') }}
|
||||
onSelect={vi.fn()}
|
||||
/>
|
||||
)
|
||||
fireEvent.pointerEnter(screen.getByRole('button', { name: 'Your messages' }), {
|
||||
pointerType: 'mouse'
|
||||
})
|
||||
await screen.findByRole('dialog')
|
||||
expect(scrolled).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// The rail itself: a column of ticks down the right edge of the transcript, one
|
||||
// per user message, with a hover panel that previews them and jumps on click.
|
||||
|
||||
import { memo, useEffect, useRef, useState } from 'react'
|
||||
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -22,6 +22,65 @@ function railItemLabel(item: NativeChatRailItem): string {
|
||||
: translate('components.native-chat.railEmptyMessage', 'Message')
|
||||
}
|
||||
|
||||
type NativeChatMessageRailMode = 'hover' | 'interactive' | null
|
||||
|
||||
function NativeChatMessageRailItems({
|
||||
mode,
|
||||
items,
|
||||
activeId,
|
||||
onSelect
|
||||
}: {
|
||||
mode: NativeChatMessageRailMode
|
||||
items: readonly NativeChatRailItem[]
|
||||
activeId: string | null
|
||||
onSelect: (item: NativeChatRailItem) => void
|
||||
}): React.JSX.Element {
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
const currentItemRef = useRef<HTMLButtonElement>(null)
|
||||
const previousMode = useRef<NativeChatMessageRailMode>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const currentItem = activeId === null || items.length === 0 ? null : currentItemRef.current
|
||||
if (mode !== null) {
|
||||
currentItem?.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
if (mode === 'interactive' && previousMode.current !== 'interactive') {
|
||||
const focusTarget = currentItem ?? listRef.current?.querySelector<HTMLButtonElement>('button')
|
||||
focusTarget?.focus({ preventScroll: true })
|
||||
}
|
||||
previousMode.current = mode
|
||||
}, [activeId, items, mode])
|
||||
|
||||
return (
|
||||
<ul ref={listRef} className="scrollbar-sleek max-h-64 overflow-y-auto overflow-x-hidden">
|
||||
{items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
ref={item.id === activeId ? currentItemRef : undefined}
|
||||
onClick={() => onSelect(item)}
|
||||
aria-current={item.id === activeId ? 'true' : undefined}
|
||||
data-current={item.id === activeId}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer rounded-md px-2 py-1.5 text-left transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
item.id === activeId && 'bg-accent'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'line-clamp-2 text-xs leading-snug',
|
||||
item.id === activeId ? 'text-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{railItemLabel(item)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export const NativeChatMessageRail = memo(function NativeChatMessageRail({
|
||||
rail,
|
||||
scrollRef,
|
||||
@@ -32,10 +91,10 @@ export const NativeChatMessageRail = memo(function NativeChatMessageRail({
|
||||
onSelect: (item: NativeChatRailItem) => void
|
||||
}): React.JSX.Element | null {
|
||||
// Hover preserves focus; activation enters the focus-managed prompt picker.
|
||||
const [mode, setMode] = useState<'hover' | 'interactive' | null>(null)
|
||||
const [mode, setMode] = useState<NativeChatMessageRailMode>(null)
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const restoreFocus = useRef(false)
|
||||
const open = mode !== null
|
||||
const cancelClose = (): void => {
|
||||
if (closeTimer.current !== null) {
|
||||
clearTimeout(closeTimer.current)
|
||||
@@ -56,14 +115,13 @@ export const NativeChatMessageRail = memo(function NativeChatMessageRail({
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
if (!rail.visible) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={mode !== null}
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
cancelClose()
|
||||
if (open) {
|
||||
@@ -94,7 +152,6 @@ export const NativeChatMessageRail = memo(function NativeChatMessageRail({
|
||||
event.preventDefault()
|
||||
restoreFocus.current = true
|
||||
setMode('interactive')
|
||||
contentRef.current?.querySelector('button')?.focus()
|
||||
}
|
||||
}}
|
||||
// The rail overlays the transcript without being inside it, so a wheel
|
||||
@@ -130,7 +187,6 @@ export const NativeChatMessageRail = memo(function NativeChatMessageRail({
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
ref={contentRef}
|
||||
side="left"
|
||||
align="center"
|
||||
aria-label={translate('components.native-chat.railLabel', 'Your messages')}
|
||||
@@ -142,45 +198,22 @@ export const NativeChatMessageRail = memo(function NativeChatMessageRail({
|
||||
restoreFocus.current = true
|
||||
setMode('interactive')
|
||||
}}
|
||||
onOpenAutoFocus={(event) => {
|
||||
if (mode === 'hover') {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!restoreFocus.current) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ul className="scrollbar-sleek max-h-64 overflow-y-auto overflow-x-hidden">
|
||||
{rail.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelect(item)
|
||||
setMode(null)
|
||||
}}
|
||||
aria-current={item.id === rail.activeId ? 'true' : undefined}
|
||||
data-current={item.id === rail.activeId}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer rounded-md px-2 py-1.5 text-left transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
item.id === rail.activeId && 'bg-accent'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'line-clamp-2 text-xs leading-snug',
|
||||
item.id === rail.activeId ? 'text-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{railItemLabel(item)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<NativeChatMessageRailItems
|
||||
mode={mode}
|
||||
items={rail.items}
|
||||
activeId={rail.activeId}
|
||||
onSelect={(item) => {
|
||||
onSelect(item)
|
||||
setMode(null)
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user