feat(native-chat): a solid, fading Jump to latest (#25759)

"Jump to latest" was a faint, see-through pill that popped in and out. It is
now a solid button that fades in and out, can't be clicked or tabbed to while
hidden, and keeps focus in the composer when clicked.

Co-authored-by: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com>
This commit is contained in:
Brennan Benson
2026-10-06 12:10:32 -07:00
committed by GitHub
co-authored by Kelvin Amoaba
parent ab6389045e
commit 975e732ce3
3 changed files with 77 additions and 13 deletions
@@ -0,0 +1,39 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, expect, it, vi } from 'vitest'
import { NativeChatJumpToLatest } from './NativeChatJumpToLatest'
afterEach(cleanup)
// The reader is usually mid-draft when they reach for this: a press must not take the caret.
it('leaves focus in the composer when pressed with the pointer', async () => {
const onJump = vi.fn()
render(
<>
<NativeChatJumpToLatest visible onJump={onJump} />
<textarea aria-label="Message" />
</>
)
const composer = screen.getByRole('textbox', { name: 'Message' })
composer.focus()
await userEvent.click(screen.getByRole('button', { name: 'Jump to latest' }))
// Anti-vacuous: the press did act.
expect(onJump).toHaveBeenCalledOnce()
expect(document.activeElement).toBe(composer)
})
// Mounted while hidden so it can fade out; it must not stay reachable meanwhile.
it('stays mounted but unreachable while hidden', () => {
const { container } = render(<NativeChatJumpToLatest visible={false} onJump={vi.fn()} />)
expect(screen.queryByRole('button', { name: 'Jump to latest' })).toBeNull()
const button = container.querySelector('button')
expect(button).toHaveAttribute('inert')
expect(button).toHaveAttribute('data-shown', 'false')
})
@@ -0,0 +1,36 @@
// The way back to the latest message once the reader has scrolled up.
//
// It stays mounted so it can fade rather than pop, and while hidden it is inert
// and hidden from assistive tech, so it can be neither clicked, tabbed to nor read.
import { ArrowDown } from 'lucide-react'
import { translate } from '@/i18n/i18n'
/** Keeps focus where the reader was typing: a pointer press never moves it here. */
function keepFocus(event: React.MouseEvent): void {
event.preventDefault()
}
export function NativeChatJumpToLatest({
visible,
onJump
}: {
visible: boolean
onJump: () => void
}): React.JSX.Element {
const label = translate('components.native-chat.jumpToLatest', 'Jump to latest')
return (
<button
type="button"
data-shown={visible}
inert={!visible}
aria-hidden={!visible || undefined}
onMouseDown={keepFocus}
onClick={onJump}
className="absolute bottom-3 left-1/2 flex h-8 -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-popover px-3 text-xs font-medium text-popover-foreground shadow-floating transition-[opacity,translate] duration-150 ease-out hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none data-[shown=false]:pointer-events-none data-[shown=false]:translate-y-1 data-[shown=false]:opacity-0"
>
<ArrowDown className="size-3.5" />
<span>{label}</span>
</button>
)
}
@@ -2,11 +2,10 @@ import {
NATIVE_CHAT_TRANSCRIPT_OUTER_CLASS,
NATIVE_CHAT_TRANSCRIPT_COLUMN_CLASS
} from './native-chat-appearance-style'
import { NativeChatJumpToLatest } from './NativeChatJumpToLatest'
import { useNativeChatRowTypography } from './use-native-chat-row-typography'
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { ArrowDown } from 'lucide-react'
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
import { translate } from '@/i18n/i18n'
import type { NativeChatLiveSession } from './use-native-chat-live-session'
import { useNativeChatTranscriptProjection } from './use-native-chat-transcript-projection'
import { structuredQuestionTranscript } from './structured-agent-question-projection'
@@ -404,17 +403,7 @@ export function NativeChatMessageList({
onReaderScroll={beginNavigation}
pendingId={railHistoryJump.pendingId}
/>
{showJump ? (
<button
type="button"
onClick={jumpToLatest}
aria-label={translate('components.native-chat.jumpToLatest', 'Jump to latest')}
className="absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-card/90 px-3 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowDown className="size-3.5" />
<span>{translate('components.native-chat.jumpToLatest', 'Jump to latest')}</span>
</button>
) : null}
<NativeChatJumpToLatest visible={showJump} onJump={jumpToLatest} />
</div>
{taskListState.list && taskListState.list.tasks.length > 0 ? (
<div className="shrink-0 px-3 pb-2 sm:px-4">