diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 44a6d8b865dc..e4e9e97659a5 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -66,6 +66,7 @@ const NativeChatComposerPane = forwardRef[0]) => diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.growth-windowing.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.growth-windowing.test.tsx index f74ace43e501..e83f5a7ce31b 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.growth-windowing.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.growth-windowing.test.tsx @@ -453,6 +453,7 @@ describe('transcript follow ownership across growth and appends', () => { const scheduleSpy = vi.spyOn(window, 'requestAnimationFrame') const scrollToSpy = vi.spyOn(scroller, 'scrollTo') const readingAt = 2000 + fireEvent.wheel(scroller, { deltaY: -100 }) scroller.scrollTop = readingAt fireEvent.scroll(scroller) expect(scrollToSpy).toHaveBeenLastCalledWith({ behavior: 'auto', top: readingAt }) 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 858eb230c299..7fc08a3036b5 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 @@ -26,7 +26,19 @@ afterEach(cleanup) describe('revealing a diff from a turn rollup', () => { let restoreLayout = (): void => {} beforeEach(() => { - restoreLayout = stubLayout() + restoreLayout = stubLayout({ scrollGeometry: true }) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function ( + this: HTMLElement + ) { + const row = this.closest('[data-index]') + const scroller = this.closest('[data-native-chat-scroll]') + return new DOMRect( + 0, + row ? Number.parseFloat(row.style.top) - (scroller?.scrollTop ?? 0) : 0, + 100, + this.offsetHeight + ) + }) }) afterEach(() => { restoreLayout() @@ -79,7 +91,7 @@ describe('revealing a diff from a turn rollup', () => { ].map((item, index) => ({ ...item, sequence: index + 1 })) const scrollTo = vi.fn() vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo) - const { container } = render( + const transcript = (): React.JSX.Element => ( { expandSignal={false} /> ) + const { container, rerender } = render(transcript()) + const selectedTop = Number.parseFloat( + screen.getByText('Second prompt').closest('[data-index]')?.style.top ?? '' + ) + expect(selectedTop).toBeGreaterThan(0) fireEvent.click(screen.getByRole('button', { name: /1 changed file/ })) fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) scrollTranscript(container, 6000) @@ -94,14 +111,27 @@ describe('revealing a diff from a turn rollup', () => { scrollTo.mockClear() fireEvent.click(screen.getByRole('button', { name: 'Your messages' })) fireEvent.click(screen.getByRole('button', { name: 'Second prompt' })) - expect(scrollTo).toHaveBeenCalledTimes(1) + expect(scrollTo.mock.calls).toEqual([ + [{ top: 6000, behavior: 'auto' }], + [{ top: selectedTop, behavior: 'smooth' }] + ]) expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull() + scrollTo.mockClear() + rerender(transcript()) + rerender(transcript()) + expect(scrollTo).not.toHaveBeenCalled() scrollTranscript(container, 0) scrollTo.mockClear() fireEvent.click(screen.getByRole('button', { name: /1 changed file/ })) fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ })) - expect(scrollTo).toHaveBeenCalledTimes(1) + const diffTop = Number.parseFloat( + screen.getByText('Edited').closest('[data-index]')?.style.top ?? '' + ) + expect(Number.isFinite(diffTop)).toBe(true) + expect(scrollTo.mock.calls.filter(([options]) => options?.behavior === 'smooth')).toEqual([ + [{ top: diffTop, behavior: 'smooth' }] + ]) }) }) 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 9155f50f4215..492269cc82ea 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 @@ -435,6 +435,7 @@ describe('jumping from the rail while following the end', () => { await settle(10) // A reader parked above the end, so the button shows. act(() => { + fireEvent.wheel(scroller(), { deltaY: -100 }) scroller().scrollTop = 200 }) await settle(2) @@ -450,6 +451,29 @@ describe('jumping from the rail while following the end', () => { expect(screen.queryByText('prompt-5')).toBeNull() expect(distanceFromBottom()).toBe(0) }) + + it('jumps to a message without animating when the reader asks for reduced motion', async () => { + vi.spyOn(window, 'matchMedia').mockImplementation((query: string) => ({ + matches: query === '(prefers-reduced-motion: reduce)', + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false + })) + render() + await settle(10) + + fireEvent.click(screen.getByRole('button', { name: 'Your messages' })) + await frame() + fireEvent.click(screen.getByRole('button', { name: 'prompt-45' })) + await frame() + + // A smooth scroll would still be easing in here, a few pixels from where it started. + expect(Math.abs(rowOffsetFromViewportTop('prompt-45'))).toBeLessThanOrEqual(2) + }) }) describe('revealing a diff while a rail jump pages', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.reader-navigation.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.reader-navigation.test.tsx new file mode 100644 index 000000000000..91ef38b065a9 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.reader-navigation.test.tsx @@ -0,0 +1,435 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { createRef } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { routeNativeChatRootKeyToInput } from './native-chat-root-key-routing' +import { NativeChatMessageList } from './NativeChatMessageList' +import type { NativeChatMessageListHandle } from './use-native-chat-reveal-latest' +import { + BELOW_TRANSCRIPT_PX, + deliverResizes, + layout, + list, + marker, + ROW_PX, + scrollTranscript, + session, + stubLayout, + stubResizeObserver, + TRANSCRIPT_LENGTH +} from './native-chat-windowing-test-harness' + +afterEach(cleanup) + +function scrollRoot(container: HTMLElement): HTMLElement { + const scroller = container.querySelector('[data-native-chat-scroll]') + if (!scroller) { + throw new Error('no transcript scroll root') + } + return scroller +} + +/** Deliver resize and scroll events to a fixed point, as a painted frame would. */ +function paint(container: HTMLElement): void { + const scroller = scrollRoot(container) + let lastScrollTop = scroller.scrollTop + for (let pass = 0; pass < 12; pass += 1) { + let changed = false + act(() => { + changed = deliverResizes() + vi.advanceTimersByTime(16) + }) + if (scroller.scrollTop !== lastScrollTop) { + lastScrollTop = scroller.scrollTop + fireEvent.scroll(scroller) + changed = true + } + if (!changed && pass >= 2) { + return + } + } + throw new Error('the transcript never settled: resize and scroll kept moving it') +} + +function distanceFromBottom(container: HTMLElement): number { + const scroller = scrollRoot(container) + return scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop +} + +const transcript = Array.from({ length: TRANSCRIPT_LENGTH }, (_, index) => marker(index)) + +function userMessage(index: number, text: string): NativeChatMessage { + return { + id: `message-${index}`, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: index + 1, + source: 'transcript' + } +} + +function toolMessage(index: number): NativeChatMessage { + return { + id: `message-${index}`, + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'shell', input: { command: 'pwd' }, state: 'completed' }, + { type: 'tool-result', output: '/repo' } + ], + timestamp: index + 1, + source: 'transcript' + } +} + +function liveList(messages: NativeChatMessage[]): React.JSX.Element { + return +} + +/** The last `count` closed tool runs, with the row each sits in. */ +function closedRuns(count: number): { toggle: HTMLElement; rowIndex: number }[] { + return screen + .getAllByRole('button', { expanded: false }) + .slice(-count) + .map((toggle) => { + const row = toggle.closest('[data-index]') + if (!row) { + throw new Error('the tool run has no mounted row') + } + return { toggle, rowIndex: Number(row.dataset.index) } + }) +} + +/** Click a run's toggle and let its row re-measure, as a painted frame would. */ +function toggleRun(container: HTMLElement, toggle: HTMLElement, openRows: readonly number[]): void { + fireEvent.click(toggle) + const heights = Array.from({ length: Math.max(-1, ...openRows) + 1 }, () => ROW_PX) + for (const rowIndex of openRows) { + heights[rowIndex] = ROW_PX * 6 + } + layout.measuredRowHeights = heights + paint(container) +} + +function offersJumpToLatest(): boolean { + return screen.queryByRole('button', { name: 'Jump to latest' }) !== null +} + +describe('reader navigation', () => { + let restore: (() => void)[] = [] + beforeEach(() => { + vi.useFakeTimers() + restore = [stubLayout({ scrollGeometry: true, offsetChain: true }), stubResizeObserver()] + layout.belowTranscriptPx = BELOW_TRANSCRIPT_PX + layout.aboveTranscriptPx = 0 + }) + afterEach(() => { + for (const undo of restore.toReversed()) { + undo() + } + layout.measuredRowHeights = [] + vi.useRealTimers() + }) + + it('brings a reader who scrolled up to what they just sent, and follows its reply', () => { + const handle = createRef() + const view = (messages: NativeChatMessage[], isWorking: boolean) => ( + + ) + const { container, rerender } = render(view(transcript, false)) + paint(container) + scrollTranscript(container, 1000) + paint(container) + // Anti-vacuous: the reader is parked well above the end. + expect(distanceFromBottom(container)).toBeGreaterThan(1000) + + act(() => handle.current?.revealLatest()) + const sent = [...transcript, userMessage(TRANSCRIPT_LENGTH, 'follow-up question')] + rerender(view(sent, true)) + paint(container) + rerender(view([...sent, marker(TRANSCRIPT_LENGTH + 1)], true)) + paint(container) + + expect(distanceFromBottom(container)).toBe(0) + expect(screen.getByText('follow-up question')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Jump to latest' })).toBeNull() + }) + + it('stops following when the reader pages up from the focused transcript', () => { + const { container, rerender } = render(liveList(transcript)) + paint(container) + const scroller = scrollRoot(container) + scroller.focus() + fireEvent.keyDown(scroller, { key: 'PageUp' }) + // The browser pages the focused scroller; the key is all that says a reader moved it. + scroller.scrollTop -= scroller.clientHeight + fireEvent.scroll(scroller) + const pagedTo = scroller.scrollTop + rerender(liveList([...transcript, marker(TRANSCRIPT_LENGTH)])) + paint(container) + + expect(scroller.scrollTop).toBe(pagedTo) + expect(offersJumpToLatest()).toBe(true) + }) + + it('leaves a reader who scrolled up in place when a message arrives from another device', () => { + const { container, rerender } = render(liveList(transcript)) + paint(container) + scrollTranscript(container, 1000) + paint(container) + const readingAt = scrollRoot(container).scrollTop + + const delivered = [...transcript, userMessage(TRANSCRIPT_LENGTH, 'sent from the phone')] + rerender(liveList(delivered)) + paint(container) + rerender(liveList([...delivered, marker(TRANSCRIPT_LENGTH + 1)])) + paint(container) + + expect(scrollRoot(container).scrollTop).toBe(readingAt) + expect(offersJumpToLatest()).toBe(true) + }) + + it('keeps following when the reader opens a tool run at the live end', () => { + const toolIndex = TRANSCRIPT_LENGTH + const withTool = [...transcript, toolMessage(toolIndex)] + const { container, rerender } = render(liveList(withTool)) + paint(container) + // Anti-vacuous: following the end, so the run sits at the bottom of the view. + expect(distanceFromBottom(container)).toBe(0) + const scrollTopBeforeOpen = scrollRoot(container).scrollTop + + const [run] = closedRuns(1) + toggleRun(container, run.toggle, [run.rowIndex]) + expect(run.toggle).toHaveAttribute('aria-expanded', 'true') + rerender(liveList([...withTool, marker(toolIndex + 1), marker(toolIndex + 2)])) + paint(container) + + // The opened output and the streamed rows both pushed the end down, and the view went with it. + expect(scrollRoot(container).scrollTop).toBeGreaterThan(scrollTopBeforeOpen) + expect(distanceFromBottom(container)).toBe(0) + expect(offersJumpToLatest()).toBe(false) + }) + + describe('closing what the reader opened', () => { + const toolIndex = TRANSCRIPT_LENGTH + const withTool = [...transcript, toolMessage(toolIndex)] + const streamedOn = [...withTool, marker(toolIndex + 1), marker(toolIndex + 2)] + + it('stays where the reader scrolled to while it was open', () => { + const { container, rerender } = render(liveList(withTool)) + paint(container) + const [run] = closedRuns(1) + toggleRun(container, run.toggle, [run.rowIndex]) + const scrolledTo = scrollRoot(container).scrollTop - 100 + scrollTranscript(container, scrolledTo) + paint(container) + + toggleRun(container, run.toggle, []) + rerender(liveList(streamedOn)) + paint(container) + + expect(scrollRoot(container).scrollTop).toBe(scrolledTo) + expect(offersJumpToLatest()).toBe(true) + }) + + it('leaves a reader who had already scrolled away where they are', () => { + const { container, rerender } = render(liveList(withTool)) + paint(container) + const scrolledTo = scrollRoot(container).scrollTop - 100 + scrollTranscript(container, scrolledTo) + paint(container) + const [run] = closedRuns(1) + + toggleRun(container, run.toggle, [run.rowIndex]) + toggleRun(container, run.toggle, []) + rerender(liveList(streamedOn)) + paint(container) + + expect(scrollRoot(container).scrollTop).toBe(scrolledTo) + expect(offersJumpToLatest()).toBe(true) + }) + }) + + it('keeps following when an earlier turn expands under a pending tail pin', () => { + const messages = [ + userMessage(0, 'Earlier turn'), + ...Array.from({ length: 8 }, (_, i) => toolMessage(i + 1)), + marker(9), + userMessage(10, 'Current turn'), + marker(11) + ] + const { container } = render( + + ) + const lastRow = screen.getByText('marker-11').closest('[data-index]') + if (!lastRow) { + throw new Error('The current answer is not mounted') + } + layout.measuredRowHeights = Array.from( + { length: Number(lastRow.dataset.index) + 1 }, + (_, index) => (index === Number(lastRow.dataset.index) ? ROW_PX * 20 : ROW_PX) + ) + paint(container) + const scroller = scrollRoot(container) + const window = container.querySelector('[data-native-chat-window]') + const beforeSlots = window?.querySelectorAll('[data-index]').length ?? 0 + // A pin created outside the painted frames is still waiting to reconcile by numeric index. + layout.belowTranscriptPx += 20 + deliverResizes() + expect(scroller.scrollTop).toBeGreaterThan(0) + fireEvent.click(screen.getByRole('button', { name: 'Toggle turn details' })) + // Anti-vacuous: the expansion inserted rows before the tail the pin named. + expect(window?.querySelectorAll('[data-index]').length ?? 0).toBeGreaterThan(beforeSlots) + paint(container) + act(() => vi.advanceTimersByTime(160)) + paint(container) + + expect(distanceFromBottom(container)).toBe(0) + expect(screen.getByText('marker-11')).toBeInTheDocument() + }) + + it.each([false, true])( + 'keeps Space scrolling in the focused transcript (shift=%s)', + (shiftKey) => { + const insertTypedText = vi.fn(() => true) + const focus = vi.fn(() => true) + const composer = { + focus, + insertTypedText, + handlePasteEvent: vi.fn(), + pasteFromClipboard: vi.fn(), + contains: () => false + } + const { container } = render( +
routeNativeChatRootKeyToInput(event, composer, null)}> + {list(transcript)} +
+ ) + const root = scrollRoot(container) + root.focus() + fireEvent.keyDown(root, { key: ' ', shiftKey }) + expect(document.activeElement).toBe(root) + expect(insertTypedText).not.toHaveBeenCalled() + expect(focus).not.toHaveBeenCalled() + fireEvent.keyDown(root, { key: 'a' }) + expect(insertTypedText).toHaveBeenCalledWith('a') + } + ) + + it('makes the transcript a keyboard stop, so the scroll keys can reach it', () => { + const { container } = render(list(transcript)) + + expect(scrollRoot(container)).toHaveAttribute('tabindex', '0') + }) + + it('names the transcript as a region, so a focused scroll stop is announced', () => { + const { container } = render(list(transcript)) + + expect(screen.getByRole('region', { name: 'Conversation' })).toBe(scrollRoot(container)) + }) +}) + +describe('disclosure following across retained pane visibility', () => { + let visible = true + let restore: (() => void)[] = [] + beforeEach(() => { + visible = true + vi.useFakeTimers() + layout.belowTranscriptPx = BELOW_TRANSCRIPT_PX + layout.aboveTranscriptPx = 0 + layout.measuredRowHeights = [] + restore = [ + stubLayout({ scrollGeometry: true, offsetChain: true, isVisible: () => visible }), + stubResizeObserver() + ] + }) + afterEach(() => { + for (const undo of restore.toReversed()) { + undo() + } + layout.measuredRowHeights = [] + vi.useRealTimers() + }) + + function view(messages: NativeChatMessage[]): React.JSX.Element { + return ( + + ) + } + + it.each([false, true])( + 'follows passive growth after underflow disclosure (hide before settlement=%s)', + (hideBeforeSettlement) => { + const initial = [marker(0), toolMessage(1)] + const rendered = render(view(initial)) + paint(rendered.container) + const scroller = scrollRoot(rendered.container) + expect(scroller.scrollHeight).toBeLessThan(scroller.clientHeight) + fireEvent.click(screen.getByRole('button', { expanded: false })) + if (hideBeforeSettlement) { + visible = false + rendered.rerender(view(initial)) + act(() => vi.advanceTimersByTime(300)) + visible = true + rendered.rerender(view(initial)) + } + paint(rendered.container) + expect(scroller.scrollHeight).toBeLessThan(scroller.clientHeight) + expect(scroller.scrollTop).toBe(0) + const grown = [...initial, ...Array.from({ length: 200 }, (_, i) => marker(i + 10))] + rendered.rerender(view(grown)) + paint(rendered.container) + expect(scroller.scrollHeight).toBeGreaterThan(scroller.clientHeight + 1000) + expect(distanceFromBottom(rendered.container)).toBe(0) + expect(screen.getByText('marker-209')).toBeInTheDocument() + } + ) + + it.each([false, true])( + 'restores distant history after interrupted disclosure (growth while hidden=%s)', + (growWhileHidden) => { + const initial = transcript.with(20, toolMessage(20)) + const grown = [...initial, ...Array.from({ length: 200 }, (_, i) => marker(i + 1000))] + const rendered = render(view(initial)) + paint(rendered.container) + scrollTranscript(rendered.container, 900) + paint(rendered.container) + const scroller = scrollRoot(rendered.container) + expect(distanceFromBottom(rendered.container)).toBeGreaterThan(1000) + const readingAt = scroller.scrollTop + fireEvent.click(screen.getByRole('button', { expanded: false })) + visible = false + rendered.rerender(view(growWhileHidden ? grown : initial)) + paint(rendered.container) + act(() => vi.advanceTimersByTime(300)) + visible = true + rendered.rerender(view(growWhileHidden ? grown : initial)) + paint(rendered.container) + expect(scroller.scrollTop).toBe(readingAt) + expect(distanceFromBottom(rendered.container)).toBeGreaterThan(1000) + expect(offersJumpToLatest()).toBe(true) + rendered.rerender(view([...grown, marker(1200)])) + paint(rendered.container) + expect(scroller.scrollTop).toBe(readingAt) + expect(distanceFromBottom(rendered.container)).toBeGreaterThan(1000) + } + ) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 594c289882f6..6c9058213ba7 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -6,6 +6,7 @@ import { NativeChatJumpToLatest } from './NativeChatJumpToLatest' import { useNativeChatRowTypography } from './use-native-chat-row-typography' import { useCallback, useLayoutEffect, 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' import { useNativeChatTranscriptProjection } from './use-native-chat-transcript-projection' import { structuredQuestionTranscript } from './structured-agent-question-projection' @@ -46,7 +47,8 @@ import type { NativeChatRailOutlineEntry } from './native-chat-message-rail-items' import { useNativeChatRailHistoryJump } from './use-native-chat-rail-history-jump' -import { nativeChatReaderScrollInputHandlers } from './native-chat-reader-scroll-input' +import { useNativeChatReaderScrollInput } from './native-chat-reader-scroll-input' +import { useNativeChatMessageListHandle } from './use-native-chat-reveal-latest' import type { AgentJournalRenderItem, @@ -67,6 +69,7 @@ type NativeChatNavigationRequest = | { kind: 'rail'; messageId: string; requestId: number } export function NativeChatMessageList({ + ref, session, journalItems, journalSubmissions, @@ -86,6 +89,7 @@ export function NativeChatMessageList({ stopping = false, runtimeContext }: { + ref?: Parameters[0] session: NativeChatLiveSession journalItems?: readonly AgentJournalRenderItem[] /** With the items, what places each row in its turn (structured lane). */ @@ -222,19 +226,20 @@ export function NativeChatMessageList({ // mutually exclusive things to be doing. revealIndex: nativeChatSlotIndexOf(slots, railJump?.messageId ?? revealedDiff?.messageId) }) - const { showJump, onScroll, scrollToBottom, scrollMessageToTop } = useNativeChatTranscriptScroll({ - scrollRef, - contentRef, - itemCount: slots.length, - isWorking, - showsTailRow: tailRow !== null, - isVisible, - alignToViewportTop: transcriptWindow.alignToViewportTop, - scrollToEnd: transcriptWindow.scrollToEnd, - restoreScrollOffset: transcriptWindow.restoreScrollOffset, - consumeProgrammaticScroll: transcriptWindow.consumeProgrammaticScroll, - reconcileReaderScroll: transcriptWindow.reconcileReaderScroll - }) + const { showJump, onScroll, scrollToBottom, scrollMessageToTop, readerLeavesEnd } = + useNativeChatTranscriptScroll({ + scrollRef, + contentRef, + itemCount: slots.length, + isWorking, + showsTailRow: tailRow !== null, + isVisible, + alignToViewportTop: transcriptWindow.alignToViewportTop, + scrollToEnd: transcriptWindow.scrollToEnd, + restoreScrollOffset: transcriptWindow.restoreScrollOffset, + consumeProgrammaticScroll: transcriptWindow.consumeProgrammaticScroll, + reconcileReaderScroll: transcriptWindow.reconcileReaderScroll + }) const olderHistory = useNativeChatOlderHistoryAutoload({ scrollRef, historyKey: `${session.agent}:${session.sessionId ?? ''}:${session.olderHistoryGeneration}`, @@ -261,6 +266,7 @@ export function NativeChatMessageList({ const railHistoryJump = useNativeChatRailHistoryJump({ items: rail.items, sessionKey: `${session.agent}:${session.sessionId}`, + isVisible, loadEarlier, jumpToLoaded: requestRailJump }) @@ -269,18 +275,20 @@ export function NativeChatMessageList({ // otherwise land later and pull the reader away from where they just went. const selectRailItem = useCallback( (item: NativeChatRailItem) => { + beginNavigation() + readerLeavesEnd() if (item.slotIndex === null) { startHistoryJump(item) return } - beginNavigation() requestRailJump(item) }, - [beginNavigation, requestRailJump, startHistoryJump] + [beginNavigation, readerLeavesEnd, requestRailJump, startHistoryJump] ) const revealDiff = useCallback( (target: NativeChatDiffTarget) => { beginNavigation() + readerLeavesEnd() // A subagent's edit is revealed inside its section. if (target.subagentSections) { openSubagentSections(target.subagentSections) @@ -291,16 +299,17 @@ export function NativeChatMessageList({ target: { ...target, requestId: navigationSequence.current } }) }, - [beginNavigation, openSubagentSections] + [beginNavigation, readerLeavesEnd, openSubagentSections] ) const jumpToLatest = useCallback(() => { beginNavigation() scrollToBottom() }, [beginNavigation, scrollToBottom]) - const readerScrollInput = useMemo( - () => nativeChatReaderScrollInputHandlers(beginNavigation), - [beginNavigation] - ) + useNativeChatMessageListHandle(ref, jumpToLatest) + const readerScrollInput = useNativeChatReaderScrollInput(scrollRef, { + onReaderScroll: beginNavigation, + 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 @@ -361,12 +370,13 @@ export function NativeChatMessageList({
{showOlderHistory ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRail.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRail.tsx index f6429c969a91..945610e4c932 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRail.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRail.tsx @@ -111,7 +111,7 @@ export const NativeChatMessageRail = memo(function NativeChatMessageRail({ scrollRef: React.RefObject onSelect: (item: NativeChatRailItem) => void /** The reader scrolled the transcript through the rail. */ - onReaderScroll?: () => void + onReaderScroll?: (deltaY: number) => void /** A tick whose older history is still paging in. */ pendingId?: string | null }): React.JSX.Element | null { @@ -200,8 +200,11 @@ export const NativeChatMessageRail = memo(function NativeChatMessageRail({ : event.deltaMode === WHEEL_DELTA_PAGE ? element.clientHeight : 1 + if (event.ctrlKey) { + return + } + onReaderScroll?.(event.deltaY * scale) element.scrollTop += event.deltaY * scale - onReaderScroll?.() }} 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" > diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index e9b60f0da27a..973fc1c7866c 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -58,7 +58,7 @@ function controller( cards, queueCapable, pause, - resume: vi.fn(async () => {}), + resume: vi.fn(async () => false), resuming: false, steer: vi.fn(async () => {}), remove: vi.fn(async () => {}), diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index 4c236d87284e..2007bb1900e7 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -35,7 +35,7 @@ export function NativeChatQueuedMessageList({ const pause = cards.some((card) => card.hold === 'queue-paused') ? controller.pause : null // Only when focus was on the queue (a card, or Resume) — never pull it from wherever the user // moved on to. - const refocusAfter = (action: Promise): void => { + const refocusAfter = (action: Promise): void => { void action.then(() => { const active = document.activeElement if (!active || active === document.body || queueRef.current?.contains(active)) { diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.reveal-latest.test.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.reveal-latest.test.tsx new file mode 100644 index 000000000000..e1e708a6beb2 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.reveal-latest.test.tsx @@ -0,0 +1,142 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import { useImperativeHandle } from 'react' +import type { NativeChatLiveSession } from './use-native-chat-live-session' +import type { AskPrompt } from './native-chat-interactive-prompt' +import type { NativeChatInteractiveSend } from './use-native-chat-interactive-send' +import type { NativeChatMessageListHandle } from './use-native-chat-reveal-latest' + +// The transcript and composer are stubbed: the wire under test is which of this +// pane's sends tell the transcript to bring the latest into view. +type ComposerStubProps = { onSubmitted?: () => void } +const stubs = vi.hoisted( + (): { + session: NativeChatLiveSession | null + revealLatest: () => void + composer: ComposerStubProps | null + cardSend: NativeChatInteractiveSend | null + } => ({ session: null, revealLatest: vi.fn(), composer: null, cardSend: null }) +) +vi.mock('./use-native-chat-retained-session', () => ({ + useNativeChatRetainedSession: () => stubs.session +})) +vi.mock('./NativeChatMessageList', () => ({ + NativeChatMessageList: (props: { ref?: React.Ref }) => { + useImperativeHandle(props.ref, () => ({ revealLatest: stubs.revealLatest })) + return null + } +})) +vi.mock('./use-native-chat-interactive-send', () => ({ + useNativeChatInteractiveSend: (): NativeChatInteractiveSend => ({ + sendAnswer: () => ({ settleAfterMs: 0, waitsForVerifiedDelivery: false }), + sendRaw: () => {}, + sendRawVerified: async () => true, + cancelPending: () => {}, + cancelAsk: async () => true, + cancel: () => {} + }) +})) +// A pending question, so the pane mounts its card with the send it hands it. +vi.mock('./use-native-chat-interactive-prompt-card', () => ({ + useNativeChatInteractivePromptCard: () => ({ kind: 'question', prompt }) +})) +vi.mock('./NativeChatInteractiveCard', () => ({ + NativeChatInteractiveCard: (props: { send: NativeChatInteractiveSend }) => { + stubs.cardSend = props.send + return null + } +})) +vi.mock('./NativeChatComposer', () => ({ + NativeChatComposer: (props: ComposerStubProps) => { + stubs.composer = props + return null + } +})) + +const prompt: AskPrompt = { + questions: [{ question: 'Indent with?', multiSelect: false, options: [{ label: 'Tabs' }] }] +} + +const { NativeChatResolvedView } = await import('./NativeChatResolvedView') + +afterEach(() => { + cleanup() + vi.mocked(stubs.revealLatest).mockReset() + stubs.composer = null + stubs.cardSend = null +}) + +describe('NativeChatResolvedView sends', () => { + it('brings the latest into view for a composer send and a prompt answer, not a Stop', () => { + stubs.session = { + messages: [ + { + id: 'user-1', + role: 'user', + blocks: [{ type: 'text', text: 'Rename the module' }], + timestamp: 1, + source: 'transcript' + } + ], + status: 'ready', + sessionId: 'session-reveal', + agent: 'claude', + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: vi.fn(), + readPhase: 'ready' + } + render( + + ) + // Anti-vacuous: opening the pane reveals nothing by itself. + expect(stubs.revealLatest).not.toHaveBeenCalled() + + stubs.composer?.onSubmitted?.() + expect(stubs.revealLatest).toHaveBeenCalledOnce() + // A question answer and an approval option, then Stop and a dismissal: only the answers are sends. + stubs.cardSend?.sendAnswer(prompt, [{ indices: [0] }]) + expect(stubs.revealLatest).toHaveBeenCalledTimes(2) + // An empty answer writes nothing, so it moves nobody. + stubs.cardSend?.sendAnswer(prompt, [{ indices: [] }]) + expect(stubs.revealLatest).toHaveBeenCalledTimes(2) + void stubs.cardSend?.sendRawVerified('1') + expect(stubs.revealLatest).toHaveBeenCalledTimes(3) + stubs.cardSend?.cancel() + void stubs.cardSend?.cancelAsk() + expect(stubs.revealLatest).toHaveBeenCalledTimes(3) + }) + + it('leaves the reader where they are when the terminal is gone and nothing is written', () => { + render( + + ) + stubs.cardSend?.sendAnswer(prompt, [{ indices: [0] }]) + void stubs.cardSend?.sendRawVerified('1') + expect(stubs.cardSend).not.toBeNull() + expect(stubs.revealLatest).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index f6eb4a555166..dc98e60d11b2 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -9,6 +9,10 @@ import { useNativeChatRetainedSession } from './use-native-chat-retained-session import { isNativeChatTranscriptUnsettled } from './native-chat-live-session-contract' import { selectNativeChatViewState } from './native-chat-view-state' import { NativeChatMessageList } from './NativeChatMessageList' +import { + useNativeChatInteractiveSendReveal, + useNativeChatRevealLatest +} from './use-native-chat-reveal-latest' import { useNativeChatLaunchPromptDeliveryNotice } from './use-native-chat-launch-prompt-delivery-notice' import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer' import { useNativeChatFontSize } from './use-native-chat-font-size' @@ -116,7 +120,10 @@ export function NativeChatResolvedView({ const canSend = useNativeChatCanSend(targetPtyId) // Reuse the verified composer send path for interactive cards and composer // stop (Stop sends ESC, the agent-TUI interrupt key). - const interactiveSend = useNativeChatInteractiveSend(terminalTabId, paneKey, targetPtyId, agent) + const send = useNativeChatInteractiveSend(terminalTabId, paneKey, targetPtyId, agent) + // Every send this pane makes brings the latest into view, wherever the reader had scrolled. + const { messageListRef, revealLatest } = useNativeChatRevealLatest() + const interactiveSend = useNativeChatInteractiveSendReveal(send, targetPtyId, revealLatest) const [workingInterrupted, setWorkingInterrupted] = useState(false) const previousWorkingEpochRef = useRef(null) const rootRef = useRef(null) @@ -379,6 +386,7 @@ export function NativeChatResolvedView({ ) : ( { await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) }) + // The parked messages are the transcript here, and the reader may have scrolled up among them. + it('brings the latest into view when launch Retry relaunches behind a parked message', async () => { + mocks.mode = 'outbox' + mocks.launchLifecycle = 'failed' + render(sessionView()) + expect(composerSend()('still there?', [])).toBe(true) + await screen.findByRole('button', { name: 'Retry' }) + mocks.revealLatest.mockClear() + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + + expect(mocks.revealLatest).toHaveBeenCalledOnce() + }) + it('leaves a send into an unconfirmed start parked without relaunching', () => { mocks.mode = 'outbox' mocks.launchLifecycle = 'visibility-unknown' diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx index 7d5437b2e672..1299f43f2950 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.stopping.test.tsx @@ -137,7 +137,9 @@ describe("the chat pane while a person's Stop ends the turn", () => { expect(mocks.composerProps).toMatchObject({ isStopping: false }) expect(mocks.composerProps?.afterStop).toBeUndefined() - expect(mocks.composerProps?.steerQueued).toBe(mocks.queuedSteerNewest) + // The pane wraps steering so it also brings the latest into view; it still steers through the queue. + mocks.composerProps?.steerQueued?.() + expect(mocks.queuedSteerNewest).toHaveBeenCalledOnce() mocks.composerProps?.onStop?.() expect(mocks.stop).toHaveBeenCalledOnce() }) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 66127bd23478..50fb40590945 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -10,6 +10,7 @@ import type { NativeChatApprovalCardProps } from './NativeChatApprovalCard' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' import type { NativeChatQuestionCardProps } from './NativeChatQuestionCard' import type { NativeChatLaunchSeed } from './native-chat-composer-types' +import type { NativeChatMessageListHandle } from './use-native-chat-reveal-latest' import type { NativeChatFileLinkContext } from './native-chat-file-link' import type { NativeChatOlderPageResult } from './native-chat-pagination' import type { StructuredAgentSessionThreadGoal } from './use-structured-agent-session-thread-goal' @@ -94,6 +95,7 @@ export function seedOutbox(sessionId: string, entries: unknown[]): void { } type StructuredSessionMessageListProps = { + ref?: React.Ref allowFileUriLinks?: boolean isVisible?: boolean onLinkClick?: (...args: unknown[]) => void @@ -177,7 +179,8 @@ export function createStructuredSessionMocks() { queuedSteer: vi.fn<(messageId: string) => Promise>(async () => {}), queuedRemove: vi.fn<(messageId: string) => Promise>(async () => {}), queuedEdit: vi.fn<(messageId: string) => Promise>(async () => {}), - queuedSteerNewest: vi.fn<() => boolean>(() => false) + queuedSteerNewest: vi.fn<() => boolean>(() => false), + revealLatest: vi.fn<() => void>() } const moduleFactories = { @@ -326,6 +329,7 @@ export function createStructuredSessionMocks() { nativeChatMessageList: () => ({ NativeChatMessageList: (props: typeof mocks.messageListProps) => { mocks.messageListProps = props + useImperativeHandle(props?.ref, () => ({ revealLatest: mocks.revealLatest })) return } }), @@ -405,6 +409,9 @@ export function createStructuredSessionMocks() { mocks.loadingOlder = false mocks.olderHistoryGeneration = 0 mocks.loadOlder.mockReset() + mocks.revealLatest.mockReset() + mocks.queuedSteerNewest.mockReset() + mocks.queuedSteerNewest.mockReturnValue(false) } return { mocks, moduleFactories, resetStructuredSessionMocks } diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index a8761e9e9730..564e5555d1ed 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -6,6 +6,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import { useAppStore } from '@/store' +import { TooltipProvider } from '@/components/ui/tooltip' import { claudeGroupedQuestionPromptItems, legacySingleQuestionPromptItems @@ -31,6 +32,7 @@ vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard import { NativeChatStructuredSession } from './NativeChatStructuredSession' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' +import { seededEntry, seedOutbox } from './NativeChatStructuredSession.test-harness' import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store' describe('NativeChatStructuredSession', () => { @@ -370,7 +372,7 @@ describe('NativeChatStructuredSession', () => { expect(mocks.composerProps?.isWorking).toBe(true) }) - it('suppresses live turn activity for a pending approval but keeps background work visible', () => { + it('suppresses live turn activity for a pending approval but keeps background work visible', async () => { const approvalItems: AgentJournalRenderItem[] = [ { itemId: 'approval-item', @@ -423,6 +425,7 @@ describe('NativeChatStructuredSession', () => { kind: 'option', optionId: 'allow' }) + await waitFor(() => expect(mocks.revealLatest).toHaveBeenCalledOnce()) expect(mocks.messageListProps?.awaitingInput).toBe('shown') act(() => mocks.approvalCardProps?.onCancel?.()) @@ -719,4 +722,102 @@ describe('NativeChatStructuredSession', () => { answers: [{ questionId: 'q1', optionIds: [], other: 'Svelte' }] }) }) + + // The reader may have scrolled far up; what they just did has to come into view. + it('brings the latest into view at the press for the submits this pane makes', async () => { + mocks.promptItems = legacySingleQuestionPromptItems + const { rerender } = render( + + ) + // An answer reveals as it is pressed, before the host decides on it. + mocks.respond.mockReturnValueOnce(new Promise(() => {})) + mocks.questionCardProps?.onAnswer([{ indices: [1], other: '' }]) + expect(mocks.respond).toHaveBeenCalledOnce() + expect(mocks.revealLatest).toHaveBeenCalledOnce() + + mocks.promptItems = [] + rerender( + + ) + const steerQueued = mocks.composerProps?.steerQueued + const onSubmitted = mocks.composerProps?.structuredTransport?.onSubmitted + if (!steerQueued || typeof onSubmitted !== 'function') { + throw new Error('Structured composer was not wired') + } + mocks.revealLatest.mockClear() + + // Refused: nothing was steered, so nothing moves. + expect(steerQueued()).toBe(false) + expect(mocks.revealLatest).not.toHaveBeenCalled() + + // The composer's sends reveal through its transport. + onSubmitted() + expect(mocks.revealLatest).toHaveBeenCalledOnce() + mocks.queuedSteerNewest.mockReturnValue(true) + expect(steerQueued()).toBe(true) + expect(mocks.revealLatest).toHaveBeenCalledTimes(2) + }) + + it('reveals the latest when a delivery notice retries its message', async () => { + mocks.mode = 'outbox' + mocks.call.mockResolvedValue({ + ok: true, + value: { submission: { clientMessageId: 'op-head', dispatchState: 'accepted' } } + }) + seedOutbox('session-retry-reveal', [ + seededEntry('session-retry-reveal', 'op-head', 'first', 'unconfirmed') + ]) + render( + + ) + + fireEvent.click(await screen.findByRole('button', { name: /Retry/ })) + + expect(mocks.revealLatest).toHaveBeenCalledOnce() + await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + }) + + it("brings the latest into view when a queued card's Steer sends it now", () => { + mocks.queuedCards = [ + { messageId: 'draft-1', position: 1, text: 'Also check SSH', state: 'waiting', hold: 'turn' } + ] + render( + + + + ) + + fireEvent.click(screen.getByRole('button', { name: /Steer/ })) + + expect(mocks.queuedSteer).toHaveBeenCalledWith('draft-1') + expect(mocks.revealLatest).toHaveBeenCalledOnce() + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index dae1e61a27fb..2b8c7bce72b9 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -12,6 +12,7 @@ import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draf import { NativeChatEmptyState } from './NativeChatEmptyState' import { NativeChatLoadingCue } from './NativeChatLoadingCue' import { NativeChatMessageList } from './NativeChatMessageList' +import { useStructuredNativeChatSubmitReveal } from './use-structured-native-chat-submit-reveal' import { NativeChatQuestionCard } from './NativeChatQuestionCard' import { selectNativeChatViewState, structuredChatHistoryPhase } from './native-chat-view-state' import { useNativeChatComposerRevealFocus } from './use-native-chat-composer-reveal-focus' @@ -124,13 +125,15 @@ export function NativeChatStructuredSession( }), [controller, historyPhase, props.agent, props.sessionId] ) + const submits = useStructuredNativeChatSubmitReveal(controller, provisionalLaunch.retry) + const { retryDelivery, revealLatest } = submits const agentLabel = structuredAgentLabel(props.agent) const deliveryNotices = useStructuredAgentSessionDeliveryNotices({ outbox: controller.outbox, submissions: controller.submissions, journalItems: controller.journalItems, failedHere: controller.failedHere, - retry: controller.retry, + retry: retryDelivery, agentName: agentLabel }) // Nothing reads an unread history, so its pane stays blank beside the Retry line. @@ -183,16 +186,23 @@ export function NativeChatStructuredSession( ? (objective: string) => threadGoal.change({ kind: 'set', objective }) : null return { - send: (text: string, attachments: readonly { id: string; path: string }[]): boolean => - sendThroughRelaunch(() => - controller.send( + send: ( + text: string, + attachments: readonly { id: string; path: string }[] + ): boolean | 'queued' => { + let admission: boolean | 'queued' = false + const accepted = sendThroughRelaunch(() => { + admission = controller.send( text, attachments.map((attachment) => ({ path: attachment.path, previewUri: attachment.path })) ) - ), + return admission !== false + }) + return accepted ? admission : false + }, dispatchCommand: (text: string) => dispatchStructuredAgentSessionComposerCommand(text, { agent: props.agent, @@ -215,6 +225,7 @@ export function NativeChatStructuredSession( contextUsage: controller.contextUsage, worktreeId: ownerWorktreeId ?? undefined, onError: setComposerError, + onSubmitted: revealLatest, runtime: (props.target.kind === 'local' ? 'local' : 'remote') as 'local' | 'remote', sessionId: props.sessionId, runtimeEnvironmentId: @@ -227,6 +238,7 @@ export function NativeChatStructuredSession( props.agent, props.sessionId, props.target, + revealLatest, sendThroughRelaunch ]) @@ -268,6 +280,7 @@ export function NativeChatStructuredSession( {/* Host-held drafts, never transcript rows. Above the status area, so running shells and agents sit next to the composer. */} @@ -339,7 +352,7 @@ export function NativeChatStructuredSession( void controller.respond(prompt, { kind: 'option', optionId })} + onChoose={(optionId) => void submits.respond(prompt, { kind: 'option', optionId })} onCancel={cancelPrompt} shouldFocus={!promptsUnanswerable && props.isVisible && props.isFocusedGroup} onLinkClick={onLinkClick} @@ -372,7 +385,7 @@ export function NativeChatStructuredSession( return { questionId: question.id, optionIds, ...(other ? { other } : {}) } }) if (chosen.every((answer) => answer.optionIds.length > 0 || answer.other)) { - void controller.respond(prompt, { kind: 'answers', answers: chosen }) + void submits.respond(prompt, { kind: 'answers', answers: chosen }) } }} onCancel={cancelPrompt} @@ -388,6 +401,7 @@ export function NativeChatStructuredSession( agent={props.agent} isWorking={controller.canStop} {...stopControls.composer} + steerQueued={stopControls.stopping ? undefined : submits.queuedMessages.steerNewest} structuredTransport={structuredTransport} launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft: true }} /> 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 e782812e7dd3..6741369acfde 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 @@ -5,7 +5,8 @@ import { nextFollowingEnd, shouldShowJumpToLatest, NATIVE_CHAT_BOTTOM_THRESHOLD_PX, - NATIVE_CHAT_FOLLOW_REARM_PX + NATIVE_CHAT_FOLLOW_REARM_PX, + readerGestureLeavesEnd } from './native-chat-autoscroll' const atBottom = { scrollTop: 952, scrollHeight: 1000, clientHeight: 48 } @@ -76,8 +77,8 @@ describe('nextFollowingEnd', () => { expect(nextFollowingEnd({ ...following, programmatic: true, geometry: wellAway })).toBe(true) }) - it('treats an unmarked offset away from the end as the reader leaving', () => { - expect(nextFollowingEnd({ ...following, geometry: wellAway })).toBe(false) + it('keeps following through unmarked passive layout offsets', () => { + expect(nextFollowingEnd({ ...following, geometry: wellAway })).toBe(true) }) it.each([0, NATIVE_CHAT_FOLLOW_REARM_PX, 400])( @@ -98,7 +99,7 @@ describe('nextFollowingEnd', () => { it('lets the reader park just inside the near-bottom band', () => { expect(NATIVE_CHAT_FOLLOW_REARM_PX).toBeLessThan(NATIVE_CHAT_BOTTOM_THRESHOLD_PX) const parked = parkedAbove(NATIVE_CHAT_BOTTOM_THRESHOLD_PX - 1) - expect(nextFollowingEnd({ ...following, geometry: parked })).toBe(false) + expect(nextFollowingEnd({ ...following, following: false, geometry: parked })).toBe(false) expect(isNearBottom(parked)).toBe(true) expect(shouldShowJumpToLatest(false, parked)).toBe(false) }) @@ -142,3 +143,17 @@ describe('nextFollowingEnd', () => { expect(nextFollowingEnd({ ...following, geometry: parkedAbove(1.5) })).toBe(true) }) }) + +it('only detaches for gestures that can move away from the tail', () => { + expect(readerGestureLeavesEnd({ kind: 'wheel', deltaY: -10, zoom: false }, atBottom)).toBe(true) + expect(readerGestureLeavesEnd({ kind: 'wheel', deltaY: 10, zoom: false }, atBottom)).toBe(false) + expect(readerGestureLeavesEnd({ kind: 'wheel', deltaY: -10, zoom: true }, atBottom)).toBe(false) + expect(readerGestureLeavesEnd({ kind: 'key', key: 'Home' }, atBottom)).toBe(true) + expect(readerGestureLeavesEnd({ kind: 'key', key: 'End' }, atBottom)).toBe(false) + expect(readerGestureLeavesEnd({ kind: 'scrollbar-press' }, atBottom)).toBe(true) + expect(readerGestureLeavesEnd({ kind: 'touch-drag' }, parkedAbove(60))).toBe(true) + expect(readerGestureLeavesEnd({ kind: 'touch-drag' }, atBottom)).toBe(false) + expect(readerGestureLeavesEnd({ kind: 'wheel', deltaY: -10, zoom: false }, noOverflow)).toBe( + false + ) +}) 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 137d0ead61c7..ab07cc590a7f 100644 --- a/src/renderer/src/components/native-chat/native-chat-autoscroll.ts +++ b/src/renderer/src/components/native-chat/native-chat-autoscroll.ts @@ -52,21 +52,58 @@ export type FollowIntent = { previousDistanceFromEnd: number } -/** Whether the transcript should still follow the end after this offset. - * - * Application writes preserve intent even when their delayed events arrive - * after the end moved. Reader events detach away from the end and reattach on - * arriving at it — against the re-arm band, never the wider near-bottom one. - * Arriving takes closing on the end: a smooth scroll leaving the end marks only - * its landing, and its first unmarked frames still sit inside the band. Measured - * against the end, not the offset, so content shrinking under a detached reader - * and clamping them onto the end still reattaches. */ +/** Passive offsets preserve following; detached views rearm only while closing on the actual tail. */ export function nextFollowingEnd(intent: FollowIntent): boolean { - if (intent.programmatic) { + if (intent.following || intent.programmatic) { return intent.following } - if (!intent.following && distanceFromBottom(intent.geometry) > intent.previousDistanceFromEnd) { + if (distanceFromBottom(intent.geometry) > intent.previousDistanceFromEnd) { return false } return isNearBottom(intent.geometry, NATIVE_CHAT_FOLLOW_REARM_PX) } + +/** A reader input that can move the transcript, reduced to what decides following. */ +export type ReaderGesture = + | { kind: 'wheel'; deltaY: number; zoom: boolean } + | { kind: 'touch-drag' } + | { kind: 'scrollbar-press' } + | { kind: 'content-press' } + | { kind: 'key'; key: string; shift?: boolean } + +const KEYS_AWAY_FROM_END = new Set(['PageUp', 'Home', 'ArrowUp']) +const KEYS_TOWARD_END = new Set(['PageDown', 'End', 'ArrowDown']) + +/** The direction a gesture scrolls: -1 up, 1 down, 0 when it does not scroll. */ +export function readerGestureDirection(gesture: ReaderGesture): -1 | 0 | 1 { + if (gesture.kind === 'wheel') { + return gesture.zoom || gesture.deltaY === 0 ? 0 : gesture.deltaY < 0 ? -1 : 1 + } + if (gesture.kind === 'key') { + if (gesture.key === ' ') { + return gesture.shift ? -1 : 1 + } + return KEYS_AWAY_FROM_END.has(gesture.key) ? -1 : KEYS_TOWARD_END.has(gesture.key) ? 1 : 0 + } + return 0 +} + +/** Gestures that cannot leave the tail must not strand a following view without a scroll event. */ +export function readerGestureLeavesEnd(gesture: ReaderGesture, geometry: ScrollGeometry): boolean { + const contentAbove = geometry.scrollTop > 0 + const awayFromEnd = !isNearBottom(geometry, NATIVE_CHAT_FOLLOW_REARM_PX) + switch (gesture.kind) { + case 'wheel': + return readerGestureDirection(gesture) < 0 && contentAbove + case 'scrollbar-press': + return contentAbove + // A touch's direction is not observable; it leaves once its drag has carried the view away. + case 'touch-drag': + case 'content-press': + return awayFromEnd + case 'key': { + const direction = readerGestureDirection(gesture) + return direction < 0 ? contentAbove : direction > 0 && awayFromEnd + } + } +} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index caaed6916370..b06dd3a98428 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -19,7 +19,10 @@ export type NativeChatOptionPickerRequest = { export type NativeChatStructuredComposerTransport = { conversationCommands?: readonly AgentSessionConversationCommand[] - send: (text: string, attachments: readonly NativeChatComposerImageAttachment[]) => boolean + send: ( + text: string, + attachments: readonly NativeChatComposerImageAttachment[] + ) => boolean | 'queued' dispatchCommand: (text: string) => Promise optionsSurface: SessionOptionsSurface optionSnapshot: SessionOptionDescriptor[] @@ -33,6 +36,8 @@ export type NativeChatStructuredComposerTransport = { /** Present only where the host can set this session's goal. */ threadGoal?: { setObjective: (objective: string) => Promise } onError: (message: string | null) => void + /** A local send: brings the latest into view at the press, not when the host answers. */ + onSubmitted?: () => void runtime: 'local' | 'remote' /** The session behind this composer; a real user send relinquishes orchestration ownership. */ sessionId: string @@ -82,6 +87,8 @@ export type NativeChatComposerProps = { onSlashCommand?: (command: string, output?: string) => void /** The host's own answer to a command the agent must not see, or null to send it. */ answerCommandLocally?: NativeChatLocalCommandAnswer + /** Anything sent to the terminal: a message, a command or a session option. */ + onSubmitted?: () => void /** Picker-only agent commands continue in the hosted TUI after dispatch. */ onSwitchToTerminal?: () => void /** Reads the hosted TUI's current rendered screen when chat is entered. */ diff --git a/src/renderer/src/components/native-chat/native-chat-reader-scroll-input.test.tsx b/src/renderer/src/components/native-chat/native-chat-reader-scroll-input.test.tsx new file mode 100644 index 000000000000..66ec19bad30d --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-reader-scroll-input.test.tsx @@ -0,0 +1,87 @@ +// @vitest-environment happy-dom +import { fireEvent, render, cleanup } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { useRef } from 'react' +import { useNativeChatReaderScrollInput } from './native-chat-reader-scroll-input' + +afterEach(cleanup) +function harness() { + const onReaderScroll = vi.fn() + const onLeaveEnd = vi.fn() + let railWheel: (deltaY: number) => void = () => {} + const view = render() + function Harness() { + const ref = useRef(null) + const input = useNativeChatReaderScrollInput(ref, { onReaderScroll, onLeaveEnd }) + railWheel = input.railWheel + return ( +
+
+ output +
+