From e9b3efa7d75cf36244e2a1a03bfdd6f27a114f5b Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:55:29 +0000 Subject: [PATCH] feat(native-chat): preview each rail tick with its reply (#26056) Hovering the rail opened the whole message list. Also fixes rail jumps that were cancelled, undone or landed off when rows resized mid-scroll. --- ...agent-session-conversation-outline.test.ts | 22 + .../agent-session-conversation-outline.ts | 14 +- ...essageList.message-rail-windowing.test.tsx | 6 - ...ChatMessageList.rail-history-jump.test.tsx | 39 +- .../native-chat/NativeChatMessageList.tsx | 36 +- .../NativeChatMessageRail.test.tsx | 551 ++++-------------- .../native-chat/NativeChatMessageRail.tsx | 332 +++++------ .../native-chat-active-rail-item.test.ts | 16 + .../native-chat-active-rail-item.ts | 6 +- .../native-chat-autoscroll.test.ts | 28 +- .../native-chat/native-chat-autoscroll.ts | 6 +- .../native-chat-message-rail-items.test.ts | 98 +++- .../native-chat-message-rail-items.ts | 104 +++- .../native-chat-rail-outline-parity.test.ts | 212 ++++++- .../native-chat-reader-scroll-input.ts | 8 +- ...native-chat-submission-navigation.test.tsx | 1 + .../structured-agent-session-rail-outline.ts | 4 +- .../use-native-chat-message-rail.test.ts | 58 +- .../use-native-chat-message-rail.ts | 48 +- .../use-native-chat-rail-jump-landing.ts | 50 ++ ...use-native-chat-transcript-scroll.test.tsx | 1 + .../use-native-chat-transcript-scroll.ts | 8 +- ...ve-chat-transcript-window.options.test.tsx | 86 ++- .../use-native-chat-transcript-window.ts | 119 ++-- .../use-native-chat-viewport-align.ts | 130 +++++ ...nt-session-queued-messages.resume.test.tsx | 1 + .../agent-session-conversation-outline.ts | 93 ++- tests/e2e/native-chat-message-rail.spec.ts | 76 ++- 28 files changed, 1302 insertions(+), 851 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-native-chat-rail-jump-landing.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-viewport-align.ts diff --git a/src/main/native-chat/agent-session-wire/agent-session-conversation-outline.test.ts b/src/main/native-chat/agent-session-wire/agent-session-conversation-outline.test.ts index 35737dd84cc..9dd2b5c48f2 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-conversation-outline.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-conversation-outline.test.ts @@ -77,6 +77,28 @@ describe('conversation outline read', () => { expect(replyBytes(trimmed.entries)).toBeLessThanOrEqual(budget) }) + it('carries the reply to each prompt, and drops replies before it shortens a preview', () => { + const items = Array.from({ length: 10 }, (_, index): AgentJournalRenderItem[] => [ + prompt(index * 2 + 1, 'x'.repeat(100)), + { + itemId: `reply-${index}`, + revision: 1, + sequence: index * 2 + 2, + observedAt: index * 2 + 2, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Answer' }] } + } + ]).flat() + const full = readAgentSessionConversationOutline(snapshot(items), Number.MAX_SAFE_INTEGER) + expect(full.entries.map((entry) => entry.reply)).toEqual(Array(10).fill('Answer')) + + // One byte short: the budget counts a separator per entry, the array two brackets. + const tight = readAgentSessionConversationOutline(snapshot(items), replyBytes(full.entries) - 2) + expect(tight.entries.every((entry) => entry.reply === undefined)).toBe(true) + expect(tight.entries.map((entry) => entry.preview)).toEqual( + full.entries.map((entry) => entry.preview) + ) + }) + it('stays inside the history page budget for a journal far past it', () => { // ~10k prompts of 1 KB each: an order of magnitude past the budget at full preview. const items = Array.from({ length: 10_000 }, (_, index) => diff --git a/src/main/native-chat/agent-session-wire/agent-session-conversation-outline.ts b/src/main/native-chat/agent-session-wire/agent-session-conversation-outline.ts index 198a813b217..ecdcb30062d 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-conversation-outline.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-conversation-outline.ts @@ -2,8 +2,9 @@ // request rather than stored, so it can never disagree with the journal it lists. // // One reply must fit the same budget a history page does. Past it the reply -// degrades in order of what the rail can best spare: shorter previews, then no -// previews (a tick still marks the message), and only then the oldest entries. +// degrades in order of what the rail can best spare: the agent's replies, then +// shorter previews, then no previews (a tick still marks the message), and only +// then the oldest entries. import { AGENT_SESSION_OUTLINE_PREVIEW_MAX_CHARS, @@ -32,6 +33,12 @@ function withPreviewCap( }) } +function withoutReplies( + entries: readonly AgentSessionConversationOutlineEntry[] +): AgentSessionConversationOutlineEntry[] { + return entries.map(({ reply: _reply, ...entry }) => entry) +} + function totalBytes(entries: readonly AgentSessionConversationOutlineEntry[]): number { return entries.reduce((total, entry) => total + entryBytes(entry), 0) } @@ -42,6 +49,9 @@ export function readAgentSessionConversationOutline( ): AgentSessionConversationOutline { const projected = projectAgentSessionConversationOutline(snapshot.items, snapshot.submissions) let entries = withPreviewCap(projected, AGENT_SESSION_OUTLINE_PREVIEW_MAX_CHARS) + if (totalBytes(entries) > budgetBytes) { + entries = withoutReplies(entries) + } for (const maxChars of [DEGRADED_PREVIEW_MAX_CHARS, 0]) { if (totalBytes(entries) <= budgetBytes) { break diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.message-rail-windowing.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.message-rail-windowing.test.tsx index 7fc08a3036b..561fd7fa118 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.message-rail-windowing.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.message-rail-windowing.test.tsx @@ -109,7 +109,6 @@ describe('revealing a diff from a turn rollup', () => { scrollTranscript(container, 6000) expect(screen.getByText('Edited')).toBeInTheDocument() scrollTo.mockClear() - fireEvent.click(screen.getByRole('button', { name: 'Your messages' })) fireEvent.click(screen.getByRole('button', { name: 'Second prompt' })) expect(scrollTo.mock.calls).toEqual([ [{ top: 6000, behavior: 'auto' }], @@ -164,12 +163,7 @@ describe('jumping to a message from the rail', () => { index % 10 === 0 ? userMarker(index) : marker(index) ) - /** Open the hover panel through the trigger and click the first prompt. */ function jumpToFirstPrompt(): void { - fireEvent.click(screen.getByRole('button', { name: 'Your messages' })) - act(() => { - vi.advanceTimersByTime(300) - }) fireEvent.click(screen.getByRole('button', { name: 'prompt-0' })) act(() => { vi.advanceTimersByTime(300) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.rail-history-jump.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.rail-history-jump.test.tsx index 492269cc82e..f849085733e 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.rail-history-jump.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.rail-history-jump.test.tsx @@ -277,8 +277,6 @@ describe('jumping from the rail while following the end', () => { expect(distanceFromBottom()).toBe(0) expect(screen.queryByText(prompt) !== null).toBe(loaded) - fireEvent.click(screen.getByRole('button', { name: 'Your messages' })) - await frame() fireEvent.click(screen.getByRole('button', { name: prompt })) await settle(60) @@ -302,13 +300,11 @@ describe('jumping from the rail while following the end', () => { render() await settle(10) - fireEvent.click(screen.getByRole('button', { name: 'Your messages' })) - await frame() fireEvent.click(screen.getByRole('button', { name: 'prompt-5' })) await frame() // Anti-vacuous: the older page is in flight. expect(releaseFirstPage).not.toBeNull() - // The list stays open while the pick pages in, so the reader picks again in place. + // The reader picks again while the first pick is still paging in. fireEvent.click(screen.getByRole('button', { name: 'prompt-45' })) await settle(40) act(() => releaseFirstPage?.()) @@ -341,27 +337,23 @@ describe('jumping from the rail while following the end', () => { } async function pickUnloadedWhilePaging(prompt: string): Promise { - fireEvent.click(screen.getByRole('button', { name: 'Your messages' })) - await frame() fireEvent.click(screen.getByRole('button', { name: prompt })) await frame() } - it('keeps the list open with the pick marked busy until its history lands', async () => { + it('marks the pick busy until its history lands', async () => { const pages = holdFirstPage() render() await settle(10) await pickUnloadedWhilePaging('prompt-5') expect(pages.asked()).toBe(1) - // The failure mode: picking closed the list, so the busy item was never seen. - expect(screen.getByRole('dialog')).toBeTruthy() expect(screen.getByRole('button', { name: 'prompt-5' }).getAttribute('aria-busy')).toBe('true') pages.release() await settle(60) - expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.getByRole('button', { name: 'prompt-5' }).getAttribute('aria-busy')).toBeNull() expect(Math.abs(rowOffsetFromViewportTop('prompt-5'))).toBeLessThanOrEqual(2) }) @@ -382,8 +374,8 @@ describe('jumping from the rail while following the end', () => { input() await frame() - // Abandoning the jump settles the pick: nothing is left pulsing in an open list. - expect(screen.queryByRole('dialog')).toBeNull() + // Abandoning the jump settles the pick: nothing is left pulsing on the rail. + expect(screen.getByRole('button', { name: 'prompt-5' }).getAttribute('aria-busy')).toBeNull() pages.release() await settle(60) @@ -401,7 +393,7 @@ describe('jumping from the rail while following the end', () => { expect(pages.asked()).toBe(1) // The rail forwards its wheel to the transcript: the reader is scrolling. - fireEvent.wheel(screen.getByRole('button', { name: 'Your messages' }), { deltaY: -40 }) + fireEvent.wheel(screen.getByRole('toolbar', { name: 'Your messages' }), { deltaY: -40 }) await frame() // Anti-vacuous: the wheel moved the transcript. expect(distanceFromBottom()).toBe(40) @@ -413,22 +405,6 @@ describe('jumping from the rail while following the end', () => { expect(distanceFromBottom()).toBe(40) }) - it('keeps paging when the reader wheels the open message list', async () => { - const pages = holdFirstPage() - render() - await settle(10) - await pickUnloadedWhilePaging('prompt-5') - expect(pages.asked()).toBe(1) - - // The list scrolls itself; the transcript is not being read. - fireEvent.wheel(screen.getByRole('dialog'), { deltaY: -40 }) - pages.release() - await settle(60) - - expect(pages.asked()).toBeGreaterThan(1) - expect(Math.abs(rowOffsetFromViewportTop('prompt-5'))).toBeLessThanOrEqual(2) - }) - it('stays at the latest message when "Jump to latest" is pressed while the jump pages', async () => { const pages = holdFirstPage() render() @@ -466,8 +442,6 @@ describe('jumping from the rail while following the end', () => { render() await settle(10) - fireEvent.click(screen.getByRole('button', { name: 'Your messages' })) - await frame() fireEvent.click(screen.getByRole('button', { name: 'prompt-45' })) await frame() @@ -556,7 +530,6 @@ describe('revealing a diff while a rail jump pages', () => { vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo) const { container } = render() - fireEvent.click(screen.getByRole('button', { name: 'Your messages' })) fireEvent.click(screen.getByRole('button', { name: 'Oldest prompt' })) // Anti-vacuous: the older page is in flight. expect(holdPage).toHaveBeenCalledTimes(1) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index a7aad33bec9..599a21b010a 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -4,7 +4,7 @@ import { } 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 { useCallback, useMemo, useRef, useState } from 'react' import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' import { translate } from '@/i18n/i18n' import type { NativeChatLiveSession } from './use-native-chat-live-session' @@ -47,6 +47,7 @@ import type { NativeChatRailOutlineEntry } from './native-chat-message-rail-items' import { useNativeChatRailHistoryJump } from './use-native-chat-rail-history-jump' +import { useNativeChatRailJumpLanding } from './use-native-chat-rail-jump-landing' import { useNativeChatReaderScrollInput } from './native-chat-reader-scroll-input' import { useNativeChatMessageListHandle } from './use-native-chat-reveal-latest' @@ -241,6 +242,7 @@ export function NativeChatMessageList({ showsTailRow: tailRow !== null, isVisible, alignToViewportTop: transcriptWindow.alignToViewportTop, + isAlignPending: transcriptWindow.isAlignPending, scrollToEnd: transcriptWindow.scrollToEnd, restoreScrollOffset: transcriptWindow.restoreScrollOffset, consumeProgrammaticScroll: transcriptWindow.consumeProgrammaticScroll, @@ -257,10 +259,10 @@ export function NativeChatMessageList({ const rail = useNativeChatMessageRail({ scrollRef, slots, + turnRows, virtualItems: transcriptWindow.virtualItems, outline: railOutline }) - const servicedRailJumpRef = useRef(0) const requestRailJump = useCallback((item: NativeChatRailItem) => { navigationSequence.current += 1 setNavigationRequest({ @@ -314,29 +316,17 @@ export function NativeChatMessageList({ useNativeChatMessageListHandle(ref, jumpToLatest) const readerScrollInput = useNativeChatReaderScrollInput(scrollRef, { onReaderScroll: beginNavigation, + onTakeScroll: transcriptWindow.cancelAlign, onLeaveEnd: readerLeavesEnd }) - // Pinning the target mounts it in the same commit, so the row exists by the time - // layout runs. Routed through `scrollMessageToTop` rather than the virtualizer - // because that is what releases the bottom pin — without it the next streamed - // token snaps the reader straight back down. - // - // Serviced once per request, then released. `slots` takes a new identity on - // every render, so an effect that merely depended on it would re-scroll to this - // row forever; and a request left standing would keep its pin, which outranks - // the diff reveal that shares it. - useLayoutEffect(() => { - if (railJump === null || servicedRailJumpRef.current === railJump.requestId) { - return - } - servicedRailJumpRef.current = railJump.requestId - const index = nativeChatSlotIndexOf(slots, railJump.messageId) - const row = scrollRef.current?.querySelector(`[data-index="${index}"]`) - if (row) { - scrollMessageToTop(row) - } - setNavigationRequest(null) - }, [railJump, scrollMessageToTop, slots]) + useNativeChatRailJumpLanding({ + railJump, + slots, + scrollRef, + scrollMessageToTop, + onActivate: rail.onActivate, + release: setNavigationRequest + }) const rowContext = useMemo( () => ({ diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRail.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRail.test.tsx index fdcac435cd8..d0b027254ee 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRail.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRail.test.tsx @@ -1,260 +1,147 @@ // @vitest-environment happy-dom -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { createRef, useImperativeHandle, useState } from 'react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useEffect, useState } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' import { NativeChatMessageRail } from './NativeChatMessageRail' +import { useNativeChatMessageRail } from './use-native-chat-message-rail' afterEach(cleanup) -const items = Array.from({ length: 3 }, (_, index) => ({ - id: `prompt-${index}`, - text: `Prompt ${index}`, - slotIndex: index, - hasImages: false -})) -const overflowItems = Array.from({ length: 20 }, (_, index) => ({ - id: `overflow-prompt-${index}`, - text: `Overflow prompt ${index}`, - slotIndex: index, - hasImages: false -})) +const NO_SLOTS: never[] = [] +const NO_ROWS = { messages: [], turnKeys: [] } -function retainClosingPopover(): ReturnType { - 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) - }) +/** The rail over the real hook, on a thread known only from the host's outline. + * The first two prompts have replies; past 20 the ticks are sampled. */ +function Rail({ + count = 3, + activeIndex, + scroller, + ...rest +}: Partial> & { + count?: number + activeIndex?: number + scroller?: HTMLDivElement +}): React.JSX.Element { + const [scrollRef] = useState(() => ({ current: scroller ?? document.createElement('div') })) + const [outline] = useState(() => + Array.from({ length: count }, (_, index) => ({ + id: `prompt-${index}`, + text: `Prompt ${index}`, + hasImages: false, + reply: index < 2 ? `Reply ${index}` : undefined + })) + ) + const rail = useNativeChatMessageRail({ + scrollRef, + slots: NO_SLOTS, + turnRows: NO_ROWS, + virtualItems: NO_SLOTS, + outline }) -} - -const unloadedItems = [ - { id: 'unloaded-prompt', text: 'Unloaded prompt', slotIndex: null, hasImages: false }, - ...items -] - -/** Stands in for the transcript: an unloaded pick stays pending until settled. */ -function PagingRail({ ref }: { ref?: React.Ref<{ settle: () => void }> }): React.JSX.Element { - const [pendingId, setPendingId] = useState(null) - useImperativeHandle(ref, () => ({ settle: () => setPendingId(null) }), []) + const { onActivate } = rail + useEffect(() => { + if (activeIndex !== undefined) { + onActivate(`prompt-${activeIndex}`) + } + }, [activeIndex, onActivate]) return ( - setPendingId(item.slotIndex === null ? item.id : null)} - pendingId={pendingId} - /> + <> + + + > ) } +const tick = (name: string): HTMLElement => screen.getByRole('button', { name }) +const preview = (): HTMLElement | null => + document.querySelector('[data-slot="hover-card-content"]') + describe('message rail interaction', () => { - it('keeps the list open on an unloaded pick until its jump settles', async () => { - const user = userEvent.setup() - const paging = createRef<{ settle: () => void }>() - render() - await user.hover(screen.getByRole('button', { name: 'Your messages' })) - await screen.findByRole('dialog') + it('previews only the hovered tick: its message and the reply to it', async () => { + render() + fireEvent.pointerEnter(tick('Prompt 1'), { pointerType: 'mouse' }) + await waitFor(() => expect(preview()).not.toBeNull()) + expect(preview()?.textContent).toBe('Prompt 1Reply 1') - await user.click(screen.getByRole('button', { name: 'Unloaded prompt' })) - expect(screen.getByRole('dialog')).toBeTruthy() - expect(screen.getByRole('button', { name: 'Unloaded prompt' }).getAttribute('aria-busy')).toBe( - 'true' - ) - - act(() => paging.current?.settle()) - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + fireEvent.pointerEnter(tick('Prompt 0'), { pointerType: 'mouse' }) + await waitFor(() => expect(preview()?.textContent).toBe('Prompt 0Reply 0')) }) - it('closes a list held for a pending pick on Escape', async () => { - const user = userEvent.setup() - render() - await user.hover(screen.getByRole('button', { name: 'Your messages' })) - await screen.findByRole('dialog') - await user.click(screen.getByRole('button', { name: 'Unloaded prompt' })) - expect(screen.getByRole('dialog')).toBeTruthy() - - await user.keyboard('{Escape}') - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + it('previews the message alone while the agent has not replied', async () => { + render() + fireEvent.pointerEnter(tick('Prompt 2'), { pointerType: 'mouse' }) + await waitFor(() => expect(preview()).not.toBeNull()) + expect(preview()?.textContent).toBe('Prompt 2') }) - it('opens from the keyboard, reaches prompts, jumps, and restores focus', async () => { - const user = userEvent.setup() + it('jumps to the clicked tick', async () => { const select = vi.fn() - render( - - ) - const trigger = screen.getByRole('button', { name: 'Your messages' }) - await user.tab() - expect(document.activeElement).toBe(trigger) - await user.keyboard('{Enter}') - await waitFor(() => - expect(document.activeElement).toBe( - screen.getByRole('button', { name: 'Overflow prompt 12' }) - ) - ) - await user.keyboard('{Enter}') - expect(select).toHaveBeenCalledWith(overflowItems[12]) - await waitFor(() => expect(document.activeElement).toBe(trigger)) - expect(screen.queryByRole('dialog')).toBeNull() - await user.keyboard('{Enter}') - await user.keyboard('{Escape}') - await waitFor(() => expect(document.activeElement).toBe(trigger)) - expect(screen.queryByRole('dialog')).toBeNull() + render() + await userEvent.setup().click(tick('Prompt 1')) + expect(select).toHaveBeenCalledWith(expect.objectContaining({ id: 'prompt-1' })) }) - it('focuses the current prompt when a hover preview becomes interactive', async () => { - render( - - ) - 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( - - ) - 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( - - ) - 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( - - ) - 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( - - ) - - expect(document.activeElement).toBe(focusedPrompt) - }) - - it('keeps focus in the transcript while a hover preview opens and closes', async () => { - render( - <> - - - > - ) + it('keeps focus where it was while a hover preview opens and closes', async () => { + render() const composer = screen.getByRole('textbox') composer.focus() - const trigger = screen.getByRole('button', { name: 'Your messages' }) - fireEvent.pointerEnter(trigger, { pointerType: 'mouse' }) - await screen.findByRole('dialog') + const rail = screen.getByRole('toolbar', { name: 'Your messages' }) + fireEvent.pointerEnter(rail, { pointerType: 'mouse' }) + fireEvent.pointerEnter(tick('Prompt 0'), { pointerType: 'mouse' }) + await waitFor(() => expect(preview()).not.toBeNull()) expect(document.activeElement).toBe(composer) - fireEvent.pointerLeave(trigger, { pointerType: 'mouse' }) - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + fireEvent.pointerLeave(rail, { pointerType: 'mouse' }) + await waitFor(() => expect(preview()).toBeNull()) expect(document.activeElement).toBe(composer) }) + it('is one tab stop on the current tick, walked by arrow keys, each previewing itself', async () => { + const user = userEvent.setup() + const select = vi.fn() + render() + screen.getByRole('textbox').focus() + await user.tab() + expect(document.activeElement).toBe(tick('Prompt 1')) + await waitFor(() => expect(preview()).not.toBeNull()) + expect(preview()?.textContent).toBe('Prompt 1Reply 1') + + await user.keyboard('{ArrowUp}') + expect(document.activeElement).toBe(tick('Prompt 0')) + await waitFor(() => expect(preview()?.textContent).toBe('Prompt 0Reply 0')) + await user.keyboard('{End}{Enter}') + expect(select).toHaveBeenCalledWith(expect.objectContaining({ id: 'prompt-2' })) + + await user.keyboard('{Escape}') + await waitFor(() => expect(preview()).toBeNull()) + expect(document.activeElement).toBe(tick('Prompt 2')) + await user.tab() + expect(screen.getByRole('toolbar').contains(document.activeElement)).toBe(false) + }) + + // A long thread draws only some ticks; the keyboard still reaches every message, + // and the tick it lands on stays drawn while it holds focus. + it('walks every message with the arrow keys, sampled onto the rail or not', async () => { + const user = userEvent.setup() + render() + // Anti-vacuous: the second message has no tick until the keyboard reaches it. + expect(screen.queryByRole('button', { name: 'Prompt 1' })).toBeNull() + screen.getByRole('textbox').focus() + await user.tab() + expect(document.activeElement).toBe(tick('Prompt 0')) + await user.keyboard('{ArrowDown}') + expect(document.activeElement).toBe(tick('Prompt 1')) + await waitFor(() => expect(preview()?.textContent).toBe('Prompt 1Reply 1')) + await user.keyboard('{ArrowDown}') + expect(document.activeElement).toBe(tick('Prompt 2')) + }) + + it('marks a tick whose history is still paging in as busy', () => { + render() + expect(tick('Prompt 0').getAttribute('aria-busy')).toBe('true') + expect(tick('Prompt 1').getAttribute('aria-busy')).toBeNull() + }) + it.each([ [0, 7], [1, 112], @@ -262,205 +149,11 @@ describe('message rail interaction', () => { ])('forwards wheel delta mode %i', (deltaMode, expected) => { const element = document.createElement('div') Object.defineProperty(element, 'clientHeight', { value: 400 }) - render( - - ) - fireEvent.wheel(screen.getByRole('button', { name: 'Your messages' }), { deltaY: 7, deltaMode }) + render() + fireEvent.wheel(screen.getByRole('toolbar', { 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 - - 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( - - ) - 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( - - ) - 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( - - ) - - 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( - - ) - 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( - - ) - - expect(scrolled).toEqual([screen.getByRole('button', { name: 'Prompt 2' })]) - }) - - // Pressing an item focuses it, which turns a hover preview interactive. Scrolling - // the lit row into view at that moment moved the list under the pointer, so the - // release landed on the list instead of the pressed item and the click was lost. - it('does not move the list or focus when a press makes a hover preview interactive', async () => { - const user = userEvent.setup() - const select = vi.fn() - render( - - ) - await user.hover(screen.getByRole('button', { name: 'Your messages' })) - await screen.findByRole('dialog') - // Anti-vacuous: opening revealed the lit row. - expect(scrolled).toEqual([screen.getByRole('button', { name: 'Overflow prompt 19' })]) - scrolled.length = 0 - - const older = screen.getByRole('button', { name: 'Overflow prompt 0' }) - await user.pointer({ keys: '[MouseLeft>]', target: older }) - expect(scrolled).toEqual([]) - expect(document.activeElement).toBe(older) - await user.pointer({ keys: '[/MouseLeft]', target: older }) - expect(select).toHaveBeenCalledWith(overflowItems[0]) - }) - - it('does not move the list while a picked message pages in', async () => { - const { rerender } = render( - - ) - fireEvent.pointerEnter(screen.getByRole('button', { name: 'Your messages' }), { - pointerType: 'mouse' - }) - await screen.findByRole('dialog') - // Anti-vacuous: opening revealed the lit row. - expect(scrolled).toEqual([screen.getByRole('button', { name: 'Prompt 2' })]) - scrolled.length = 0 - - // A landed page gives every row a new slot while the pick is still pending. - const pagedItems = items.map((item) => ({ ...item, slotIndex: item.slotIndex + 5 })) - rerender( - - ) - - expect(scrolled).toEqual([]) - }) - - it('reveals the lit row when the list opens from the keyboard', async () => { - const user = userEvent.setup() - render( - - ) - screen.getByRole('button', { name: 'Your messages' }).focus() - await user.keyboard('{Enter}') - const lit = screen.getByRole('button', { name: 'Overflow prompt 12' }) - await waitFor(() => expect(document.activeElement).toBe(lit)) - expect(scrolled).toContain(lit) - }) - - it('leaves the panel alone when no message is lit', async () => { - render( - - ) - fireEvent.pointerEnter(screen.getByRole('button', { name: 'Your messages' }), { - pointerType: 'mouse' - }) - await screen.findByRole('dialog') - expect(scrolled).toEqual([]) - }) - }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRail.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRail.tsx index 945610e4c93..8a8a794ecc4 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRail.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRail.tsx @@ -1,11 +1,15 @@ // 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. +// per user message. Each tick previews its own message and the agent's reply +// while hovered or focused, and jumps to the message on click. -import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { memo, useLayoutEffect, useRef, useState } from 'react' +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' -import type { NativeChatRailItem } from './native-chat-message-rail-items' +import { + NATIVE_CHAT_RAIL_ROOMY_TICKS, + type NativeChatRailItem +} from './native-chat-message-rail-items' import type { NativeChatMessageRailState } from './use-native-chat-message-rail' const WHEEL_DELTA_LINE = 1 @@ -22,82 +26,25 @@ function railItemLabel(item: NativeChatRailItem): string { : translate('components.native-chat.railEmptyMessage', 'Message') } -type NativeChatMessageRailMode = 'hover' | 'interactive' | null +/** Where the card sits, and the reply it showed, latched on close for its exit animation. */ +type NativeChatRailPreviewTarget = { id: string; top: number; reply: string } -function NativeChatMessageRailItems({ - mode, - items, - activeId, - pendingId, - onSelect -}: { - mode: NativeChatMessageRailMode - items: readonly NativeChatRailItem[] - activeId: string | null - pendingId: string | null - onSelect: (item: NativeChatRailItem) => void -}): React.JSX.Element { - const listRef = useRef(null) - const currentItemRef = useRef(null) - const previousMode = useRef(null) +/** From a tick's centre up to the card's top edge, so the card's first line meets + * the tick: the card's `p-4` plus half a `text-xs leading-snug` line. */ +const PREVIEW_FIRST_LINE_PX = 24 - const open = mode !== null - // Reveal the lit row when the list opens or its rows shift — not when a hover - // preview turns interactive, which a press on an item does: moving the list - // then slides the item out from under the pointer and the click is lost. Nor - // while a picked item pages in: each landed page shifts the rows under it. - useLayoutEffect(() => { - if (open && activeId !== null && items.length > 0 && pendingId === null) { - currentItemRef.current?.scrollIntoView({ block: 'nearest' }) - } - }, [activeId, items, open, pendingId]) - - // Entering interactive from the rail moves focus into the list; entering it by - // focusing an item already put focus where the reader chose. - useLayoutEffect(() => { - if ( - mode === 'interactive' && - previousMode.current !== 'interactive' && - !listRef.current?.contains(document.activeElement) - ) { - const focusTarget = - (activeId === null ? null : currentItemRef.current) ?? - listRef.current?.querySelector('button') - focusTarget?.focus({ preventScroll: true }) - } - previousMode.current = mode - }, [activeId, mode]) - - return ( - - {items.map((item) => ( - - onSelect(item)} - aria-current={item.id === activeId ? 'true' : undefined} - aria-busy={item.id === pendingId ? 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' - )} - > - - {railItemLabel(item)} - - - - ))} - - ) +/** How far a navigation key moves through the messages; null for any other key. */ +function railKeyStep(key: string, index: number, count: number): number | null { + if (key === 'ArrowDown') { + return index + 1 + } + if (key === 'ArrowUp') { + return index - 1 + } + if (key === 'Home') { + return 0 + } + return key === 'End' ? count - 1 : null } export const NativeChatMessageRail = memo(function NativeChatMessageRail({ @@ -115,75 +62,80 @@ export const NativeChatMessageRail = memo(function NativeChatMessageRail({ /** A tick whose older history is still paging in. */ pendingId?: string | null }): React.JSX.Element | null { - // Hover preserves focus; activation enters the focus-managed prompt picker. - const [mode, setMode] = useState(null) - // A pick that pages history in keeps the list open, its item pulsing, until the - // jump lands or is abandoned; then it closes as any pick does. - const [heldId, setHeldId] = useState(null) - if (heldId !== null && (heldId !== pendingId || mode === null)) { - setHeldId(null) - setMode(null) - } - const closeTimer = useRef | null>(null) - const restoreFocus = useRef(false) - const open = mode !== null - const cancelClose = (): void => { - if (closeTimer.current !== null) { - clearTimeout(closeTimer.current) + const [target, setTarget] = useState(null) + const railRef = useRef(null) + /** A message an arrow key asked to focus, whose tick may not be drawn until the next render. */ + const focusRequest = useRef(null) + const { ticks, items, focusId } = rail + + useLayoutEffect(() => { + if (focusRequest.current !== focusId) { + return } - closeTimer.current = null - } - const leavePreview = (): void => { - cancelClose() - if (mode === 'hover') { - closeTimer.current = setTimeout(() => setMode(null), 120) + const index = ticks.findIndex((item) => item.id === focusId) + if (index !== -1) { + focusRequest.current = null + railRef.current?.querySelectorAll('button')[index]?.focus() } - } - useEffect( - () => () => { - if (closeTimer.current !== null) { - clearTimeout(closeTimer.current) - } - }, - [] - ) + }, [focusId, ticks]) + if (!rail.visible) { return null } + const previewed = target === null ? null : (ticks.find((item) => item.id === target.id) ?? null) + const previewedId = rail.previewId !== null && previewed ? previewed.id : null + const reply = previewedId === null ? (target?.reply ?? '') : rail.previewReply + // One tab stop for the whole rail; arrow keys walk it from there. + const tabId = + [focusId, rail.activeId].find((id) => ticks.some((item) => item.id === id)) ?? ticks[0]?.id + + const show = (item: NativeChatRailItem, element: HTMLElement): void => { + setTarget({ id: item.id, top: element.offsetTop + element.offsetHeight / 2, reply: '' }) + rail.onPreview(item.id) + } + const close = (): void => { + setTarget((current) => current && { ...current, reply: rail.previewReply }) + rail.onPreview(null) + } + return ( - { - cancelClose() - if (open) { - restoreFocus.current = true - } - setMode(open ? 'interactive' : null) - }} + // One card for the whole rail, moved to the tick it previews: a card per tick + // would animate out and in again on every step between neighbours. The ticks + // open it; the card's own hover, blur and Escape handling closes it. + !next && close()} + openDelay={0} + closeDelay={100} > - - + { - if (event.pointerType === 'touch') { + aria-orientation="vertical" + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) { + rail.onFocusItem(null) + } + }} + onKeyDown={(event) => { + // Through every message, not only the drawn ticks: a long thread samples them. + const next = railKeyStep( + event.key, + items.findIndex((item) => item.id === tabId), + items.length + ) + if (next === null) { return } - cancelClose() - if (mode === null) { - restoreFocus.current = false - } - setMode((current) => current ?? 'hover') - }} - onPointerLeave={leavePreview} - onClick={(event) => { - cancelClose() - if (mode === 'hover') { - event.preventDefault() - restoreFocus.current = true - setMode('interactive') + event.preventDefault() + const item = items[next] + if (item) { + focusRequest.current = item.id + rail.onFocusItem(item.id) } }} // The rail overlays the transcript without being inside it, so a wheel @@ -206,56 +158,70 @@ export const NativeChatMessageRail = memo(function NativeChatMessageRail({ onReaderScroll?.(event.deltaY * scale) element.scrollTop += event.deltaY * scale }} - className="group/rail absolute inset-y-0 right-[14px] z-10 flex w-4 cursor-default flex-col items-center justify-center gap-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + className="absolute top-1/2 right-2.5 z-10 flex w-6 -translate-y-1/2 flex-col" > - {rail.ticks.map((item) => ( - - ))} - - - { + // The pointer or keyboard owns the fill while a preview is open; otherwise + // the fill reports the scroll position. + const lit = item.id === (previewedId ?? rail.activeId) + return ( + { + if (event.pointerType !== 'touch') { + show(item, event.currentTarget) + } + }} + onFocus={(event) => { + rail.onFocusItem(item.id) + // A click focuses the tick too, and must not reopen the preview it dismissed. + if (event.currentTarget.matches(':focus-visible')) { + show(item, event.currentTarget) + } + }} + onClick={() => onSelect(item)} + // Padding, not a gap, so the pointer never falls between two ticks. + className={cn( + 'flex w-full shrink-0 cursor-pointer items-center justify-center rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', + ticks.length > NATIVE_CHAT_RAIL_ROOMY_TICKS ? 'py-0.5' : 'py-1' + )} + > + + + ) + })} + + + { - cancelClose() - restoreFocus.current = true - setMode('interactive') - }} - onOpenAutoFocus={(event) => event.preventDefault()} - onCloseAutoFocus={(event) => { - if (!restoreFocus.current) { - event.preventDefault() - } - }} + align="start" + alignOffset={(target?.top ?? 0) - PREVIEW_FIRST_LINE_PX} + sideOffset={8} + className="w-72" > - { - onSelect(item) - if (item.slotIndex === null) { - setHeldId(item.id) - } else { - setMode(null) - } - }} - /> - - + {previewed ? ( + + + {railItemLabel(previewed)} + + {reply.length > 0 ? ( + {reply} + ) : null} + + ) : null} + + ) }) 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 72e4898ce04..03bd1d307d5 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 @@ -82,6 +82,22 @@ describe('active rail item', () => { ).toBe('u2') }) + // A message jumped to near the end sits at the top edge a few pixels short of + // the bottom: it is what is being read, not the newest turn. + it('lights the turn at the fold when parked just above the bottom', () => { + expect( + findActiveNativeChatRailItem({ + slots: [{ turnKey: 'u1' }, { turnKey: 'u1' }, { turnKey: 'u2' }, { turnKey: 'u2' }], + virtualItems: rows(4), + // 30px short of the end, with a `u1` row at the top edge. + scrollTop: 100, + clientHeight: VIEWPORT, + scrollHeight: 430, + previousActiveId: null + }) + ).toBe('u1') + }) + it('lights nothing above the first prompt', () => { expect( findActiveNativeChatRailItem({ 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 dc780334df4..95298cf2f74 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 @@ -14,7 +14,7 @@ // `item.start - scrollMargin` inside a sizer sitting `scrollMargin` down), which is // the same coordinate space as `scrollTop`. -import { NATIVE_CHAT_BOTTOM_THRESHOLD_PX } from './native-chat-autoscroll' +import { NATIVE_CHAT_FOLLOW_REARM_PX } from './native-chat-autoscroll' /** The virtualizer's item, restated so this module needs nothing from the lib. */ export type NativeChatRailVirtualItem = { @@ -55,7 +55,9 @@ export function findActiveNativeChatRailItem({ // Pinned to the bottom the newest turn is what is being read, whatever happens // to sit at the top edge — a short last turn would otherwise light its predecessor. - const atBottom = scrollHeight - clientHeight - scrollTop <= NATIVE_CHAT_BOTTOM_THRESHOLD_PX + // The follow band, not the wider jump-affordance one: a message jumped to near + // the end sits at the top edge a few pixels short of it, and is what is being read. + 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]) diff --git a/src/renderer/src/components/native-chat/native-chat-autoscroll.test.ts b/src/renderer/src/components/native-chat/native-chat-autoscroll.test.ts index 6741369acfd..09c1b3b6348 100644 --- a/src/renderer/src/components/native-chat/native-chat-autoscroll.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-autoscroll.test.ts @@ -63,7 +63,8 @@ describe('nextFollowingEnd', () => { following: true, programmatic: false, geometry: parkedAbove(0), - previousDistanceFromEnd: 400 + previousDistanceFromEnd: 400, + settling: false } it('follows when the reader reaches the end', () => { @@ -89,7 +90,8 @@ describe('nextFollowingEnd', () => { following: false, programmatic: true, geometry: parkedAbove(distance), - previousDistanceFromEnd: 400 + previousDistanceFromEnd: 400, + settling: false }) ).toBe(false) } @@ -108,7 +110,8 @@ describe('nextFollowingEnd', () => { const detached = { following: false, programmatic: false, - previousDistanceFromEnd: 400 + previousDistanceFromEnd: 400, + settling: false } expect( nextFollowingEnd({ ...detached, geometry: parkedAbove(NATIVE_CHAT_FOLLOW_REARM_PX) }) @@ -122,7 +125,12 @@ describe('nextFollowingEnd', () => { // move a pixel or two and are unmarked: read as the reader arriving, they // re-armed follow and the next frame rebased the view, cancelling the scroll. it('does not reattach a detached reader who is moving away from the end', () => { - const leaving = { following: false, programmatic: false, previousDistanceFromEnd: 0 } + const leaving = { + following: false, + programmatic: false, + previousDistanceFromEnd: 0, + settling: false + } expect(nextFollowingEnd({ ...leaving, geometry: parkedAbove(0.3) })).toBe(false) expect(nextFollowingEnd({ ...leaving, geometry: parkedAbove(2) })).toBe(false) // Arriving from above still reattaches, and standing still at the end does too. @@ -132,6 +140,18 @@ describe('nextFollowingEnd', () => { expect(nextFollowingEnd({ ...leaving, geometry: parkedAbove(0) })).toBe(true) }) + it('does not reattach a jump still travelling when shrinking content clamps it onto the end', () => { + const clamped = { + following: false, + programmatic: false, + previousDistanceFromEnd: 35, + geometry: parkedAbove(0) + } + expect(nextFollowingEnd({ ...clamped, settling: true })).toBe(false) + // Anti-vacuous: the same offset reattaches a reader with no jump under way. + expect(nextFollowingEnd({ ...clamped, settling: false })).toBe(true) + }) + it('keeps a following reader through a small move up inside the band', () => { expect( nextFollowingEnd({ ...following, previousDistanceFromEnd: 0, geometry: parkedAbove(2) }) diff --git a/src/renderer/src/components/native-chat/native-chat-autoscroll.ts b/src/renderer/src/components/native-chat/native-chat-autoscroll.ts index ab07cc590a7..e555585a816 100644 --- a/src/renderer/src/components/native-chat/native-chat-autoscroll.ts +++ b/src/renderer/src/components/native-chat/native-chat-autoscroll.ts @@ -50,11 +50,15 @@ export type FollowIntent = { geometry: ScrollGeometry /** Distance from the end at the previous scroll event. */ previousDistanceFromEnd: number + /** Whether a jump to a row is still travelling there. Rows it passes measure + * shorter than estimated, the content shrinks, and the browser clamps the view + * onto the end: that is the end arriving, not the reader. */ + settling: boolean } /** Passive offsets preserve following; detached views rearm only while closing on the actual tail. */ export function nextFollowingEnd(intent: FollowIntent): boolean { - if (intent.following || intent.programmatic) { + if (intent.following || intent.programmatic || intent.settling) { return intent.following } if (distanceFromBottom(intent.geometry) > intent.previousDistanceFromEnd) { diff --git a/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts b/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts index a541c601966..0ed652d3a80 100644 --- a/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts @@ -6,8 +6,10 @@ import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots' import { buildNativeChatRailItems, mergeNativeChatRailOutline, + nativeChatRailReplyPreview, + nativeChatRailTickCapacity, selectNativeChatRailTicks, - NATIVE_CHAT_RAIL_MAX_TICKS, + NATIVE_CHAT_RAIL_ROOMY_TICKS, type NativeChatRailItem } from './native-chat-message-rail-items' @@ -110,40 +112,118 @@ describe('rail items', () => { }) }) +describe('rail reply preview', () => { + const outline = [ + { id: 'old-1', text: 'first', hasImages: false, reply: 'Host reply 1' }, + { id: 'old-2', text: 'second', hasImages: false, reply: 'Host reply 2, cut off early' } + ] + const rowsOf = (messages: NativeChatMessage[], turnKeys: (string | undefined)[]) => ({ + messages, + turnKeys + }) + + it("reads a loaded message its turn's reply, past a steer into the same turn", () => { + const rows = rowsOf( + [ + text('u1', 'ask', 'user'), + text('a1', 'Looking.'), + text('s1', 'also', 'user'), + text('a2', 'Done.') + ], + ['u1', 'u1', 'u1', 'u1'] + ) + expect(nativeChatRailReplyPreview(rows, [], 'u1')).toBe('Done.') + expect(nativeChatRailReplyPreview(rows, [], 's1')).toBe('Done.') + expect(nativeChatRailReplyPreview(rows, [], 'gone')).toBe('') + }) + + it('reads unloaded history its reply from the host', () => { + const rows = rowsOf([text('u1', 'ask', 'user'), text('a1', 'Done.')], ['u1', 'u1']) + const items = mergeNativeChatRailOutline(outline, []) + expect(nativeChatRailReplyPreview(rows, items, 'old-1')).toBe('Host reply 1') + expect(nativeChatRailReplyPreview(rows, items, 'old-2')).toBe('Host reply 2, cut off early') + }) + + // A long live turn scrolls its own prompt out of the loaded window; the host's + // reply for it was cut when the outline was read, the loaded rows are current. + it('prefers the loaded end of a turn whose prompt is no longer loaded', () => { + const rows = rowsOf( + [text('a0', 'Still going, latest words.'), text('u1', 'ask', 'user')], + ['old-2', 'u1'] + ) + const items = mergeNativeChatRailOutline(outline, []) + expect(nativeChatRailReplyPreview(rows, items, 'old-2')).toBe('Still going, latest words.') + expect(nativeChatRailReplyPreview(rows, items, 'old-1')).toBe('Host reply 1') + }) + + // A steer is in the turn it was sent into, under another message's key: without + // the host naming that turn it would keep the host's reply while its turn's own + // prompt, beside it on the rail, showed the loaded one. + it("reads an unloaded steer the loaded end of its turn, as its turn's prompt", () => { + const rows = rowsOf([text('a0', 'Still going, latest words.')], ['old-2']) + const items = mergeNativeChatRailOutline( + [ + ...outline, + { id: 'steer', text: 'also this', hasImages: false, reply: 'Stale.', turnKey: 'old-2' } + ], + [] + ) + expect(nativeChatRailReplyPreview(rows, items, 'steer')).toBe('Still going, latest words.') + expect(nativeChatRailReplyPreview(rows, items, 'old-2')).toBe('Still going, latest words.') + }) +}) + describe('rail tick sampling', () => { it('keeps every tick while the thread fits', () => { - const items = railItems(NATIVE_CHAT_RAIL_MAX_TICKS) - expect(selectNativeChatRailTicks({ items, activeId: null })).toBe(items) + const items = railItems(NATIVE_CHAT_RAIL_ROOMY_TICKS) + expect(selectNativeChatRailTicks({ items, keepIds: [] })).toBe(items) + }) + + // A tick is the only way to its message, so a tall transcript draws more of them. + it('fits more ticks into a taller viewport, never fewer than the roomy count', () => { + const items = railItems(60) + expect(nativeChatRailTickCapacity(0)).toBe(NATIVE_CHAT_RAIL_ROOMY_TICKS) + const maxTicks = nativeChatRailTickCapacity(800) + expect(maxTicks).toBeGreaterThanOrEqual(60) + expect(selectNativeChatRailTicks({ items, keepIds: [], maxTicks })).toBe(items) + expect(selectNativeChatRailTicks({ items, keepIds: [], maxTicks: 30 })).toHaveLength(30) }) it('caps a long thread and keeps both ends', () => { const items = railItems(120) - const ticks = selectNativeChatRailTicks({ items, activeId: null }) - expect(ticks).toHaveLength(NATIVE_CHAT_RAIL_MAX_TICKS) + const ticks = selectNativeChatRailTicks({ items, keepIds: [] }) + expect(ticks).toHaveLength(NATIVE_CHAT_RAIL_ROOMY_TICKS) expect(ticks[0]?.id).toBe('m0') expect(ticks.at(-1)?.id).toBe('m119') }) it('always includes the active tick', () => { const items = railItems(120) - const ticks = selectNativeChatRailTicks({ items, activeId: 'm7' }) + const ticks = selectNativeChatRailTicks({ items, keepIds: ['m7'] }) expect(ticks.map((tick) => tick.id)).toContain('m7') - expect(ticks).toHaveLength(NATIVE_CHAT_RAIL_MAX_TICKS) + expect(ticks).toHaveLength(NATIVE_CHAT_RAIL_ROOMY_TICKS) }) // Losing an end would make the rail claim the conversation starts or stops // somewhere it doesn't, so the eviction has to fall on a neighbour instead. it('evicts a neighbour rather than an end when the active tick is near one', () => { const items = railItems(120) - const ticks = selectNativeChatRailTicks({ items, activeId: 'm1' }) + const ticks = selectNativeChatRailTicks({ items, keepIds: ['m1'] }) const ids = ticks.map((tick) => tick.id) expect(ids).toContain('m0') expect(ids).toContain('m1') expect(ids).toContain('m119') }) + // A previewed or focused tick must not be resampled away as the reader scrolls. + it('keeps every named tick at once, within the cap', () => { + const ticks = selectNativeChatRailTicks({ items: railItems(120), keepIds: ['m7', 'm8', null] }) + expect(ticks).toHaveLength(NATIVE_CHAT_RAIL_ROOMY_TICKS) + expect(ticks.map((tick) => tick.id)).toEqual(expect.arrayContaining(['m0', 'm7', 'm8', 'm119'])) + }) + it('returns ticks in thread order', () => { - const ticks = selectNativeChatRailTicks({ items: railItems(120), activeId: 'm63' }) + const ticks = selectNativeChatRailTicks({ items: railItems(120), keepIds: ['m63'] }) const indexes = ticks.map((tick) => tick.slotIndex ?? -1) expect(indexes).toEqual([...indexes].sort((left, right) => left - right)) }) diff --git a/src/renderer/src/components/native-chat/native-chat-message-rail-items.ts b/src/renderer/src/components/native-chat/native-chat-message-rail-items.ts index a35eaa33ab9..f54858122ac 100644 --- a/src/renderer/src/components/native-chat/native-chat-message-rail-items.ts +++ b/src/renderer/src/components/native-chat/native-chat-message-rail-items.ts @@ -6,12 +6,28 @@ // compared against a virtual item without a second lookup table. Messages older // than the loaded window come from the host's outline and have no slot yet. -import { nativeChatUserMessagePreview } from '../../../../shared/agent-session-conversation-outline' +import { + nativeChatTurnReplyPreviews, + nativeChatUserMessagePreview +} from '../../../../shared/agent-session-conversation-outline' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' import type { NativeChatTranscriptSlot } from './native-chat-transcript-slots' -/** Ticks past this are sampled away: a taller rail than the viewport cannot be - * read at a glance, which is the only thing the rail is for. */ -export const NATIVE_CHAT_RAIL_MAX_TICKS = 20 +/** Ticks up to this count sit at the roomy pitch; past it they pack tight. */ +export const NATIVE_CHAT_RAIL_ROOMY_TICKS = 20 +/** The tight pitch as the rail draws it: a 3px tick inside `py-0.5`. */ +const RAIL_TIGHT_PITCH_PX = 7 +const RAIL_VIEWPORT_SHARE = 0.7 + +/** How many ticks the rail may draw. A tick is the only way to its message, so + * the rail packs as many as a share of the viewport holds before sampling any + * away: a rail taller than that cannot be read at a glance. */ +export function nativeChatRailTickCapacity(viewportHeight: number): number { + return Math.max( + NATIVE_CHAT_RAIL_ROOMY_TICKS, + Math.floor((viewportHeight * RAIL_VIEWPORT_SHARE) / RAIL_TIGHT_PITCH_PX) + ) +} export type NativeChatRailItem = { id: string @@ -21,6 +37,10 @@ export type NativeChatRailItem = { /** Preview prose, whitespace collapsed. Empty when the message is images only. */ text: string hasImages: boolean + /** The host's preview of the agent's reply, on an outline item only. */ + reply?: string + /** On an outline item that did not open its turn: the message that did. */ + turnKey?: string } /** A user message older than the loaded window, oldest first. */ @@ -28,6 +48,36 @@ export type NativeChatRailOutlineEntry = { id: string text: string hasImages: boolean + reply?: string + turnKey?: string +} + +/** The transcript's rows and each one's turn, as the slots are built from them: + * before folding, so a folded turn still has its reply to read. */ +export type NativeChatRailTurnRows = { + messages: readonly NativeChatMessage[] + turnKeys: readonly (string | undefined)[] +} + +/** What the agent answered a rail item's turn with, read when its preview opens: + * carried on every item, a streaming reply would rebuild the rail on each frame. + * Loaded rows are authoritative for the turns they hold, including the tail of a + * turn whose prompt is no longer loaded; the host's reply covers the rest. */ +export function nativeChatRailReplyPreview( + { messages, turnKeys }: NativeChatRailTurnRows, + items: readonly NativeChatRailItem[], + id: string +): string { + const row = messages.findIndex((message) => message.id === id) + const item = items.find((candidate) => candidate.id === id) + // An unloaded prompt is its turn's key unless the host names another: a steer's + // turn is the one it was sent into. + const turnKey = row === -1 ? (item?.turnKey ?? id) : turnKeys[row] + const loaded = + turnKey === undefined + ? undefined + : nativeChatTurnReplyPreviews(messages, turnKeys, turnKey).get(turnKey) + return loaded ?? item?.reply ?? '' } export function buildNativeChatRailItems( @@ -36,9 +86,12 @@ export function buildNativeChatRailItems( ): readonly NativeChatRailItem[] { const items: NativeChatRailItem[] = [] for (const [slotIndex, slot] of slots.entries()) { - // A send a Stop took back is no tick, as the host's outline of older history leaves it out. + // A send a Stop took back is no tick, as the host's outline of older history leaves it + // out. Nor is a subagent's prompt, drawn while its section is open: the rail maps + // the conversation, and must not change with what the reader has expanded. if ( slot.kind !== 'message' || + slot.depth !== 0 || slot.message.role !== 'user' || slot.message.stoppedBeforeStart === true ) { @@ -81,38 +134,47 @@ export function mergeNativeChatRailOutline( } /** Evenly spaced ticks across the whole thread, always including both ends and - * the active one. Keeping the ends fixed is what makes the rail read as a map - * of the conversation rather than a window onto part of it. */ + * the kept ones: the tick the reader is on, and any they are previewing or have + * focused. Keeping the ends fixed is what makes the rail read as a map of the + * conversation rather than a window onto part of it. */ export function selectNativeChatRailTicks({ items, - activeId + keepIds = [], + maxTicks = NATIVE_CHAT_RAIL_ROOMY_TICKS }: { items: readonly NativeChatRailItem[] - activeId: string | null + keepIds?: readonly (string | null)[] + maxTicks?: number }): readonly NativeChatRailItem[] { - if (items.length <= NATIVE_CHAT_RAIL_MAX_TICKS) { + if (items.length <= maxTicks) { return items } const maxIndex = items.length - 1 const sampled = new Set() - for (let slot = 0; slot < NATIVE_CHAT_RAIL_MAX_TICKS; slot += 1) { - sampled.add(Math.round((slot * maxIndex) / (NATIVE_CHAT_RAIL_MAX_TICKS - 1))) + for (let slot = 0; slot < maxTicks; slot += 1) { + sampled.add(Math.round((slot * maxIndex) / (maxTicks - 1))) } - const activeIndex = activeId === null ? -1 : items.findIndex((item) => item.id === activeId) - if (activeIndex >= 0 && !sampled.has(activeIndex)) { - sampled.add(activeIndex) - // Drop the neighbour nearest the active tick, never an end: losing an end + const kept = new Set([0, maxIndex]) + for (const id of keepIds) { + const index = id === null ? -1 : items.findIndex((item) => item.id === id) + if (index >= 0) { + kept.add(index) + } + } + for (const keep of kept) { + if (sampled.has(keep)) { + continue + } + sampled.add(keep) + // Drop the nearest neighbour that is not itself kept, never an end: losing one // would make the rail claim the thread starts or stops somewhere it doesn't. let evict: number | null = null let evictDistance = Number.POSITIVE_INFINITY for (const index of sampled) { - if (index === activeIndex || index === 0 || index === maxIndex) { - continue - } - const distance = Math.abs(index - activeIndex) - if (distance < evictDistance) { + const distance = Math.abs(index - keep) + if (!kept.has(index) && distance < evictDistance) { evict = index evictDistance = distance } diff --git a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts index 4d8232303e9..f80e9977af6 100644 --- a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' import { + AGENT_SESSION_OUTLINE_PREVIEW_MAX_CHARS, + nativeChatTurnReplyPreviews, projectAgentSessionConversationOutline, truncateOutlinePreview } from '../../../../shared/agent-session-conversation-outline' @@ -11,9 +13,15 @@ import type { import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' import { DISPATCH_REJECTED_CANCELLED } from '../../../../shared/structured-agent-session-dispatch-rejection' import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import { buildNativeChatRailItems } from './native-chat-message-rail-items' +import { nativeChatRowsInDrawOrder } from '../../../../shared/native-chat-turn-grouping' +import { nativeChatTurnMembership } from '../../../../shared/native-chat-turn-membership' +import { + buildNativeChatRailItems, + nativeChatRailReplyPreview +} from './native-chat-message-rail-items' import { createNativeChatMessageListProjection } from './native-chat-message-list-projection' import type { NativeChatResolvedPrompt } from './native-chat-resolution-receipt' +import { nativeChatSubagentSections } from './native-chat-subagent-sections' import { projectNativeChatTaskListFrames } from './native-chat-task-list-frames' import { omitNativeChatThreadGoalRows } from './native-chat-thread-goal-rows' import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots' @@ -66,31 +74,53 @@ const JOURNAL: AgentJournalRenderItem[] = [ { ...user(12, [{ type: 'text', text: 'Observed earlier' }]), observedAt: 1_009.5 } ] -/** The renderer's own path from journal items to rail items, as the list runs it. */ -function loadedRailItems(items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[]) { - const projected = createNativeChatMessageListProjection()( +/** The renderer's own path from journal items to slots, as the list runs it. + * `openSections`: the subagent sections the reader has expanded. */ +function loadedSlots( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[], + openSections: readonly string[] = [] +) { + const projection = createNativeChatMessageListProjection()( projectStructuredAgentSessionMessages(items, [], submissions) - ).conversation - const messages = omitNativeChatThreadGoalRows(projectNativeChatTaskListFrames(projected)) - let turn: string | undefined - const turnKeys = messages.map((message) => { - if (message.role === 'user') { - turn = message.id - } - return turn - }) + ) + const messages = omitNativeChatThreadGoalRows( + projectNativeChatTaskListFrames(projection.conversation) + ) + const membership = nativeChatTurnMembership(messages, { items, submissions }) + const rows = { + messages: nativeChatRowsInDrawOrder(messages, membership.drawOrder), + turnKeys: nativeChatRowsInDrawOrder(membership.turnKeys, membership.drawOrder) + } const slots = buildNativeChatTranscriptSlots({ - messages, - turnKeys, - liveTurnKey: turn, + ...rows, + liveTurnKey: membership.liveTurnKey, receipts: new Map(), turnStatuses: { active: null, completedByTurn: {} }, turnDiffs: new Map(), expandedTurnKeys: new Set(), isWorking: false, - lifecycleWorking: false + lifecycleWorking: false, + subagentSections: nativeChatSubagentSections(messages, projection.subagentRows), + subagentChoices: { + sections: new Map(openSections.map((agentId) => [agentId, true])), + rosters: new Map() + } }) - return buildNativeChatRailItems(slots) + return { rows, slots } +} + +function loadedRailItems( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[], + openSections?: readonly string[] +) { + const { rows, slots } = loadedSlots(items, submissions, openSections) + const railItems = buildNativeChatRailItems(slots) + return railItems.map((item) => ({ + ...item, + reply: nativeChatRailReplyPreview(rows, railItems, item.id) + })) } describe('conversation outline parity with the loaded rail', () => { @@ -108,9 +138,10 @@ describe('conversation outline parity with the loaded rail', () => { outline.map((entry) => ({ id: entry.itemId, text: entry.preview, - hasImages: entry.imageCount > 0 + hasImages: entry.imageCount > 0, + reply: entry.reply ?? '' })) - ).toEqual(loaded.map(({ id, text, hasImages }) => ({ id, text, hasImages }))) + ).toEqual(loaded.map(({ id, text, hasImages, reply }) => ({ id, text, hasImages, reply }))) // Anti-vacuous: the folded tool result, the refused send, the harness turn and the empty // prompt were all dropped, and the recovered row sits where it was journalled. expect(outline.map((entry) => entry.itemId)).toEqual([ @@ -151,18 +182,153 @@ describe('conversation outline parity with the loaded rail', () => { ).toContain(agentJournalSubmissionKey('client-stopped')) }) - it('carries each entry its creation sequence and image count', () => { + // An imported tool result rides a user row mid-turn; it must not end the turn's reply. + it('reads a reply past a tool result folded into the turn, loaded or outlined', () => { + const journal = [ + user(1, [{ type: 'text', text: 'Fix it' }]), + row(2, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Looking.' }] }), + row(3, { kind: 'tool-call', name: 'Read', state: 'completed', input: { file_path: 'a.ts' } }), + user(4, [{ type: 'tool-result', output: 'file body' }]), + row(5, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Fixed.' }] }) + ] + expect(projectAgentSessionConversationOutline(journal, []).map((entry) => entry.reply)).toEqual( + ['Fixed.'] + ) + expect(loadedRailItems(journal, []).map((item) => item.reply)).toEqual(['Fixed.']) + }) + + // A subagent's rows are its own, not the conversation's: its prompt is no tick and + // its prose is no reply, whether or not its section is open. + it('never reads a subagent into the rail, as a tick or as a reply', () => { + const journal = [ + user(1, [{ type: 'text', text: 'Fix it' }]), + row(2, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Fixed.' }] }), + { ...user(3, [{ type: 'text', text: 'Child task' }]), agentId: 'sub-1' }, + { + ...row(4, { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'Child done.' }] + }), + agentId: 'sub-1' + } + ] + const outline = projectAgentSessionConversationOutline(journal, []) + expect(outline.map(({ itemId, reply }) => ({ id: itemId, reply }))).toEqual([ + { id: 'item-1', reply: 'Fixed.' } + ]) + // Anti-vacuous: with its section open, the child's prompt is a drawn row. + const open = loadedSlots(journal, [], ['sub-1']).slots + expect(open.some((slot) => slot.kind === 'message' && slot.message.id === 'item-3')).toBe(true) + for (const sections of [[], ['sub-1']]) { + expect( + loadedRailItems(journal, [], sections).map(({ id, reply }) => ({ id, reply })) + ).toEqual([{ id: 'item-1', reply: 'Fixed.' }]) + } + }) + + // A steer joins the running turn, so the host names that turn for it: a client + // that has not loaded the steer cannot otherwise tell whose reply it shares. + it("names the turn of a steer, and gives it that turn's reply", () => { + const inTurn = { turnScope: { kind: 'turn' as const, turnItemId: 'turn-1' } } + const journal: AgentJournalRenderItem[] = [ + { ...user(1, [{ type: 'text', text: 'Fix it' }]), ...inTurn }, + row( + 2, + { kind: 'turn', turnId: 'turn-1', state: 'completed', userItemId: 'item-1', startedAt: 0 }, + 'turn-1' + ), + { + ...row(3, { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'Looking.' }] + }), + ...inTurn + }, + { ...user(4, [{ type: 'text', text: 'Also the tests' }]), ...inTurn }, + { + ...row(5, { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'Done.' }] + }), + ...inTurn + } + ] + expect( + projectAgentSessionConversationOutline(journal, []).map(({ itemId, turnKey, reply }) => ({ + itemId, + turnKey, + reply + })) + ).toEqual([ + { itemId: 'item-1', turnKey: undefined, reply: 'Done.' }, + { itemId: 'item-4', turnKey: 'item-1', reply: 'Done.' } + ]) + }) + + it('carries each entry its creation sequence, image count and the reply to it', () => { const outline = projectAgentSessionConversationOutline(JOURNAL, [REJECTED]) expect(outline).toEqual([ - { itemId: 'item-1', sequence: 1, preview: 'Fix the parser', imageCount: 0 }, + { itemId: 'item-1', sequence: 1, preview: 'Fix the parser', imageCount: 0, reply: 'On it.' }, { itemId: 'item-5', sequence: 5, preview: '', imageCount: 1 }, - { itemId: 'item-9', sequence: 9, preview: 'Compare these', imageCount: 2 }, + { + itemId: 'item-9', + sequence: 9, + preview: 'Compare these', + imageCount: 2, + reply: 'Done.' + }, { itemId: 'item-11', sequence: 11, preview: 'Thanks', imageCount: 0 }, { itemId: 'item-12', sequence: 12, preview: 'Observed earlier', imageCount: 0 } ]) }) }) +describe('reply preview', () => { + const says = (text: string): Pick => ({ + role: 'assistant', + blocks: [{ type: 'text', text }] + }) + const asks: Pick = { + role: 'user', + blocks: [{ type: 'text', text: 'prompt' }] + } + + it("is the turn's last assistant prose, as plain text, whatever sits between", () => { + const fixed = + '## Fixed\n\n```ts\nconst hidden = 1\n```\n\n- The **parser** now reads `a.ts`.\n---' + expect( + nativeChatTurnReplyPreviews( + // A steer and a prompt queued for the next turn both land mid-turn. + [asks, says('Let me look.'), asks, asks, says(fixed), says('A later turn.')], + ['t1', 't1', 't1', 't2', 't1', 't2'] + ) + ).toEqual( + new Map([ + ['t1', 'Fixed The parser now reads a.ts.'], + ['t2', 'A later turn.'] + ]) + ) + }) + + it('keeps earlier prose when the last message is only code, and skips a turn with none', () => { + expect( + nativeChatTurnReplyPreviews( + [says('Here it is:'), says('```\ncode\n```'), asks], + ['t1', 't1', 't2'] + ) + ).toEqual(new Map([['t1', 'Here it is:']])) + }) + + it('is cut to the preview cap', () => { + expect(nativeChatTurnReplyPreviews([says('word '.repeat(200))], ['t1']).get('t1')).toHaveLength( + AGENT_SESSION_OUTLINE_PREVIEW_MAX_CHARS - 1 + ) + }) +}) + describe('outline preview truncation', () => { it('cuts to the cap without splitting a surrogate pair', () => { expect(truncateOutlinePreview('short', 10)).toBe('short') diff --git a/src/renderer/src/components/native-chat/native-chat-reader-scroll-input.ts b/src/renderer/src/components/native-chat/native-chat-reader-scroll-input.ts index a1f5fe3ee65..4f1ea0dbe14 100644 --- a/src/renderer/src/components/native-chat/native-chat-reader-scroll-input.ts +++ b/src/renderer/src/components/native-chat/native-chat-reader-scroll-input.ts @@ -67,6 +67,8 @@ function gestureLeavesEnd(gesture: ReaderGesture, transcript: HTMLElement): bool type ReaderScrollCallbacks = { onReaderScroll: () => void + /** The same gesture, for whatever was still moving the transcript to yield to it. */ + onTakeScroll?: () => void onLeaveEnd: () => void } @@ -135,8 +137,12 @@ export function nativeChatReaderScrollInputHandlers({ /** The transcript scroller's input props, and the wheel the rail overlaying it forwards. */ export function useNativeChatReaderScrollInput( scrollRef: React.RefObject, - { onReaderScroll, onLeaveEnd }: ReaderScrollCallbacks + { onReaderScroll: onScrollInput, onTakeScroll, onLeaveEnd }: ReaderScrollCallbacks ): { scrollerProps: NativeChatReaderScrollInputHandlers; railWheel: (deltaY: number) => void } { + const onReaderScroll = useCallback(() => { + onScrollInput() + onTakeScroll?.() + }, [onScrollInput, onTakeScroll]) const scrollerProps = useMemo( () => nativeChatReaderScrollInputHandlers({ onReaderScroll, onLeaveEnd }), [onLeaveEnd, onReaderScroll] diff --git a/src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx b/src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx index a1b12db0486..15f16c1be60 100644 --- a/src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx @@ -123,6 +123,7 @@ function useReviewTranscript() { showsTailRow: true, isVisible: true, alignToViewportTop: vi.fn(), + isAlignPending: () => false, scrollToEnd, restoreScrollOffset: vi.fn(), consumeProgrammaticScroll: () => false, diff --git a/src/renderer/src/components/native-chat/structured-agent-session-rail-outline.ts b/src/renderer/src/components/native-chat/structured-agent-session-rail-outline.ts index fd64b0a4ca6..1b445fa1b91 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-rail-outline.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-rail-outline.ts @@ -69,7 +69,9 @@ export function selectStructuredRailOutline( .map((entry) => ({ id: entry.itemId, text: entry.preview, - hasImages: entry.imageCount > 0 + hasImages: entry.imageCount > 0, + reply: entry.reply, + turnKey: entry.turnKey })) } views.set(outline, { edge: oldestLoadedSequence, count, view }) 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 796b11f6f2f..58e48d370ea 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 @@ -41,6 +41,9 @@ function slotsOf(messages: NativeChatMessage[]) { }) } +/** No test but the preview's reads the rows the slots came from. */ +const NO_ROWS = { messages: [], turnKeys: [] } + const CONVERSATION = [ message('u1', 'user'), message('a1', 'assistant'), @@ -56,7 +59,8 @@ describe('message rail hook', () => { ) const scrollRef = { current: document.createElement('div') } const { result, rerender, unmount } = renderHook( - ({ slots }) => useNativeChatMessageRail({ scrollRef, slots, virtualItems: [] }), + ({ slots }) => + useNativeChatMessageRail({ scrollRef, slots, turnRows: NO_ROWS, virtualItems: [] }), { initialProps: { slots: slotsOf(conversation) } } ) const initial = result.current @@ -75,6 +79,36 @@ describe('message rail hook', () => { derive.mockRestore() }) + it('keeps the previewed reply current while its turn streams', () => { + const scrollRef = { current: document.createElement('div') } + const streamed = (body: string) => { + const messages = [ + ...CONVERSATION, + { ...message('a3', 'assistant'), blocks: [{ type: 'text' as const, text: body }] } + ] + return { + slots: slotsOf(messages), + turnRows: { messages, turnKeys: ['u1', 'u1', 'u2', 'u2', 'u3', 'u3'] } + } + } + const { result, rerender, unmount } = renderHook( + ({ slots, turnRows }) => + useNativeChatMessageRail({ scrollRef, slots, turnRows, virtualItems: [] }), + { initialProps: streamed('Thinking') } + ) + expect(result.current.previewReply).toBe('') + act(() => result.current.onPreview('u3')) + expect(result.current.previewReply).toBe('Thinking') + rerender(streamed('Thinking it through')) + expect(result.current.previewReply).toBe('Thinking it through') + // Closed, the rail's state stops following the stream. + act(() => result.current.onPreview(null)) + const closed = result.current + rerender(streamed('Thinking it through, done')) + expect(result.current).toBe(closed) + unmount() + }) + it('removes its scroll listener and pending idle read on unmount', () => { vi.useFakeTimers() const element = document.createElement('div') @@ -83,6 +117,7 @@ describe('message rail hook', () => { useNativeChatMessageRail({ scrollRef: { current: element }, slots: slotsOf(CONVERSATION), + turnRows: NO_ROWS, virtualItems: [] }) ) @@ -103,7 +138,8 @@ describe('message rail hook', () => { const addListener = vi.spyOn(element, 'addEventListener') const { rerender } = renderHook( - ({ slots }) => useNativeChatMessageRail({ scrollRef, slots, virtualItems: [] }), + ({ slots }) => + useNativeChatMessageRail({ scrollRef, slots, turnRows: NO_ROWS, virtualItems: [] }), { initialProps: { slots: slotsOf(CONVERSATION) } } ) // Same prompts, new array identity — exactly what a re-render produces. @@ -119,7 +155,12 @@ describe('message rail hook', () => { const scrollRef = { current: element } const { result } = renderHook(() => - useNativeChatMessageRail({ scrollRef, slots: slotsOf(CONVERSATION), virtualItems: [] }) + useNativeChatMessageRail({ + scrollRef, + slots: slotsOf(CONVERSATION), + turnRows: NO_ROWS, + virtualItems: [] + }) ) expect(result.current.items.map((item) => item.id)).toEqual(['u1', 'u2', 'u3']) expect(result.current.visible).toBe(true) @@ -128,13 +169,14 @@ describe('message rail hook', () => { useNativeChatMessageRail({ scrollRef, slots: slotsOf([message('u1', 'user'), message('a1', 'assistant')]), + turnRows: NO_ROWS, virtualItems: [] }) ) expect(single.current.visible).toBe(true) const { result: empty } = renderHook(() => - useNativeChatMessageRail({ scrollRef, slots: [], virtualItems: [] }) + useNativeChatMessageRail({ scrollRef, slots: [], turnRows: NO_ROWS, virtualItems: [] }) ) expect(empty.current.visible).toBe(false) }) @@ -148,7 +190,13 @@ describe('message rail hook', () => { })) const loaded = [message('u1', 'user'), message('a1', 'assistant')] const { result } = renderHook(() => - useNativeChatMessageRail({ scrollRef, slots: slotsOf(loaded), virtualItems: [], outline }) + useNativeChatMessageRail({ + scrollRef, + slots: slotsOf(loaded), + turnRows: NO_ROWS, + virtualItems: [], + outline + }) ) expect(result.current.visible).toBe(true) expect(result.current.items.map((item) => item.id)).toEqual([ 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 b066401b23b..e5ca4dcc430 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 @@ -10,9 +10,13 @@ import { findActiveNativeChatRailItem } from './native-chat-active-rail-item' import { buildNativeChatRailItems, mergeNativeChatRailOutline, + NATIVE_CHAT_RAIL_ROOMY_TICKS, + nativeChatRailReplyPreview, + nativeChatRailTickCapacity, selectNativeChatRailTicks, type NativeChatRailItem, - type NativeChatRailOutlineEntry + type NativeChatRailOutlineEntry, + type NativeChatRailTurnRows } from './native-chat-message-rail-items' import type { NativeChatTranscriptSlot } from './native-chat-transcript-slots' import type { NativeChatTranscriptWindow } from './use-native-chat-transcript-window' @@ -20,31 +24,49 @@ import type { NativeChatTranscriptWindow } from './use-native-chat-transcript-wi /** Quiet period that counts as "stopped scrolling". */ export const NATIVE_CHAT_RAIL_IDLE_MS = 120 -/** Narrower than this the panel would cover the message it previews, so the whole - * rail stands down rather than half-working in a split pane. */ +/** Narrower than this the preview card would cover the transcript it sits beside, + * so the whole rail stands down rather than half-working in a split pane. */ export const NATIVE_CHAT_RAIL_MIN_WIDTH_PX = 512 export type NativeChatMessageRailState = { ticks: readonly NativeChatRailItem[] items: readonly NativeChatRailItem[] activeId: string | null + /** Light a tick now, ahead of the scroll that is taking the reader to it. */ + onActivate: (id: string) => void visible: boolean + /** The item whose preview is open, if any. */ + previewId: string | null + /** The item whose tick holds keyboard focus, if any. */ + focusId: string | null + /** The rail names the item it focused, or is about to: an item named here + * always has a tick, so arrow keys can walk every message, sampled or not. */ + onFocusItem: (id: string | null) => void + /** What the agent answered the previewed item with; empty when nothing is + * previewed or it has said nothing. Follows a reply that is still streaming. */ + previewReply: string + /** The rail names the item its preview shows, or null once it closes. */ + onPreview: (id: string | null) => void } export function useNativeChatMessageRail({ scrollRef, slots, + turnRows, virtualItems, outline = null }: { scrollRef: React.RefObject slots: readonly NativeChatTranscriptSlot[] + /** The rows the slots were built from, for the previewed turn's reply. */ + turnRows: NativeChatRailTurnRows virtualItems: NativeChatTranscriptWindow['virtualItems'] /** User messages older than the loaded window; null when none are known. */ outline?: readonly NativeChatRailOutlineEntry[] | null }): NativeChatMessageRailState { const [activeId, setActiveId] = useState(null) const [wideEnough, setWideEnough] = useState(true) + const [maxTicks, setMaxTicks] = useState(NATIVE_CHAT_RAIL_ROOMY_TICKS) const previousItemsRef = useRef([]) const loadedItems = buildNativeChatRailItems(slots, previousItemsRef.current) @@ -122,20 +144,34 @@ export function useNativeChatMessageRail({ } const observer = new ResizeObserver(() => { setWideEnough(element.clientWidth >= NATIVE_CHAT_RAIL_MIN_WIDTH_PX) + setMaxTicks(nativeChatRailTickCapacity(element.clientHeight)) }) observer.observe(element) return () => observer.disconnect() }, [scrollRef]) - const ticks = useMemo(() => selectNativeChatRailTicks({ items, activeId }), [items, activeId]) + const [previewId, setPreviewId] = useState(null) + const [focusId, setFocusId] = useState(null) + const ticks = useMemo( + () => selectNativeChatRailTicks({ items, keepIds: [activeId, focusId, previewId], maxTicks }), + [items, activeId, focusId, previewId, maxTicks] + ) + const previewReply = + previewId === null ? '' : nativeChatRailReplyPreview(turnRows, items, previewId) return useMemo( () => ({ ticks, items, activeId, - visible: wideEnough && items.length > 0 + onActivate: setActiveId, + visible: wideEnough && items.length > 0, + previewId, + previewReply, + onPreview: setPreviewId, + focusId, + onFocusItem: setFocusId }), - [ticks, items, activeId, wideEnough] + [ticks, items, activeId, wideEnough, previewId, previewReply, focusId] ) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-rail-jump-landing.ts b/src/renderer/src/components/native-chat/use-native-chat-rail-jump-landing.ts new file mode 100644 index 00000000000..4f580b9f6b9 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-rail-jump-landing.ts @@ -0,0 +1,50 @@ +// Carrying out a rail jump to a loaded message: scroll its row to the top, light +// its tick, release the request. +// +// Pinning the target mounts it in the same commit, so the row exists by the time +// layout runs. Routed through `scrollMessageToTop` rather than the virtualizer +// because that is what releases the bottom pin — without it the next streamed +// token snaps the reader straight back down. +// +// Serviced once per request, then released. `slots` takes a new identity on +// every render, so an effect that merely depended on it would re-scroll to this +// row forever; and a request left standing would keep its pin, which outranks +// the diff reveal that shares it. + +import { useLayoutEffect, useRef } from 'react' +import { + nativeChatSlotIndexOf, + type NativeChatTranscriptSlot +} from './native-chat-transcript-slots' + +export function useNativeChatRailJumpLanding({ + railJump, + slots, + scrollRef, + scrollMessageToTop, + onActivate, + release +}: { + railJump: { messageId: string; requestId: number } | null + slots: readonly NativeChatTranscriptSlot[] + scrollRef: React.RefObject + scrollMessageToTop: (element: HTMLElement) => void + onActivate: (id: string) => void + release: (request: null) => void +}): void { + const servicedRef = useRef(0) + useLayoutEffect(() => { + if (railJump === null || servicedRef.current === railJump.requestId) { + return + } + servicedRef.current = railJump.requestId + const index = nativeChatSlotIndexOf(slots, railJump.messageId) + const row = scrollRef.current?.querySelector(`[data-index="${index}"]`) + if (row) { + scrollMessageToTop(row) + } + // Lit now: the scroll's own position report only arrives once it settles. + onActivate(railJump.messageId) + release(null) + }, [onActivate, railJump, release, scrollMessageToTop, scrollRef, slots]) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.test.tsx index 389f9e9373d..3a1bd8a9238 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.test.tsx @@ -29,6 +29,7 @@ function TranscriptHarness({ showsTailRow: false, isVisible, alignToViewportTop: vi.fn(), + isAlignPending: () => false, scrollToEnd, restoreScrollOffset, consumeProgrammaticScroll: () => false, diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts b/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts index b2be67a4214..dda9f9c64f4 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts @@ -59,6 +59,7 @@ export function useNativeChatTranscriptScroll({ showsTailRow, isVisible, alignToViewportTop, + isAlignPending, scrollToEnd, restoreScrollOffset, consumeProgrammaticScroll, @@ -72,6 +73,8 @@ export function useNativeChatTranscriptScroll({ showsTailRow: boolean isVisible: boolean alignToViewportTop: (element: HTMLElement) => void + /** Whether a jump to a row is still travelling there. */ + isAlignPending: () => boolean scrollToEnd: () => void restoreScrollOffset: (offset: number) => void consumeProgrammaticScroll: (event: Event) => boolean @@ -117,13 +120,14 @@ export function useNativeChatTranscriptScroll({ following: followingRef.current, programmatic: consumeProgrammaticScroll(event.nativeEvent), geometry, - previousDistanceFromEnd: previousDistanceFromEndRef.current + previousDistanceFromEnd: previousDistanceFromEndRef.current, + settling: isAlignPending() }) reconcileReaderScroll(false) previousDistanceFromEndRef.current = distanceFromBottom(geometry) syncScrollState() }, - [consumeProgrammaticScroll, reconcileReaderScroll, scrollRef, syncScrollState] + [consumeProgrammaticScroll, isAlignPending, reconcileReaderScroll, scrollRef, syncScrollState] ) const scrollToEndWhenMeasurable = useCallback(() => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx index 257e507c697..70853c1b2c4 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx @@ -14,10 +14,26 @@ type VirtualizerOptionsCapture = { | null } +type SizeChangeProbe = { + scrollOffset: number + scrollDirection: 'forward' | 'backward' | null + itemSizeCache: Map +} +type SizeChangePolicyHolder = { + shouldAdjustScrollPositionOnItemSizeChange?: ( + item: { index: number; key: string; end: number }, + delta: number, + instance: SizeChangeProbe + ) => boolean +} + const virtualizerMock = vi.hoisted(() => { const scrollElement: { current: HTMLElement | null } = { current: null } + /** The instance the hook last received, where it sets its scroll-adjust policy. */ + const instance: { current: SizeChangePolicyHolder | null } = { current: null } return { options: { current: null } as VirtualizerOptionsCapture, + instance, getTotalSize: vi.fn(() => 0), getVirtualItems: vi.fn(() => []), measureElement: vi.fn(), @@ -25,6 +41,11 @@ const virtualizerMock = vi.hoisted(() => { resizeItem: vi.fn(), scrollElement, scrollToEnd: vi.fn(), + scrollToIndex: vi.fn(), + getOffsetForIndex: vi.fn<(index: number, align: string) => [number, string]>(() => [ + 0, + 'start' + ]), scrollToOffset: vi.fn(), takeSnapshot: vi.fn<() => VirtualItem[]>(() => []) } @@ -34,7 +55,12 @@ vi.mock('@tanstack/react-virtual', () => ({ elementScroll: vi.fn(), useVirtualizer: (options: VirtualizerOptionsCapture['current']) => { virtualizerMock.options.current = options - return { ...virtualizerMock, scrollElement: virtualizerMock.scrollElement.current } + const instance: SizeChangePolicyHolder & Record = { + ...virtualizerMock, + scrollElement: virtualizerMock.scrollElement.current + } + virtualizerMock.instance.current = instance + return instance } })) @@ -222,6 +248,64 @@ describe('native chat transcript virtualizer contract', () => { expect(virtualizerMock.scrollToOffset).not.toHaveBeenCalled() }) + // A jump owns the scroll until the view is at its row: layout under it can clamp + // the view elsewhere first, and no event in between marks its landing reliably. + it('holds a jump to a row pending until the view reaches it, or the reader takes the scroll', () => { + const scrollElement = document.createElement('div') + Object.defineProperties(scrollElement, { + clientHeight: { configurable: true, value: 100 }, + scrollHeight: { configurable: true, value: 1_000 } + }) + const row = document.createElement('div') + row.dataset.index = '3' + scrollElement.append(row) + virtualizerMock.scrollElement.current = scrollElement + virtualizerMock.getOffsetForIndex.mockReturnValue([400, 'start']) + const { result } = renderHook(() => + useNativeChatTranscriptWindow({ + scrollRef: { current: scrollElement }, + slots: [slot('message-0')], + isVisible: true, + revealIndex: -1 + }) + ) + expect(result.current.isAlignPending()).toBe(false) + + result.current.alignToViewportTop(row) + expect(virtualizerMock.scrollToIndex).toHaveBeenCalledWith( + 3, + expect.objectContaining({ align: 'start' }) + ) + scrollElement.scrollTop = 900 + expect(result.current.isAlignPending()).toBe(true) + scrollElement.scrollTop = 401 + expect(result.current.isAlignPending()).toBe(false) + + // A measured row growing above the view just after an upward scroll: left alone + // for a reader scrolling up, compensated under a jump resting where it landed. + const compensates = (heard = scrollElement.scrollTop): boolean | undefined => + virtualizerMock.instance.current?.shouldAdjustScrollPositionOnItemSizeChange?.( + { index: 1, key: 'row-1', end: 300 }, + 18, + { + scrollOffset: heard, + scrollDirection: 'backward', + itemSizeCache: new Map([['row-1', 100]]) + } + ) + expect(compensates()).toBe(true) + // Not from an offset the virtualizer has yet to hear of: an instant jump just + // wrote the view elsewhere, and a correction from the old offset would undo it. + expect(compensates(scrollElement.scrollTop + 500)).toBe(false) + + result.current.alignToViewportTop(row) + scrollElement.scrollTop = 700 + result.current.cancelAlign() + expect(result.current.isAlignPending()).toBe(false) + expect(compensates()).toBe(false) + expect(virtualizerMock.scrollToOffset).toHaveBeenLastCalledWith(700, { behavior: 'auto' }) + }) + // Rows drawn after the window (a message shown as not sent) still fill the container. it('follows the bottom of the container when no row is windowed', () => { const container = document.createElement('div') diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts index ea4cafa28e3..bd7ad5b1587 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts @@ -9,16 +9,21 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { elementScroll, useVirtualizer, type VirtualItem } from '@tanstack/react-virtual' import { createProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks' -import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion' import { NATIVE_CHAT_ROW_GAP_PX } from './native-chat-row-height-estimate' import { nativeChatPinnedRowIndexes, nativeChatTranscriptRange } from './native-chat-pinned-rows' import { nativeChatSlotKey, type NativeChatTranscriptSlot } from './native-chat-transcript-slots' +import { + nativeChatScrollOffsetWithin, + useNativeChatViewportAlign +} from './use-native-chat-viewport-align' /** Rows kept mounted past each edge of the viewport. Chat rows are tall and * arbitrarily expensive, so this buys smoothness by the row, not by the screen. */ export const NATIVE_CHAT_WINDOW_OVERSCAN = 6 const FALLBACK_ROW_PX = 48 +/** Past this gap the DOM's offset is a write the virtualizer has yet to hear of. */ +const UNSEEN_SCROLL_PX = 1 /** Retired keys are harmless to layout but otherwise accumulate for the pane's * lifetime as a capped transcript advances. Compact them well before the stale * entries become material compared with the live window. */ @@ -32,6 +37,11 @@ export type NativeChatTranscriptWindow = { measureRow: (node: HTMLElement | null) => void /** Scroll so this element's top meets the top of the viewport. */ alignToViewportTop: (element: HTMLElement) => void + /** Whether that scroll is still travelling: true until the view reaches its + * target, or another write or the reader takes the scroll from it. */ + isAlignPending: () => boolean + /** A reader gesture that scrolls the transcript ends a scroll still travelling. */ + cancelAlign: () => void /** Pin to the transcript's end. Through the virtualizer for the same reason * the reveal is: it owns the offset, and a write it does not recognise as its * own is a reconcile it will fight. Its last-item `end` target is the @@ -46,36 +56,6 @@ export type NativeChatTranscriptWindow = { reconcileReaderScroll: (isTakingOver: boolean) => void } -/** 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. - * Absolutely positioned windowed rows are placed with `top`, never a transform, - * so `offsetTop` stays true through the window as well. */ -export function nativeChatScrollOffsetWithin( - element: HTMLElement, - container: HTMLElement -): number | null { - let top = 0 - let node: HTMLElement | null = element - while (node !== null && node !== container) { - top += node.offsetTop - // A DOM without layout has no `offsetParent` at all; that ends the chain - // rather than walking into nothing, and the caller reads the null as - // "cannot place this yet". - const parent = node.offsetParent as HTMLElement | null | undefined - node = parent && typeof parent.offsetTop === 'number' ? parent : null - } - return node === container ? top : null -} - -/** Fallback when layout provides no offset-parent chain. */ -function rectOffsetWithin(element: HTMLElement, container: HTMLElement): number { - return ( - container.scrollTop + - element.getBoundingClientRect().top - - container.getBoundingClientRect().top - ) -} - export function useNativeChatTranscriptWindow({ scrollRef, slots, @@ -94,7 +74,6 @@ export function useNativeChatTranscriptWindow({ const readerTakeoverFrameRef = useRef(null) const previousMeasurementKeysRef = useRef | null>(null) const retiredMeasurementCountRef = useRef(0) - const alignBehavior = usePrefersReducedMotion() ? 'auto' : 'smooth' const pinned = useMemo( () => nativeChatPinnedRowIndexes({ count: slots.length, revealIndex }), [slots.length, revealIndex] @@ -158,12 +137,6 @@ export function useNativeChatTranscriptWindow({ } } }) - // Preserve rows above the reader, never compensate growth within the visible - // row — including its first measurement, which may follow an exact estimate. - virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => - item.end <= (instance.scrollOffset ?? 0) && - (instance.scrollDirection !== 'backward' || !instance.itemSizeCache.has(item.key)) - const finishReaderTakeover = useCallback(() => { if (readerTakeoverFrameRef.current !== null) { window.cancelAnimationFrame(readerTakeoverFrameRef.current) @@ -246,30 +219,34 @@ export function useNativeChatTranscriptWindow({ [readScrollMargin] ) - const alignToViewportTop = useCallback( - (element: HTMLElement) => { - const container = scrollRef.current - if (!container) { - return - } - const top = - nativeChatScrollOffsetWithin(element, container) ?? rectOffsetWithin(element, container) - finishReaderTakeover() - // Through the virtualizer so a scroll it is still reconciling — the jump - // that mounted this row in the first place — is replaced rather than raced. - if (virtualizer.scrollElement) { - virtualizer.scrollToOffset(top, { align: 'start', behavior: alignBehavior }) - } else { - const max = Math.max(0, container.scrollHeight - container.clientHeight) - const landing = Math.max(0, Math.min(top, max)) - if (container.scrollTop !== landing) { - programmaticScrollMarks.mark(landing) - } - container.scrollTo({ top, behavior: alignBehavior }) - } - }, - [alignBehavior, finishReaderTakeover, programmaticScrollMarks, scrollRef, virtualizer] - ) + const { alignToViewportTop, isAlignPending, endAlign, isAlignHeld } = useNativeChatViewportAlign({ + scrollRef, + virtualizer, + finishReaderTakeover, + programmaticScrollMarks + }) + + // Preserve rows above the reader, never compensate growth within the visible + // row — including its first measurement, which may follow an exact estimate. + // Nor the first measurement of a jump's target row: at the end its correction is + // clamped, and the virtualizer retries it after the jump starts, cancelling it. + // Nor while the virtualizer has not seen a scroll just written: it corrects from + // the offset it last heard, which would put the view back where the write left. + // A row re-measured during a backward scroll is left alone, so rows do not jump + // under a reader scrolling up; a jump that landed is not that reader, and a row + // growing above it would otherwise push its message down for good. + virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => { + const offset = instance.scrollOffset ?? 0 + const written = scrollRef.current?.scrollTop ?? offset + return ( + Math.abs(written - offset) <= UNSEEN_SCROLL_PX && + !(item.index === revealIndex && !instance.itemSizeCache.has(item.key)) && + item.end <= offset && + (instance.scrollDirection !== 'backward' || + !instance.itemSizeCache.has(item.key) || + isAlignHeld()) + ) + } const scrollToEnd = useCallback(() => { const container = scrollRef.current @@ -277,6 +254,7 @@ export function useNativeChatTranscriptWindow({ return } finishReaderTakeover() + endAlign() // With no windowed row it would resolve the end from its own rows' height, 0, though rows // drawn after the window (a message shown as not sent) still fill the container. if (virtualizer.scrollElement && slots.length > 0) { @@ -291,6 +269,7 @@ export function useNativeChatTranscriptWindow({ programmaticScrollMarks.mark(container.scrollTop) } }, [ + endAlign, finishReaderTakeover, isVisible, programmaticScrollMarks, @@ -306,6 +285,7 @@ export function useNativeChatTranscriptWindow({ return } finishReaderTakeover() + endAlign() if (virtualizer.scrollElement) { virtualizer.scrollToOffset(offset, { behavior: 'auto' }) return @@ -316,7 +296,7 @@ export function useNativeChatTranscriptWindow({ programmaticScrollMarks.mark(container.scrollTop) } }, - [finishReaderTakeover, isVisible, programmaticScrollMarks, scrollRef, virtualizer] + [endAlign, finishReaderTakeover, isVisible, programmaticScrollMarks, scrollRef, virtualizer] ) const consumeProgrammaticScroll = useCallback( @@ -344,6 +324,7 @@ export function useNativeChatTranscriptWindow({ ) { return } + endAlign() virtualizer.scrollToOffset(container.scrollTop, { behavior: 'auto' }) if (readerTakeoverFrameRef.current !== null) { return @@ -354,9 +335,17 @@ export function useNativeChatTranscriptWindow({ readerTakeoverFrameRef.current = null }) }, - [scrollRef, virtualizer] + [endAlign, scrollRef, virtualizer] ) + // The virtualizer still holds the jump's row as its target, and would write it + // again as rows measure: rebase it onto where the reader is. + const cancelAlign = useCallback(() => { + if (isAlignHeld()) { + reconcileReaderScroll(true) + } + }, [isAlignHeld, reconcileReaderScroll]) + return { virtualItems: virtualizer.getVirtualItems(), totalSize: virtualizer.getTotalSize(), @@ -364,6 +353,8 @@ export function useNativeChatTranscriptWindow({ sizerRef, measureRow: virtualizer.measureElement, alignToViewportTop, + isAlignPending, + cancelAlign, scrollToEnd, restoreScrollOffset, consumeProgrammaticScroll, 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 new file mode 100644 index 00000000000..2ec5ae1d2cb --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-viewport-align.ts @@ -0,0 +1,130 @@ +// Scrolling a transcript element to the top of the viewport, and knowing while +// that scroll is still travelling: rows it passes resize under it, so nothing +// short of the view reaching its target says the jump is over. + +import { useCallback, useRef } from 'react' +import type { Virtualizer } from '@tanstack/react-virtual' +import type { ProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks' +import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion' + +/** How close to its target a jump counts as arrived: sub-pixel and zoom rounding. */ +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. + * Absolutely positioned windowed rows are placed with `top`, never a transform, + * so `offsetTop` stays true through the window as well. */ +export function nativeChatScrollOffsetWithin( + element: HTMLElement, + container: HTMLElement +): number | null { + let top = 0 + let node: HTMLElement | null = element + while (node !== null && node !== container) { + top += node.offsetTop + // A DOM without layout has no `offsetParent` at all; that ends the chain + // rather than walking into nothing, and the caller reads the null as + // "cannot place this yet". + const parent = node.offsetParent as HTMLElement | null | undefined + node = parent && typeof parent.offsetTop === 'number' ? parent : null + } + return node === container ? top : null +} + +/** Fallback when layout provides no offset-parent chain. */ +function rectOffsetWithin(element: HTMLElement, container: HTMLElement): number { + return ( + container.scrollTop + + element.getBoundingClientRect().top - + container.getBoundingClientRect().top + ) +} + +export function useNativeChatViewportAlign({ + scrollRef, + virtualizer, + finishReaderTakeover, + programmaticScrollMarks +}: { + scrollRef: React.RefObject + virtualizer: Virtualizer + finishReaderTakeover: () => void + programmaticScrollMarks: ProgrammaticScrollMarks +}): { + alignToViewportTop: (element: HTMLElement) => void + isAlignPending: () => boolean + /** Another write, or the reader, took the scroll. */ + endAlign: () => void + /** Whether the view is still the jump's: travelling, or resting where it landed. */ + isAlignHeld: () => boolean +} { + const alignBehavior = usePrefersReducedMotion() ? 'auto' : 'smooth' + /** The row or offset `alignToViewportTop` scrolled to, until something else takes the scroll. */ + const alignRef = useRef<(({ index: number } | { offset: number }) & { arrived: boolean }) | null>( + null + ) + + const alignToViewportTop = useCallback( + (element: HTMLElement) => { + const container = scrollRef.current + if (!container) { + return + } + finishReaderTakeover() + const index = Number.parseInt(element.dataset.index ?? '', 10) + // Through the virtualizer so a scroll it is still reconciling — the jump + // that mounted this row in the first place — is replaced rather than raced. + if (virtualizer.scrollElement && !Number.isNaN(index)) { + // By index for a row: rows measured on the way move the target, and the + // virtualizer re-aims at an index where a fixed offset would land beside it. + alignRef.current = { index, arrived: false } + virtualizer.scrollToIndex(index, { align: 'start', behavior: alignBehavior }) + return + } + const top = + nativeChatScrollOffsetWithin(element, container) ?? rectOffsetWithin(element, container) + alignRef.current = { offset: top, arrived: false } + if (virtualizer.scrollElement) { + virtualizer.scrollToOffset(top, { align: 'start', behavior: alignBehavior }) + return + } + const max = Math.max(0, container.scrollHeight - container.clientHeight) + const landing = Math.max(0, Math.min(top, max)) + if (container.scrollTop !== landing) { + programmaticScrollMarks.mark(landing) + } + container.scrollTo({ top, behavior: alignBehavior }) + }, + [alignBehavior, finishReaderTakeover, programmaticScrollMarks, scrollRef, virtualizer] + ) + + // Read from where the view is, not from a landing event: a stale mark or a + // scroll end left by an earlier write can precede the jump's first frame. + const isAlignPending = useCallback(() => { + const pending = alignRef.current + const container = scrollRef.current + if (pending === null || pending.arrived || !container) { + return false + } + const target = + 'index' in pending + ? virtualizer.getOffsetForIndex(pending.index, 'start')?.[0] + : pending.offset + const max = Math.max(0, container.scrollHeight - container.clientHeight) + if ( + target === undefined || + Math.abs(container.scrollTop - Math.min(target, max)) <= ALIGN_ARRIVED_PX + ) { + pending.arrived = true + return false + } + return true + }, [scrollRef, virtualizer]) + + const endAlign = useCallback(() => { + alignRef.current = null + }, []) + const isAlignHeld = useCallback(() => alignRef.current !== null, []) + + return { alignToViewportTop, isAlignPending, endAlign, isAlignHeld } +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx index 9cf147aefb3..37d4ead942e 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx @@ -316,6 +316,7 @@ function renderRevealingController(sessionId = 'session-1', fence: number | null showsTailRow: false, isVisible, alignToViewportTop: vi.fn(), + isAlignPending: () => false, scrollToEnd, restoreScrollOffset, consumeProgrammaticScroll: () => false, diff --git a/src/shared/agent-session-conversation-outline.ts b/src/shared/agent-session-conversation-outline.ts index aff03ba7935..89063e5fbfa 100644 --- a/src/shared/agent-session-conversation-outline.ts +++ b/src/shared/agent-session-conversation-outline.ts @@ -11,12 +11,14 @@ import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' -import type { NativeChatBlock } from './native-chat-types' +import type { NativeChatBlock, NativeChatMessage } from './native-chat-types' import { deriveNativeChatRowContent, nativeChatRowRendersContent } from './native-chat-row-content' +import { iterateProcessOutputLines } from './process-output-field-scanner' import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' import { projectNativeChatTranscriptMessages } from './native-chat-transcript-projection' +import { nativeChatTurnMembership } from './native-chat-turn-membership' /** Previews are cut on the host: the rail clamps to two lines, so a whole prompt * would cross the wire only to be hidden. */ @@ -31,6 +33,12 @@ export type AgentSessionConversationOutlineEntry = { * message is images only, or when the host dropped previews to fit the reply. */ preview: string imageCount: number + /** The message that opened this one's turn, when this one did not: a steer sent + * into a running turn. Absent from a host that predates the field. */ + turnKey?: string + /** What the agent answered, as preview prose. Absent when it said nothing, from a + * host that predates the field, or when the host dropped replies to fit. */ + reply?: string } export type AgentSessionConversationOutline = { @@ -65,6 +73,79 @@ export function nativeChatUserMessagePreview( return preview } +const replies = new WeakMap() + +/** An assistant message as preview prose: fenced code, rules and markdown markers + * dropped, cut to the preview cap. */ +function assistantReplyProse(blocks: readonly NativeChatBlock[]): string { + const cached = replies.get(blocks) + if (cached !== undefined) { + return cached + } + const lines: string[] = [] + let length = 0 + let fenced = false + // Lazily, stopping at the cap: a reply can be many thousands of lines. + for (const raw of iterateProcessOutputLines(deriveNativeChatRowContent(blocks).markdown)) { + const line = raw.trim() + if (line.startsWith('```')) { + fenced = !fenced + continue + } + if (fenced) { + continue + } + const plain = line + .replace(/^(?:[#>]+|[-*+]|\d+[.)])\s+/, '') + .replace(/\*\*|`/g, '') + .replace(/\s+/g, ' ') + .trim() + if (!/[\p{L}\p{N}]/u.test(plain)) { + continue + } + lines.push(plain) + length += plain.length + 1 + if (length > AGENT_SESSION_OUTLINE_PREVIEW_MAX_CHARS) { + break + } + } + const prose = truncateOutlinePreview(lines.join(' '), AGENT_SESSION_OUTLINE_PREVIEW_MAX_CHARS) + replies.set(blocks, prose) + return prose +} + +/** What the agent answered each turn with, by turn key: the prose of the turn's + * last assistant row that has any. Turns are the transcript's own (`turnKeys` from + * `nativeChatTurnMembership`), so a steer or a queued prompt cannot cut a reply + * short. `only` limits the read to one turn. */ +export function nativeChatTurnReplyPreviews( + messages: readonly Pick[], + turnKeys: readonly (string | undefined)[], + only?: string +): Map { + const replies = new Map() + // Newest first, so a long agentic turn reads one message, not every aside before it. + for (let index = messages.length - 1; index >= 0; index -= 1) { + const turnKey = turnKeys[index] + if ( + turnKey === undefined || + (only !== undefined && turnKey !== only) || + messages[index].role !== 'assistant' || + replies.has(turnKey) + ) { + continue + } + const prose = assistantReplyProse(messages[index].blocks) + if (prose.length > 0) { + replies.set(turnKey, prose) + if (only !== undefined) { + break + } + } + } + return replies +} + /** Cuts on a code-point boundary so a clipped emoji never leaves a lone surrogate. */ export function truncateOutlinePreview(text: string, maxChars: number): string { if (text.length <= maxChars) { @@ -100,7 +181,9 @@ export function projectAgentSessionConversationOutline( // Unchanged on the wire: a desktop's rejected rows tick once their page is loaded. projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: false }) ) - for (const message of transcript) { + const { turnKeys } = nativeChatTurnMembership(transcript, { items, submissions }) + const replies = nativeChatTurnReplyPreviews(transcript, turnKeys) + for (const [index, message] of transcript.entries()) { const sequence = sequences.get(message.id) if ( sequence === undefined || @@ -111,11 +194,15 @@ export function projectAgentSessionConversationOutline( continue } const preview = nativeChatUserMessagePreview(message.blocks) + const turnKey = turnKeys[index] + const reply = turnKey === undefined ? undefined : replies.get(turnKey) entries.push({ itemId: message.id, sequence, preview: preview.text, - imageCount: preview.imageCount + imageCount: preview.imageCount, + ...(turnKey === undefined || turnKey === message.id ? {} : { turnKey }), + ...(reply === undefined ? {} : { reply }) }) } return entries diff --git a/tests/e2e/native-chat-message-rail.spec.ts b/tests/e2e/native-chat-message-rail.spec.ts index 16d01060224..1afd73ff193 100644 --- a/tests/e2e/native-chat-message-rail.spec.ts +++ b/tests/e2e/native-chat-message-rail.spec.ts @@ -1,10 +1,10 @@ -// Exercise the prompt picker and an off-window jump against a real transcript. +// Exercise the rail's per-tick preview and its jumps against a real transcript. import { randomUUID } from 'node:crypto' import { appendFileSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' -import type { Page } from '@stablyai/playwright-test' +import type { Locator, Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { waitForActivePaneHookDescriptor, waitForActiveTerminalManager } from './helpers/terminal' @@ -65,10 +65,16 @@ function claudeTranscript(rowCount: number, sessionId: string): string { const turn = Math.floor(index / 2) const body = isUser ? `Question ${turn}: what does the rail do when I scroll a long reply?` - : Array.from( - { length: 6 + (turn % 7) * 3 }, - (_unused, line) => `Answer paragraph ${line + 1} for turn ${turn}.` - ).join('\n\n') + : [ + // Headings and code draw at heights the row estimate misses, so a jump + // crosses rows that resize under it. + `## Answer for turn ${turn}`, + `\`\`\`ts\n${Array.from({ length: 2 + (turn % 5) * 4 }, (_unused, line) => `const line${line} = ${turn}`).join('\n')}\n\`\`\``, + ...Array.from( + { length: 6 + (turn % 7) * 3 }, + (_unused, line) => `Answer paragraph ${line + 1} for turn ${turn}.` + ) + ].join('\n\n') return JSON.stringify({ sessionId, uuid: `${sessionId}-${index}`, @@ -116,43 +122,49 @@ test.describe('Native chat message rail', () => { const rail = orcaPage.locator('[data-native-chat-rail]') await expect(rail).toBeVisible({ timeout: 30_000 }) - // Sampling cap: 30 user turns must not render 30 bars. - const tickCount = await rail.locator(':scope > span').count() - expect(tickCount).toBeGreaterThan(2) - expect(tickCount).toBeLessThanOrEqual(20) + // Every user turn has a tick while the rail fits: a tick is the only way to its message. + const ticks = rail.getByRole('button') + await expect(ticks).toHaveCount(TRANSCRIPT_ROWS / 2) await orcaPage.screenshot({ path: path.join(SHOT_DIR, 'rail-01-app.png'), animations: 'disabled' }) - await rail.hover() - const panel = orcaPage.getByRole('dialog', { name: 'Your messages' }) - await expect(panel).toBeVisible({ timeout: 10_000 }) - // The panel lists every user message, not the sampled ticks. - await expect(panel.getByRole('button').first()).toBeVisible() + // Hovering one tick previews that message and the reply to it, not the whole thread. + const tick = rail.getByRole('button', { name: 'Question 5:', exact: false }) + await tick.hover() + const preview = orcaPage.locator('[data-slot="hover-card-content"]') + await expect(preview).toBeVisible({ timeout: 10_000 }) + await expect(preview).toContainText('Question 5:') + await expect(preview).not.toContainText('Question 6:') + await expect(preview).toContainText('Answer for turn 5') + await expect(preview.locator('p')).toHaveCount(2) await orcaPage.screenshot({ - path: path.join(SHOT_DIR, 'rail-02-panel.png'), + path: path.join(SHOT_DIR, 'rail-02-preview.png'), animations: 'disabled' }) - // Exact, not `> ticks`: a panel that listed only the sampled ticks would - // still satisfy a loose bound at 20 vs 20. - const panelCount = await panel.getByRole('button').count() - expect(panelCount).toBe(TRANSCRIPT_ROWS / 2) - - // Activating the hover preview transfers focus into the prompt picker. - await rail.press('Enter') - await expect(panel.locator('button[data-current="true"]')).toBeFocused() - await panel.getByRole('button', { name: 'Question 5:', exact: false }).click() - await expect(panel).not.toBeVisible() - const target = transcriptWindow.locator('[data-index="10"]') const scroller = orcaPage.locator('[data-native-chat-scroll]') - const targetOffset = async (): Promise => { - const [row, viewport] = await Promise.all([target.boundingBox(), scroller.boundingBox()]) - return row && viewport ? Math.abs(row.y - viewport.y) : Number.POSITIVE_INFINITY + const offsetOf = (row: Locator) => async (): Promise => { + const [box, viewport] = await Promise.all([row.boundingBox(), scroller.boundingBox()]) + return box && viewport ? Math.abs(box.y - viewport.y) : Number.POSITIVE_INFINITY } - await expect.poll(targetOffset).toBeLessThan(4) + // One click lands the message at the top and lights its tick: from the end to a + // message near it, then across the thread, with rows resizing under each jump. + // Animated and instant jumps take different paths through the list. + for (const reducedMotion of ['reduce', 'no-preference'] as const) { + await orcaPage.emulateMedia({ reducedMotion }) + for (const turn of [22, 12, 5]) { + const jumped = rail.getByRole('button', { name: `Question ${turn}:`, exact: false }) + await jumped.click() + await expect + .poll(offsetOf(transcriptWindow.locator(`[data-index="${turn * 2}"]`))) + .toBeLessThan(4) + await expect(jumped).toHaveAttribute('aria-current', 'true') + } + } + const targetOffset = offsetOf(transcriptWindow.locator('[data-index="10"]')) for (let revision = 0; revision < 3; revision += 1) { const body = `Later streamed output ${revision}` @@ -170,6 +182,6 @@ test.describe('Native chat message rail', () => { await expect.poll(targetOffset).toBeLessThan(4) } - console.log(`[rail] ticks=${tickCount} panelRows=${panelCount} shots=${SHOT_DIR}`) + console.log(`[rail] shots=${SHOT_DIR}`) }) })
+ {railItemLabel(previewed)} +
{reply}