mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dacd0a50c | |||
| 3c73da4427 |
@@ -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 <AssistantMarkdown text={block.text} writing={streaming} />;
|
||||
if (block.kind === 'steps') return <AssistantToolSteps steps={block.steps} />;
|
||||
if (block.kind === 'places') return <AssistantPlaceStrip found={block.found} message={message} />;
|
||||
if (block.kind === 'weather') return <AssistantWeatherStrip days={block.days} />;
|
||||
if (block.kind === 'sources') return <AssistantSources sources={block.sources} />;
|
||||
if (block.kind === 'remembered') return <AssistantRemembered note={block.note} live={writing} />;
|
||||
if (block.kind === 'cards') return <AssistantCardBlock block={block} message={message} />;
|
||||
return <AssistantChangeCard items={block.items} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<MessagePrimitive.Root className="trek-assistant-enter flex flex-col gap-2.5 py-2">
|
||||
{blocks.map((block) => {
|
||||
if (block.kind === 'text') {
|
||||
return <AssistantMarkdown key={block.key} text={block.text} writing={writing && block === last} />;
|
||||
}
|
||||
if (block.kind === 'steps') return <AssistantToolSteps key={block.key} steps={block.steps} />;
|
||||
if (block.kind === 'places') {
|
||||
return <AssistantPlaceStrip key={block.key} found={block.found} message={message} />;
|
||||
}
|
||||
if (block.kind === 'weather') return <AssistantWeatherStrip key={block.key} days={block.days} />;
|
||||
if (block.kind === 'sources') return <AssistantSources key={block.key} sources={block.sources} />;
|
||||
if (block.kind === 'remembered') {
|
||||
return <AssistantRemembered key={block.key} note={block.note} live={writing} />;
|
||||
}
|
||||
if (block.kind === 'cards') return <AssistantCardBlock key={block.key} block={block} message={message} />;
|
||||
return <AssistantChangeCard key={block.key} items={block.items} />;
|
||||
})}
|
||||
{blocks.map((block, i) => (
|
||||
<Fragment key={block.key}>
|
||||
{i === tail && i > 0 && <span data-assistant-tail aria-hidden="true" className="-mb-2.5 block h-0" />}
|
||||
<AnswerBlock block={block} message={message} writing={writing} streaming={writing && block === last} />
|
||||
</Fragment>
|
||||
))}
|
||||
{/* Nothing below says the answer goes on: no words being written, no step running. */}
|
||||
{writing && last?.kind !== 'text' && last?.kind !== 'steps' && (
|
||||
<p role="status" className="m-0 text-caption">
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
useRevealAnswerTail(viewport, isRunning);
|
||||
|
||||
const phone = variant === 'fullscreen';
|
||||
const attach = useAssistantAttach(tripId);
|
||||
@@ -52,7 +56,11 @@ export default function AssistantThread({ tripTitle }: { tripTitle: string }) {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ThreadPrimitive.Viewport turnAnchor="top" className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
|
||||
<ThreadPrimitive.Viewport
|
||||
ref={viewport}
|
||||
turnAnchor="top"
|
||||
className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4"
|
||||
>
|
||||
<AuiIf condition={(s) => s.thread.isEmpty}>
|
||||
<AssistantWelcome suggestions={suggestions} canEdit={canEdit} withSuggestions={!phone} />
|
||||
{mentionable && (
|
||||
|
||||
@@ -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<AssistantChangeLabelKey, { labelKey: string }
|
||||
packing: { labelKey: 'assistant.change.label.packing' },
|
||||
breaks: { labelKey: 'assistant.change.label.breaks' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Where an answer's closing words and changes begin: the block after its last
|
||||
* step, strip or card. That end is what the thread brings into view once the
|
||||
* answer is written.
|
||||
*/
|
||||
export function tailStart(blocks: MessageBlock[]): number {
|
||||
let i = blocks.length;
|
||||
while (i > 0 && (blocks[i - 1].kind === 'text' || blocks[i - 1].kind === 'change')) i -= 1;
|
||||
return i;
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
useRevealAnswerTail(ref, running);
|
||||
return (
|
||||
<div ref={ref} data-testid="viewport" tabIndex={0}>
|
||||
<span data-assistant-tail />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const block = (kind: MessageBlock['kind']) => ({ kind, key: kind }) as MessageBlock;
|
||||
|
||||
describe('useRevealAnswerTail', () => {
|
||||
let scrollTo: ReturnType<typeof vi.fn>;
|
||||
|
||||
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(<Thread running />);
|
||||
lay(view.getByTestId('viewport'), { height: 2000 });
|
||||
view.rerender(<Thread running={false} />);
|
||||
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(<Thread running />);
|
||||
const viewport = view.getByTestId('viewport');
|
||||
lay(viewport);
|
||||
fireEvent.wheel(viewport, { deltaY: 120 });
|
||||
view.rerender(<Thread running={false} />);
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-AI-REVEAL-003: does nothing on the first render or while nothing runs', () => {
|
||||
const view = render(<Thread running={false} />);
|
||||
lay(view.getByTestId('viewport'));
|
||||
view.rerender(<Thread running={false} />);
|
||||
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(<Thread running />);
|
||||
const viewport = view.getByTestId('viewport');
|
||||
lay(viewport);
|
||||
fireEvent.wheel(viewport, { deltaY: 120 });
|
||||
view.rerender(<Thread running={false} />);
|
||||
view.rerender(<Thread running />);
|
||||
view.rerender(<Thread running={false} />);
|
||||
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 = '<span data-assistant-tail></span>';
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLElement | null>, 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]);
|
||||
}
|
||||
@@ -65,7 +65,7 @@
|
||||
"B/full/spread/0": "ac843317bb8d097c",
|
||||
"B/full/spread/1": "bf4b62404c36aa0e",
|
||||
"B/full/spread/2": "1af653e4557c90b9",
|
||||
"B/full/spread/3": "f766c7b6a3cad0fa",
|
||||
"B/full/spread/3": "b094a0b5aa67b2c0",
|
||||
"B/full/relayout/3": "c0f3d2e84826b420",
|
||||
"B/full/spread/4": "8563f01a381c3b8b",
|
||||
"B/full/spread/5": "2458025780d8cbf3",
|
||||
@@ -85,14 +85,14 @@
|
||||
"C/full/head": "0ffba0f701b7ccaa",
|
||||
"C/full/spread/0": "ee87e9e195e1edde",
|
||||
"C/full/spread/1": "bf4b62404c36aa0e",
|
||||
"C/full/spread/2": "973ed5d0aacb1063",
|
||||
"C/full/relayout/2": "973ed5d0aacb1063",
|
||||
"C/full/spread/2": "b40cfdb0e5bea780",
|
||||
"C/full/relayout/2": "b40cfdb0e5bea780",
|
||||
"C/full/spread/3": "8eb39a4e9d726f5e",
|
||||
"C/full/relayout/3": "8eb39a4e9d726f5e",
|
||||
"C/full/spread/4": "5a037193ef347c07",
|
||||
"C/full/relayout/4": "5a037193ef347c07",
|
||||
"C/full/spread/5": "71c9f776ce8ffc87",
|
||||
"C/full/relayout/5": "71c9f776ce8ffc87",
|
||||
"C/full/spread/5": "b6dedb14199ad559",
|
||||
"C/full/relayout/5": "b6dedb14199ad559",
|
||||
"C/full/spread/6": "0debcd2babd7ee6b",
|
||||
"C/full/relayout/6": "0debcd2babd7ee6b",
|
||||
"C/full/spread/7": "2458025780d8cbf3",
|
||||
|
||||
@@ -281,7 +281,7 @@ export const AUTO_TEMPLATES: readonly AutoTemplate[] = [
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'photo',
|
||||
photoId: 69,
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.42168673729754674,
|
||||
focalY: 0.23833399057977409,
|
||||
@@ -436,7 +436,7 @@ export const AUTO_TEMPLATES: readonly AutoTemplate[] = [
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'photo',
|
||||
photoId: 68,
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
@@ -457,7 +457,7 @@ export const AUTO_TEMPLATES: readonly AutoTemplate[] = [
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'photo',
|
||||
photoId: 69,
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
|
||||
Reference in New Issue
Block a user