diff --git a/src/renderer/src/components/native-chat/native-chat-active-rail-item.test.ts b/src/renderer/src/components/native-chat/native-chat-active-rail-item.test.ts index 03bd1d307d5..db4aefadc19 100644 --- a/src/renderer/src/components/native-chat/native-chat-active-rail-item.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-active-rail-item.test.ts @@ -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') + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-active-rail-item.ts b/src/renderer/src/components/native-chat/native-chat-active-rail-item.ts index 95298cf2f74..5ed59abcbd8 100644 --- a/src/renderer/src/components/native-chat/native-chat-active-rail-item.ts +++ b/src/renderer/src/components/native-chat/native-chat-active-rail-item.ts @@ -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 /** 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) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts b/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts index 58e48d370ea..48bb9a3e578 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts @@ -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 } diff --git a/src/renderer/src/components/native-chat/use-native-chat-message-rail.ts b/src/renderer/src/components/native-chat/use-native-chat-message-rail.ts index e5ca4dcc430..27655a3cbcf 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-message-rail.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-message-rail.ts @@ -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(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] ) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-viewport-align.ts b/src/renderer/src/components/native-chat/use-native-chat-viewport-align.ts index 3cf0d636ef9..c6dc765a9b4 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-viewport-align.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-viewport-align.ts @@ -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.