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]); +}