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