From 3c73da4427c121b17d2b50c2d18390e7dc2033ba Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 24 Oct 2026 16:33:14 +0200 Subject: [PATCH] fix(assistant): bring the end of a long answer into view once it is written A new question is held at the top of the view, so its answer reads from its first line. When the answer carries steps, a strip of places and cards, its closing words and the changes to apply ended below the view, and the user saw only the places found. Once the answer is written, the thread now scrolls to where those closing blocks begin, or to the bottom when they are shorter than the view, unless the user scrolled it by hand meanwhile. --- .../components/Assistant/AssistantMessage.tsx | 49 +++++--- .../components/Assistant/AssistantThread.tsx | 16 ++- .../components/Assistant/assistantParts.ts | 13 ++- .../Assistant/useRevealAnswerTail.test.tsx | 109 ++++++++++++++++++ .../Assistant/useRevealAnswerTail.ts | 61 ++++++++++ 5 files changed, 225 insertions(+), 23 deletions(-) create mode 100644 client/src/components/Assistant/useRevealAnswerTail.test.tsx create mode 100644 client/src/components/Assistant/useRevealAnswerTail.ts diff --git a/client/src/components/Assistant/AssistantMessage.tsx b/client/src/components/Assistant/AssistantMessage.tsx index 6f06cf3fd..c0fb3017e 100644 --- a/client/src/components/Assistant/AssistantMessage.tsx +++ b/client/src/components/Assistant/AssistantMessage.tsx @@ -1,7 +1,7 @@ import { ActionBarPrimitive, AuiIf, MessagePrimitive } from '@assistant-ui/react'; import type { AssistantMessage as StoredMessage } from '@trek/shared'; import { Check, Copy, FileText, LoaderCircle, Share2 } from 'lucide-react'; -import { useMemo } from 'react'; +import { Fragment, useMemo } from 'react'; import { useTranslation } from '../../i18n'; import { chatOf, useAssistantStore } from '../../store/assistantStore'; @@ -16,7 +16,7 @@ import AssistantShareDialog from './AssistantShareDialog'; import AssistantSources from './AssistantSources'; import AssistantToolSteps from './AssistantToolSteps'; import AssistantWeatherStrip from './AssistantWeatherStrip'; -import { messageBlocks } from './assistantParts'; +import { type MessageBlock, messageBlocks, tailStart } from './assistantParts'; import { useAssistantView } from './assistantViewContext'; import AssistantCardBlock from './cards/AssistantCardBlock'; import { useAssistantShare } from './useAssistantShare'; @@ -108,6 +108,28 @@ function Question({ message }: { message: StoredMessage }) { ); } +/** One block of an answer, in the shape its kind takes. */ +function AnswerBlock({ + block, + message, + writing, + streaming, +}: { + block: MessageBlock; + message: StoredMessage; + writing: boolean; + streaming: boolean; +}) { + if (block.kind === 'text') return ; + if (block.kind === 'steps') return ; + if (block.kind === 'places') return ; + if (block.kind === 'weather') return ; + if (block.kind === 'sources') return ; + if (block.kind === 'remembered') return ; + if (block.kind === 'cards') return ; + return ; +} + function Answer({ message }: { message: StoredMessage }) { const { t } = useTranslation(); const { tripId, variant } = useAssistantView(); @@ -119,25 +141,16 @@ function Answer({ message }: { message: StoredMessage }) { const share = useAssistantShare(tripId, message, writing); const last = blocks[blocks.length - 1]; const hasText = blocks.some((b) => b.kind === 'text'); + const tail = tailStart(blocks); return ( - {blocks.map((block) => { - if (block.kind === 'text') { - return ; - } - if (block.kind === 'steps') return ; - if (block.kind === 'places') { - return ; - } - if (block.kind === 'weather') return ; - if (block.kind === 'sources') return ; - if (block.kind === 'remembered') { - return ; - } - if (block.kind === 'cards') return ; - return ; - })} + {blocks.map((block, i) => ( + + {i === tail && i > 0 && + ))} {/* Nothing below says the answer goes on: no words being written, no step running. */} {writing && last?.kind !== 'text' && last?.kind !== 'steps' && (

diff --git a/client/src/components/Assistant/AssistantThread.tsx b/client/src/components/Assistant/AssistantThread.tsx index 1ea9ad88d..7d9992a1e 100644 --- a/client/src/components/Assistant/AssistantThread.tsx +++ b/client/src/components/Assistant/AssistantThread.tsx @@ -1,6 +1,6 @@ import { AuiIf, ThreadPrimitive } from '@assistant-ui/react'; import { ArrowDown, FileUp, MapPin } from 'lucide-react'; -import { useMemo } from 'react'; +import { useMemo, useRef } from 'react'; import { useTranslation } from '../../i18n'; import { chatOf, useAssistantStore } from '../../store/assistantStore'; @@ -15,13 +15,15 @@ import { useAssistantAttach } from './useAssistantAttach'; import { useFollowSelectedDay } from './useAssistantFocus'; import { joinDropZones, useAssistantPlaceDrop } from './useAssistantPlaceDrop'; import { useAssistantSuggestions } from './useAssistantSuggestions'; +import { useRevealAnswerTail } from './useRevealAnswerTail'; /** * The chat itself: the messages, and under them the line that says what went * wrong, the privacy note and the composer, held at the bottom while the * messages scroll. A new question moves up to the top of the view, so its - * answer reads from its first line instead of chasing the newest one. A - * booking file or a place of the trip can be dropped anywhere on it. + * answer reads from its first line instead of chasing the newest one, and once + * the answer is written its end comes into view. A booking file or a place of + * the trip can be dropped anywhere on it. */ export default function AssistantThread({ tripTitle }: { tripTitle: string }) { const { t } = useTranslation(); @@ -31,6 +33,8 @@ export default function AssistantThread({ tripTitle }: { tripTitle: string }) { const byId = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]); const { suggestions, canEdit } = useAssistantSuggestions(tripId); useFollowSelectedDay(tripId); + const viewport = useRef(null); + useRevealAnswerTail(viewport, isRunning); const phone = variant === 'fullscreen'; const attach = useAssistantAttach(tripId); @@ -52,7 +56,11 @@ export default function AssistantThread({ tripTitle }: { tripTitle: string }) { )} - + s.thread.isEmpty}> {mentionable && ( diff --git a/client/src/components/Assistant/assistantParts.ts b/client/src/components/Assistant/assistantParts.ts index 44a2b2b45..52e8cb718 100644 --- a/client/src/components/Assistant/assistantParts.ts +++ b/client/src/components/Assistant/assistantParts.ts @@ -61,7 +61,7 @@ export interface ChangeItem { /** The answer cards of one group, side by side. */ export type CardsBlock = { kind: 'cards'; key: string; group: string; entries: CardEntry[] }; -type MessageBlock = +export type MessageBlock = | { kind: 'text'; key: string; text: string } | { kind: 'steps'; key: string; steps: ToolStep[] } | { kind: 'change'; key: string; items: ChangeItem[] } @@ -464,3 +464,14 @@ export const CHANGE_LABELS: Record 0 && (blocks[i - 1].kind === 'text' || blocks[i - 1].kind === 'change')) i -= 1; + return i; +} diff --git a/client/src/components/Assistant/useRevealAnswerTail.test.tsx b/client/src/components/Assistant/useRevealAnswerTail.test.tsx new file mode 100644 index 000000000..5d93a5926 --- /dev/null +++ b/client/src/components/Assistant/useRevealAnswerTail.test.tsx @@ -0,0 +1,109 @@ +/** + * FE-AI-REVEAL-001 to FE-AI-REVEAL-007 + * + * Once an answer is written, its closing words and changes come into view, + * unless the user scrolled the thread themselves while it was written; and + * where that end begins in an answer's blocks. + */ +import { useRef } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { fireEvent, render } from '../../../tests/helpers/render'; +import { type MessageBlock, tailStart } from './assistantParts'; +import { tailScrollTop, useRevealAnswerTail } from './useRevealAnswerTail'; + +/** A viewport of 400px holding 1200px, its tail mark 900px down, scrolled to `scrollTop`. */ +function lay(viewport: HTMLElement, { tailAt = 900, scrollTop = 0, height = 1200 } = {}) { + Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 400 }); + Object.defineProperty(viewport, 'scrollHeight', { configurable: true, value: height }); + viewport.scrollTop = scrollTop; + viewport.getBoundingClientRect = () => ({ top: 100 }) as DOMRect; + const tail = viewport.querySelector('[data-assistant-tail]') as HTMLElement | null; + if (tail) tail.getBoundingClientRect = () => ({ top: 100 + tailAt - scrollTop }) as DOMRect; +} + +function Thread({ running }: { running: boolean }) { + const ref = useRef(null); + useRevealAnswerTail(ref, running); + return ( +

+ +
+ ); +} + +const block = (kind: MessageBlock['kind']) => ({ kind, key: kind }) as MessageBlock; + +describe('useRevealAnswerTail', () => { + let scrollTo: ReturnType; + + beforeEach(() => { + scrollTo = vi.fn(); + HTMLElement.prototype.scrollTo = scrollTo as unknown as HTMLElement['scrollTo']; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + cb(0); + return 1; + }); + }); + afterEach(() => vi.restoreAllMocks()); + + it('FE-AI-REVEAL-001: brings the end of the answer into view once it is written', () => { + const view = render(); + lay(view.getByTestId('viewport'), { height: 2000 }); + view.rerender(); + expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 888 })); + }); + + it('FE-AI-REVEAL-002: leaves the thread where the user scrolled it while the answer was written', () => { + const view = render(); + const viewport = view.getByTestId('viewport'); + lay(viewport); + fireEvent.wheel(viewport, { deltaY: 120 }); + view.rerender(); + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it('FE-AI-REVEAL-003: does nothing on the first render or while nothing runs', () => { + const view = render(); + lay(view.getByTestId('viewport')); + view.rerender(); + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it('FE-AI-REVEAL-004: a new answer reveals its end again after an earlier one was scrolled by hand', () => { + const view = render(); + const viewport = view.getByTestId('viewport'); + lay(viewport); + fireEvent.wheel(viewport, { deltaY: 120 }); + view.rerender(); + view.rerender(); + view.rerender(); + expect(scrollTo).toHaveBeenCalledTimes(1); + }); +}); + +describe('tailScrollTop', () => { + it('FE-AI-REVEAL-005: stops at the bottom when the end is shorter than the view, and never scrolls up', () => { + const viewport = document.createElement('div'); + viewport.innerHTML = ''; + lay(viewport, { tailAt: 1100 }); + expect(tailScrollTop(viewport)).toBe(800); + lay(viewport, { tailAt: 300, scrollTop: 500 }); + expect(tailScrollTop(viewport)).toBeNull(); + }); + + it('FE-AI-REVEAL-006: without a mark there is nothing to reveal', () => { + const viewport = document.createElement('div'); + lay(viewport); + expect(tailScrollTop(viewport)).toBeNull(); + }); +}); + +describe('tailStart', () => { + it('FE-AI-REVEAL-007: the end begins after the last step, strip or card', () => { + expect(tailStart([block('steps'), block('places'), block('text'), block('change')])).toBe(2); + expect(tailStart([block('text'), block('places')])).toBe(2); + expect(tailStart([block('text'), block('change')])).toBe(0); + expect(tailStart([])).toBe(0); + }); +}); diff --git a/client/src/components/Assistant/useRevealAnswerTail.ts b/client/src/components/Assistant/useRevealAnswerTail.ts new file mode 100644 index 000000000..ea098a436 --- /dev/null +++ b/client/src/components/Assistant/useRevealAnswerTail.ts @@ -0,0 +1,61 @@ +import { type RefObject, useEffect, useRef } from 'react'; + +import { prefersReducedMotion } from '../../utils/reducedMotion'; + +/** Room left above the tail, so its first line does not sit flush on the edge. */ +const TAIL_GAP = 12; +/** What reading the thread by hand looks like while the answer is written. */ +const OWN_SCROLL = ['wheel', 'touchmove', 'keydown'] as const; + +/** + * Where to scroll so the end of the latest answer is in view: the start of its + * closing text and proposed changes (the `data-assistant-tail` mark), or the + * very bottom when that tail is shorter than the view. Null when it is in view + * already, so the thread never moves up. + */ +export function tailScrollTop(viewport: HTMLElement): number | null { + const marks = viewport.querySelectorAll('[data-assistant-tail]'); + const tail = marks[marks.length - 1]; + if (!tail) return null; + const offset = tail.getBoundingClientRect().top - viewport.getBoundingClientRect().top + viewport.scrollTop; + const target = Math.min(offset - TAIL_GAP, viewport.scrollHeight - viewport.clientHeight); + return target > viewport.scrollTop + 1 ? target : null; +} + +/** + * A new question moves to the top of the view, so its answer reads from the + * first line. A long answer (steps, a strip of places found, cards) then ends + * below the view, and its closing words and the changes to apply were never + * seen. Once the answer is written, this brings that end into view, unless the + * user scrolled the thread themselves while it was written. + */ +export function useRevealAnswerTail(viewport: RefObject, isRunning: boolean): void { + const scrolledByHand = useRef(false); + const wasRunning = useRef(isRunning); + + useEffect(() => { + const el = viewport.current; + if (!el || !isRunning) return; + scrolledByHand.current = false; + const mark = () => { + scrolledByHand.current = true; + }; + for (const type of OWN_SCROLL) el.addEventListener(type, mark, { passive: true }); + return () => { + for (const type of OWN_SCROLL) el.removeEventListener(type, mark); + }; + }, [viewport, isRunning]); + + useEffect(() => { + const ended = wasRunning.current && !isRunning; + wasRunning.current = isRunning; + const el = viewport.current; + if (!ended || !el || scrolledByHand.current) return; + // A frame later, so the answer's last blocks have their height. + const frame = requestAnimationFrame(() => { + const top = tailScrollTop(el); + if (top !== null) el.scrollTo({ top, behavior: prefersReducedMotion() ? 'auto' : 'smooth' }); + }); + return () => cancelAnimationFrame(frame); + }, [viewport, isRunning]); +}