mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 10:37:35 +00:00
fix(native-chat): message rail lights a message sent while the agent was working (#27821)
* fix(native-chat): light the tick of a message sent mid-turn on the message rail A message sent while the agent is working joins the running turn's key, and the rail lit ticks by turn key, so that message's tick never lit: not at the bottom, not after clicking it. Light the newest tick at or above the read row within its turn instead. * fix(native-chat): light the nearest tick above the read row, and keep a clicked tick lit Review round 1. The within-turn walk stopped at a row of no turn landing in the running turn, and could return a tick from the turn above. The lit tick is now the newest loaded rail item at or above the row at the fold, falling back to its turn key above the first loaded one (that prompt is in the outline). A jump that cannot bring its row to the top lands clamped at the bottom, and the settled position report then lit the last tick instead of the clicked one. A clicked tick now stays lit until the reader scrolls or starts another navigation (the existing reader-input path), or the set of prompts changes. Rail picks and the start of every navigation move into their own hook, as the list is at its line limit. * fix(native-chat): hold a clicked rail tick by position, not by reader input Review round 2. Releasing the clicked tick on reader input needed every scroll source to report itself, and several do not (scroll-to-top button, inline diff reveal, prompt refold, selection-drag autoscroll); releasing it whenever the prompts changed dropped it on every head trim of a long live session. The hold now records where the jump settled, at the first idle read after the click, and keeps the clicked tick lit while the view rests there. Any movement, whatever caused it, releases it, as does the clicked message leaving the rail. Drops the navigation wiring and the hook it was moved into. * fix(native-chat): anchor a clicked rail tick's hold to its row on screen Review round 3. Holding while scrollTop stayed put recorded the reader's spot when they kept scrolling within the idle period of the jump, kept an off-screen row lit after the viewport shrank, and let go when a row above re-measured late and shifted scrollTop with the content. The hold now records how far below the viewport top the clicked row settled, and keeps the tick lit while the row is still on screen at that offset.
This commit is contained in:
@@ -27,9 +27,19 @@ const TURNS: NativeChatRailSlot[] = [
|
||||
{ turnKey: 'u2' }
|
||||
]
|
||||
|
||||
/** The rail items `TURNS` loads: each turn's opening prompt. */
|
||||
const TICKS = [
|
||||
{ id: 'u1', slotIndex: 0 },
|
||||
{ id: 'u2', slotIndex: 4 }
|
||||
]
|
||||
|
||||
const VIEWPORT = 300
|
||||
/** Ten 100px rows against a 300px viewport, scrolled off the bottom. */
|
||||
const MID_SCROLL = { clientHeight: VIEWPORT, scrollHeight: 1000, previousActiveId: null }
|
||||
const MID_SCROLL = {
|
||||
clientHeight: VIEWPORT,
|
||||
scrollHeight: 1000,
|
||||
previousActiveId: null
|
||||
}
|
||||
|
||||
describe('active rail item', () => {
|
||||
// The whole point of resolving through `turnKey`: most of a transcript is reply,
|
||||
@@ -38,6 +48,7 @@ describe('active rail item', () => {
|
||||
expect(
|
||||
findActiveNativeChatRailItem({
|
||||
slots: TURNS,
|
||||
ticks: TICKS,
|
||||
virtualItems: rows(10),
|
||||
scrollTop: 250,
|
||||
...MID_SCROLL
|
||||
@@ -49,6 +60,7 @@ describe('active rail item', () => {
|
||||
expect(
|
||||
findActiveNativeChatRailItem({
|
||||
slots: TURNS,
|
||||
ticks: TICKS,
|
||||
virtualItems: rows(10),
|
||||
scrollTop: 450,
|
||||
...MID_SCROLL
|
||||
@@ -60,6 +72,7 @@ describe('active rail item', () => {
|
||||
expect(
|
||||
findActiveNativeChatRailItem({
|
||||
slots: TURNS,
|
||||
ticks: TICKS,
|
||||
virtualItems: rows(10),
|
||||
scrollTop: 400,
|
||||
...MID_SCROLL
|
||||
@@ -73,6 +86,7 @@ describe('active rail item', () => {
|
||||
expect(
|
||||
findActiveNativeChatRailItem({
|
||||
slots: TURNS,
|
||||
ticks: TICKS,
|
||||
virtualItems: rows(10),
|
||||
scrollTop: 700,
|
||||
clientHeight: VIEWPORT,
|
||||
@@ -88,6 +102,10 @@ describe('active rail item', () => {
|
||||
expect(
|
||||
findActiveNativeChatRailItem({
|
||||
slots: [{ turnKey: 'u1' }, { turnKey: 'u1' }, { turnKey: 'u2' }, { turnKey: 'u2' }],
|
||||
ticks: [
|
||||
{ id: 'u1', slotIndex: 0 },
|
||||
{ id: 'u2', slotIndex: 2 }
|
||||
],
|
||||
virtualItems: rows(4),
|
||||
// 30px short of the end, with a `u1` row at the top edge.
|
||||
scrollTop: 100,
|
||||
@@ -102,6 +120,10 @@ describe('active rail item', () => {
|
||||
expect(
|
||||
findActiveNativeChatRailItem({
|
||||
slots: [{ turnKey: undefined }, { turnKey: undefined }, ...TURNS],
|
||||
ticks: [
|
||||
{ id: 'u1', slotIndex: 2 },
|
||||
{ id: 'u2', slotIndex: 6 }
|
||||
],
|
||||
virtualItems: rows(12),
|
||||
scrollTop: 50,
|
||||
clientHeight: VIEWPORT,
|
||||
@@ -117,6 +139,7 @@ describe('active rail item', () => {
|
||||
expect(
|
||||
findActiveNativeChatRailItem({
|
||||
slots: TURNS,
|
||||
ticks: TICKS,
|
||||
virtualItems: [{ index: 0, start: 0, end: 100 }],
|
||||
scrollTop: 600,
|
||||
clientHeight: VIEWPORT,
|
||||
@@ -130,6 +153,7 @@ describe('active rail item', () => {
|
||||
expect(
|
||||
findActiveNativeChatRailItem({
|
||||
slots: TURNS,
|
||||
ticks: TICKS,
|
||||
virtualItems: [],
|
||||
scrollTop: 0,
|
||||
clientHeight: VIEWPORT,
|
||||
@@ -140,14 +164,13 @@ describe('active rail item', () => {
|
||||
})
|
||||
|
||||
// A rejected send the journal recorded keeps its place but opens no turn.
|
||||
it('lights a prompt in no turn by its own id', () => {
|
||||
const slots: NativeChatRailSlot[] = [
|
||||
...TURNS,
|
||||
{ turnKey: undefined, message: { id: 'rejected', role: 'user' } }
|
||||
]
|
||||
it('lights a prompt in no turn by its own tick', () => {
|
||||
const slots: NativeChatRailSlot[] = [...TURNS, { turnKey: undefined }]
|
||||
const ticks = [...TICKS, { id: 'rejected', slotIndex: 10 }]
|
||||
expect(
|
||||
findActiveNativeChatRailItem({
|
||||
slots,
|
||||
ticks,
|
||||
virtualItems: rows(11),
|
||||
scrollTop: 800,
|
||||
clientHeight: VIEWPORT,
|
||||
@@ -158,6 +181,7 @@ describe('active rail item', () => {
|
||||
expect(
|
||||
findActiveNativeChatRailItem({
|
||||
slots,
|
||||
ticks,
|
||||
virtualItems: rows(11),
|
||||
scrollTop: 1010,
|
||||
...MID_SCROLL,
|
||||
@@ -165,4 +189,75 @@ describe('active rail item', () => {
|
||||
})
|
||||
).toBe('rejected')
|
||||
})
|
||||
|
||||
// A message sent mid-turn takes the running turn's key but has its own tick.
|
||||
describe('a message sent into a running turn', () => {
|
||||
const slots: NativeChatRailSlot[] = [
|
||||
{ turnKey: 'u1' },
|
||||
{ turnKey: 'u1' },
|
||||
{ turnKey: 'u1' },
|
||||
{ turnKey: 'u1' },
|
||||
{ turnKey: 'u1' }
|
||||
]
|
||||
const ticks = [
|
||||
{ id: 'u1', slotIndex: 0 },
|
||||
{ id: 'steer', slotIndex: 2 }
|
||||
]
|
||||
|
||||
it('lights it when pinned to the bottom', () => {
|
||||
expect(
|
||||
findActiveNativeChatRailItem({
|
||||
slots,
|
||||
ticks,
|
||||
virtualItems: rows(5),
|
||||
scrollTop: 200,
|
||||
clientHeight: VIEWPORT,
|
||||
scrollHeight: 500,
|
||||
previousActiveId: null
|
||||
})
|
||||
).toBe('steer')
|
||||
})
|
||||
|
||||
it('lights the opener above it and itself from its row on', () => {
|
||||
const scroll = { slots, ticks, virtualItems: rows(5), ...MID_SCROLL }
|
||||
expect(findActiveNativeChatRailItem({ ...scroll, scrollTop: 150 })).toBe('u1')
|
||||
expect(findActiveNativeChatRailItem({ ...scroll, scrollTop: 250 })).toBe('steer')
|
||||
})
|
||||
|
||||
// A row outside every turn (thread-scoped) can land inside the running one.
|
||||
it('stays lit past a row of no turn below it', () => {
|
||||
expect(
|
||||
findActiveNativeChatRailItem({
|
||||
slots: [...slots.slice(0, 3), { turnKey: undefined }, { turnKey: 'u1' }],
|
||||
ticks,
|
||||
virtualItems: rows(5),
|
||||
scrollTop: 450,
|
||||
...MID_SCROLL
|
||||
})
|
||||
).toBe('steer')
|
||||
})
|
||||
|
||||
// The opener is older than the loaded window, known only from the outline.
|
||||
it('lights the outline prompt above the first loaded tick, and the tick below it', () => {
|
||||
const window = { slots, ticks: [{ id: 'steer', slotIndex: 2 }], virtualItems: rows(5) }
|
||||
expect(findActiveNativeChatRailItem({ ...window, scrollTop: 150, ...MID_SCROLL })).toBe('u1')
|
||||
expect(findActiveNativeChatRailItem({ ...window, scrollTop: 250, ...MID_SCROLL })).toBe(
|
||||
'steer'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// No prompt of the reader's opened it, so the nearest one above stands for it.
|
||||
it('lights the prompt above a turn the agent opened on its own', () => {
|
||||
expect(
|
||||
findActiveNativeChatRailItem({
|
||||
slots: [...TURNS, { turnKey: 'agent-turn' }, { turnKey: 'agent-turn' }],
|
||||
ticks: TICKS,
|
||||
virtualItems: rows(12),
|
||||
scrollTop: 1050,
|
||||
...MID_SCROLL,
|
||||
scrollHeight: 2000
|
||||
})
|
||||
).toBe('u2')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,16 +5,20 @@
|
||||
// measure. Virtual items carry the same answer without that hole.
|
||||
//
|
||||
// The row at the fold is usually the agent's, not the reader's — most of a long
|
||||
// transcript is reply. Resolving it through `turnKey`, which every row carries and
|
||||
// which holds the id of the user message that opened its turn, is what keeps the
|
||||
// reader's own prompt lit while they read the answer to it. Asking instead which
|
||||
// *user* row is on screen goes dark for the whole length of a long reply.
|
||||
// transcript is reply. Lighting the newest tick at or above it is what keeps the
|
||||
// reader's own prompt lit while they read the answer to it, a message sent mid-turn
|
||||
// included, though it shares the running turn's `turnKey`. Asking instead which
|
||||
// *user* row is on screen goes dark for the whole length of a long reply. Above the
|
||||
// first loaded tick, the row's `turnKey` names its prompt in the outline.
|
||||
//
|
||||
// Every offset below is in the scroll container's own pixels (rows are placed at
|
||||
// `item.start - scrollMargin` inside a sizer sitting `scrollMargin` down), which is
|
||||
// the same coordinate space as `scrollTop`.
|
||||
|
||||
import { NATIVE_CHAT_FOLLOW_REARM_PX } from './native-chat-autoscroll'
|
||||
import type { NativeChatRailItem } from './native-chat-message-rail-items'
|
||||
|
||||
type NativeChatRailTick = Pick<NativeChatRailItem, 'id' | 'slotIndex'>
|
||||
|
||||
/** The virtualizer's item, restated so this module needs nothing from the lib. */
|
||||
export type NativeChatRailVirtualItem = {
|
||||
@@ -26,16 +30,23 @@ export type NativeChatRailVirtualItem = {
|
||||
/** Only the fields the rail reads, so a test needs no slot builder. */
|
||||
export type NativeChatRailSlot = {
|
||||
turnKey: string | undefined
|
||||
/** A user row in no turn (one shown as not sent) lights its own tick. */
|
||||
message?: { id: string; role: string }
|
||||
}
|
||||
|
||||
function railTickOf(slot: NativeChatRailSlot | undefined): string | null {
|
||||
return slot?.turnKey ?? (slot?.message?.role === 'user' ? slot.message.id : null)
|
||||
function railTickOf(
|
||||
slots: readonly NativeChatRailSlot[],
|
||||
ticks: readonly NativeChatRailTick[],
|
||||
index: number
|
||||
): string | null {
|
||||
return (
|
||||
ticks.findLast((tick) => tick.slotIndex !== null && tick.slotIndex <= index)?.id ??
|
||||
slots[index]?.turnKey ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
export function findActiveNativeChatRailItem({
|
||||
slots,
|
||||
ticks,
|
||||
virtualItems,
|
||||
scrollTop,
|
||||
clientHeight,
|
||||
@@ -43,6 +54,8 @@ export function findActiveNativeChatRailItem({
|
||||
previousActiveId
|
||||
}: {
|
||||
slots: readonly NativeChatRailSlot[]
|
||||
/** The loaded rail items, in slot order. */
|
||||
ticks: readonly NativeChatRailTick[]
|
||||
virtualItems: readonly NativeChatRailVirtualItem[]
|
||||
scrollTop: number
|
||||
clientHeight: number
|
||||
@@ -60,7 +73,7 @@ export function findActiveNativeChatRailItem({
|
||||
const atBottom = scrollHeight - clientHeight - scrollTop <= NATIVE_CHAT_FOLLOW_REARM_PX
|
||||
if (atBottom) {
|
||||
const last = virtualItems.at(-1)
|
||||
return last === undefined ? previousActiveId : railTickOf(slots[last.index])
|
||||
return last === undefined ? previousActiveId : railTickOf(slots, ticks, last.index)
|
||||
}
|
||||
|
||||
let fold: NativeChatRailVirtualItem | undefined
|
||||
@@ -72,12 +85,12 @@ export function findActiveNativeChatRailItem({
|
||||
// Scrolled above everything the window holds: the first windowed row is the
|
||||
// nearest thing to the fold.
|
||||
if (fold === undefined) {
|
||||
return railTickOf(slots[virtualItems[0]?.index ?? -1])
|
||||
return railTickOf(slots, ticks, virtualItems[0]?.index ?? -1)
|
||||
}
|
||||
// The window lags the scroll by a commit, so a fold past every row it holds is
|
||||
// a stale read, not an answer. Holding the previous tick beats blanking one.
|
||||
if (fold.end <= scrollTop) {
|
||||
return previousActiveId
|
||||
}
|
||||
return railTickOf(slots[fold.index])
|
||||
return railTickOf(slots, ticks, fold.index)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import * as rowContent from '../../../../shared/native-chat-row-content'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import type { NativeChatResolvedPrompt } from './native-chat-resolution-receipt'
|
||||
import type { NativeChatTurnDiff } from './native-chat-turn-diffs'
|
||||
@@ -53,6 +53,10 @@ const CONVERSATION = [
|
||||
]
|
||||
|
||||
describe('message rail hook', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('reuses previews and rail state during long-history streamed renders', () => {
|
||||
const conversation = Array.from({ length: 2000 }, (_, index) =>
|
||||
message(`history-${index}`, index % 2 === 0 ? 'user' : 'assistant')
|
||||
@@ -150,6 +154,99 @@ describe('message rail hook', () => {
|
||||
expect(scrollSubscriptions).toHaveLength(1)
|
||||
})
|
||||
|
||||
// A jump that cannot bring its row to the top lands clamped at the bottom, where
|
||||
// the position report would light the last tick instead of the clicked one.
|
||||
describe('a clicked tick', () => {
|
||||
/** Five 100px rows; `u2` (row 2) clamped 50px below the top at max scroll. */
|
||||
function clampedJump() {
|
||||
vi.useFakeTimers()
|
||||
const view = { scrollTop: 150, clientHeight: 300, scrollHeight: 450, shift: 0 }
|
||||
const element = document.createElement('div')
|
||||
Object.defineProperties(element, {
|
||||
scrollTop: { get: () => view.scrollTop },
|
||||
clientHeight: { get: () => view.clientHeight },
|
||||
scrollHeight: { get: () => view.scrollHeight }
|
||||
})
|
||||
const scrollRef = { current: element }
|
||||
const hook = renderHook(
|
||||
({ slots }) =>
|
||||
useNativeChatMessageRail({
|
||||
scrollRef,
|
||||
slots,
|
||||
turnRows: NO_ROWS,
|
||||
virtualItems: slots.map((_slot, index) => ({
|
||||
key: index,
|
||||
index,
|
||||
start: view.shift + index * 100,
|
||||
end: view.shift + index * 100 + 100,
|
||||
size: 100,
|
||||
lane: 0
|
||||
}))
|
||||
}),
|
||||
{ initialProps: { slots: slotsOf(CONVERSATION) } }
|
||||
)
|
||||
const settle = (): void =>
|
||||
act(() => {
|
||||
element.dispatchEvent(new Event('scroll'))
|
||||
vi.runAllTimers()
|
||||
})
|
||||
settle()
|
||||
expect(hook.result.current.activeId).toBe('u3')
|
||||
act(() => hook.result.current.onActivate('u2'))
|
||||
return { ...hook, view, settle }
|
||||
}
|
||||
|
||||
it('stays lit while its row stays where the jump settled it', () => {
|
||||
const { result, rerender, view, settle, unmount } = clampedJump()
|
||||
settle()
|
||||
settle()
|
||||
expect(result.current.activeId).toBe('u2')
|
||||
// A row above measuring late moves the content and the offset together.
|
||||
view.shift = 30
|
||||
view.scrollTop = 180
|
||||
rerender({ slots: slotsOf(CONVERSATION) })
|
||||
settle()
|
||||
expect(result.current.activeId).toBe('u2')
|
||||
// A long session trims its oldest rows as new ones arrive.
|
||||
view.shift = 0
|
||||
view.scrollTop = 50
|
||||
rerender({ slots: slotsOf(CONVERSATION.slice(1)) })
|
||||
expect(result.current.items.map((item) => item.id)).toEqual(['u2', 'u3'])
|
||||
settle()
|
||||
expect(result.current.activeId).toBe('u2')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('is let go once its row moves, whatever moved it', () => {
|
||||
const { result, view, settle, unmount } = clampedJump()
|
||||
// No scroll: a jump that did not move the view still settles.
|
||||
act(() => vi.runAllTimers())
|
||||
view.scrollTop = 100
|
||||
settle()
|
||||
expect(result.current.activeId).toBe('u1')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('is let go once its row leaves a shrinking viewport', () => {
|
||||
const { result, view, settle, unmount } = clampedJump()
|
||||
settle()
|
||||
view.clientHeight = 40
|
||||
settle()
|
||||
expect(result.current.activeId).toBe('u1')
|
||||
unmount()
|
||||
})
|
||||
|
||||
// The reader kept scrolling past the jump before it settled: the row is off screen.
|
||||
it('is not held when the reader carried its row off screen before it settled', () => {
|
||||
const { result, view, settle, unmount } = clampedJump()
|
||||
view.scrollTop = 0
|
||||
view.clientHeight = 150
|
||||
settle()
|
||||
expect(result.current.activeId).toBe('u1')
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('ticks every user message and shows from the first one', () => {
|
||||
const element = document.createElement('div')
|
||||
const scrollRef = { current: element }
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { findActiveNativeChatRailItem } from './native-chat-active-rail-item'
|
||||
import { ALIGN_ARRIVED_PX } from './use-native-chat-viewport-align'
|
||||
import {
|
||||
buildNativeChatRailItems,
|
||||
mergeNativeChatRailOutline,
|
||||
@@ -85,15 +86,34 @@ export function useNativeChatMessageRail({
|
||||
virtualItemsRef.current = virtualItems
|
||||
const slotsRef = useRef(slots)
|
||||
slotsRef.current = slots
|
||||
// A clicked tick stays lit while its row stays on screen where the jump settled it:
|
||||
// a jump clamped short of the top leaves another row at the fold, or the last at the
|
||||
// bottom. `offset` is the row's distance below the viewport top, once settled.
|
||||
const clickedRef = useRef<{ id: string; offset: number | null } | null>(null)
|
||||
|
||||
const readActiveId = useCallback(() => {
|
||||
const element = scrollRef.current
|
||||
if (!element) {
|
||||
return
|
||||
}
|
||||
const clicked = clickedRef.current
|
||||
if (clicked !== null) {
|
||||
const top = element.scrollTop
|
||||
const slotIndex = previousItemsRef.current.find((item) => item.id === clicked.id)?.slotIndex
|
||||
const row = virtualItemsRef.current.find((item) => item.index === slotIndex)
|
||||
if (row && row.end > top && row.start < top + element.clientHeight) {
|
||||
clicked.offset ??= row.start - top
|
||||
if (Math.abs(row.start - top - clicked.offset) <= ALIGN_ARRIVED_PX) {
|
||||
return
|
||||
}
|
||||
}
|
||||
clickedRef.current = null
|
||||
}
|
||||
setActiveId((previous) =>
|
||||
findActiveNativeChatRailItem({
|
||||
slots: slotsRef.current,
|
||||
// The committed items: a read only ever follows a commit.
|
||||
ticks: previousItemsRef.current,
|
||||
virtualItems: virtualItemsRef.current,
|
||||
scrollTop: element.scrollTop,
|
||||
clientHeight: element.clientHeight,
|
||||
@@ -103,38 +123,42 @@ export function useNativeChatMessageRail({
|
||||
)
|
||||
}, [scrollRef])
|
||||
|
||||
const idleTimerRef = useRef<number | null>(null)
|
||||
const scheduleRead = useCallback(() => {
|
||||
if (idleTimerRef.current !== null) {
|
||||
window.clearTimeout(idleTimerRef.current)
|
||||
}
|
||||
idleTimerRef.current = window.setTimeout(() => {
|
||||
idleTimerRef.current = null
|
||||
readActiveId()
|
||||
}, NATIVE_CHAT_RAIL_IDLE_MS)
|
||||
}, [readActiveId])
|
||||
|
||||
useEffect(() => {
|
||||
const element = scrollRef.current
|
||||
if (!element) {
|
||||
return
|
||||
}
|
||||
let idleTimer: number | null = null
|
||||
const scheduleRead = (): void => {
|
||||
if (idleTimer !== null) {
|
||||
window.clearTimeout(idleTimer)
|
||||
}
|
||||
idleTimer = window.setTimeout(() => {
|
||||
idleTimer = null
|
||||
readActiveId()
|
||||
}, NATIVE_CHAT_RAIL_IDLE_MS)
|
||||
}
|
||||
scheduleRead()
|
||||
element.addEventListener('scroll', scheduleRead, { passive: true })
|
||||
return () => {
|
||||
element.removeEventListener('scroll', scheduleRead)
|
||||
if (idleTimer !== null) {
|
||||
window.clearTimeout(idleTimer)
|
||||
if (idleTimerRef.current !== null) {
|
||||
window.clearTimeout(idleTimerRef.current)
|
||||
idleTimerRef.current = null
|
||||
}
|
||||
}
|
||||
// Subscribed once. Depending on anything that changes per render would tear
|
||||
// the listener down and cancel the pending idle timer on every frame of a
|
||||
// streaming turn, so the highlight would never settle.
|
||||
}, [readActiveId, scrollRef])
|
||||
}, [scheduleRead, scrollRef])
|
||||
|
||||
// Re-read when the set of prompts actually changes, so a transcript that grew
|
||||
// updates without waiting for the next scroll.
|
||||
// updates without waiting for the next scroll. A click still landing is read once it settles.
|
||||
useEffect(() => {
|
||||
readActiveId()
|
||||
if (clickedRef.current?.offset !== null) {
|
||||
readActiveId()
|
||||
}
|
||||
}, [items, readActiveId])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -156,6 +180,15 @@ export function useNativeChatMessageRail({
|
||||
() => selectNativeChatRailTicks({ items, keepIds: [activeId, focusId, previewId], maxTicks }),
|
||||
[items, activeId, focusId, previewId, maxTicks]
|
||||
)
|
||||
// A jump that does not move the view fires no scroll, so its landing is read from here.
|
||||
const onActivate = useCallback(
|
||||
(id: string) => {
|
||||
clickedRef.current = { id, offset: null }
|
||||
setActiveId(id)
|
||||
scheduleRead()
|
||||
},
|
||||
[scheduleRead]
|
||||
)
|
||||
const previewReply =
|
||||
previewId === null ? '' : nativeChatRailReplyPreview(turnRows, items, previewId)
|
||||
|
||||
@@ -164,7 +197,7 @@ export function useNativeChatMessageRail({
|
||||
ticks,
|
||||
items,
|
||||
activeId,
|
||||
onActivate: setActiveId,
|
||||
onActivate,
|
||||
visible: wideEnough && items.length > 0,
|
||||
previewId,
|
||||
previewReply,
|
||||
@@ -172,6 +205,6 @@ export function useNativeChatMessageRail({
|
||||
focusId,
|
||||
onFocusItem: setFocusId
|
||||
}),
|
||||
[ticks, items, activeId, wideEnough, previewId, previewReply, focusId]
|
||||
[ticks, items, activeId, onActivate, wideEnough, previewId, previewReply, focusId]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Virtualizer } from '@tanstack/react-virtual'
|
||||
import type { ProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks'
|
||||
|
||||
/** How close to its target a jump counts as arrived: sub-pixel and zoom rounding. */
|
||||
const ALIGN_ARRIVED_PX = 2
|
||||
export const ALIGN_ARRIVED_PX = 2
|
||||
|
||||
/** Distance from a container's scroll origin down to a descendant, in the
|
||||
* container's own scroll pixels, or null when there is no chain to walk.
|
||||
|
||||
Reference in New Issue
Block a user