feat(desktop): follow streaming output at bottom + jump-to-bottom button (#45263)
Strict sticky-bottom autoscroll for the chat thread: while the viewport is parked at the bottom, the tail follows content growth (streaming tokens, late measurement, Shiki re-highlight) via a useLayoutEffect keyed on the virtualizer's own size signal, pinned in the same pre-paint pass as its scrollToFn so the two never rubber-band. The gate is a single boolean — one upward pixel (scroll/wheel/touch) disarms follow until the user returns to the bottom. Adds a floating jump-to-bottom control that appears once scrolled ~10px away (above the dim threshold so a sub-pixel settle never flashes it), positioned above the composer with respect to the status stack, with a subtle scale + slide in/out animation that honours prefers-reduced-motion. The button bridges to the virtualizer's re-arm + pin path through a small nanostore emitter. Supersedes #43624.
This commit is contained in:
parent
135fe90166
commit
bbf020e709
8 changed files with 198 additions and 30 deletions
|
|
@ -1,11 +1,35 @@
|
|||
import { atom } from 'nanostores'
|
||||
import { atom, type WritableAtom } from 'nanostores'
|
||||
|
||||
// `$threadScrolledUp` flips the instant the viewport leaves the bottom (dims the
|
||||
// composer / status stack). `$threadJumpButtonVisible` trips a little further up
|
||||
// (~10px) so the floating jump control only shows once meaningfully away.
|
||||
export const $threadScrolledUp = atom(false)
|
||||
export const $threadJumpButtonVisible = atom(false)
|
||||
|
||||
export function setThreadScrolledUp(value: boolean) {
|
||||
if ($threadScrolledUp.get() === value) {
|
||||
return
|
||||
// Skip no-op writes so subscribers don't churn on every scroll tick.
|
||||
const setter = (target: WritableAtom<boolean>) => (value: boolean) => {
|
||||
if (target.get() !== value) {
|
||||
target.set(value)
|
||||
}
|
||||
|
||||
$threadScrolledUp.set(value)
|
||||
}
|
||||
|
||||
export const setThreadScrolledUp = setter($threadScrolledUp)
|
||||
export const setThreadJumpButtonVisible = setter($threadJumpButtonVisible)
|
||||
|
||||
export const resetThreadScroll = () => {
|
||||
setThreadScrolledUp(false)
|
||||
setThreadJumpButtonVisible(false)
|
||||
}
|
||||
|
||||
// Cross-component bridge: the jump button lives by the composer, the re-arm +
|
||||
// pin machinery lives in the virtualizer. The virtualizer registers a handler;
|
||||
// the button fires it. Mirrors the composer focus/insert emitter pattern.
|
||||
const handlers = new Set<() => void>()
|
||||
|
||||
export const onScrollToBottomRequest = (handler: () => void) => {
|
||||
handlers.add(handler)
|
||||
|
||||
return () => void handlers.delete(handler)
|
||||
}
|
||||
|
||||
export const requestScrollToBottom = () => handlers.forEach(handler => handler())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue