mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
fix(assistant): design and page cards keep the newest choice and draw every page they touch
An answer or a refusal no longer overwrites a newer page choice, and Apply waits for every page selection the chat has sent, also after the card was closed. A delete of several pages draws each page it takes, the receipt opens the first page the design wrote through the same rule the strip uses, and the vacay group's choices wait while a change runs.
This commit is contained in:
@@ -1,18 +1,20 @@
|
||||
/**
|
||||
* FE-AI-LAUNCH-001 to FE-AI-LAUNCH-006
|
||||
* FE-AI-LAUNCH-001 to FE-AI-LAUNCH-007
|
||||
*
|
||||
* The way in: hidden until the assistant can run, a button that opens and
|
||||
* closes the window, a dot for warnings in the plan not seen yet, and
|
||||
* ⌘K / Ctrl+K, which leaves a dialog alone.
|
||||
* closes the window, a dot for warnings in the plan not seen yet, a floating
|
||||
* button that breathes while a trip's answer is written in a closed window,
|
||||
* and ⌘K / Ctrl+K, which leaves a dialog alone.
|
||||
*/
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { AiStatusResponse } from '@trek/shared';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildAssignment, buildDay, buildPlace, buildTrip } from '../../../tests/helpers/factories';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { act, fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import { assistantChatKey } from '../../store/assistantChatKey';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import AssistantLauncher from './AssistantLauncher';
|
||||
import { isAssistantHotkey, useAssistantHotkey } from './useAssistantHotkey';
|
||||
@@ -84,6 +86,21 @@ describe('AssistantLauncher', () => {
|
||||
expect(useAssistantStore.getState().window.open).toBe(true);
|
||||
expect(screen.getByRole('button', { name: 'Assistant' }).querySelector('.bg-warning')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-LAUNCH-007: the floating button breathes for a trip answer being written, not for one still running in the Studio', () => {
|
||||
const studio = assistantChatKey({ kind: 'journey', journeyId: 3 });
|
||||
const chat = (key: number, isRunning: boolean) => ({ ...chatOf(useAssistantStore.getState(), key), isRunning });
|
||||
useAssistantStore.setState({ status: STATUS, byTrip: { [studio]: chat(studio, true), [5]: chat(5, false) } });
|
||||
render(<AssistantLauncher variant="floating" />);
|
||||
const button = screen.getByRole('button', { name: 'Assistant' });
|
||||
expect(button).not.toHaveAttribute('aria-busy');
|
||||
|
||||
act(() => useAssistantStore.setState((s) => ({ byTrip: { ...s.byTrip, [4]: chat(4, true) } })));
|
||||
expect(button).toHaveAttribute('aria-busy', 'true');
|
||||
// An open window shows the answer itself.
|
||||
act(() => useAssistantStore.getState().open());
|
||||
expect(button).not.toHaveAttribute('aria-busy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the shortcut', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Sparkles } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { assistantScopeOf } from '../../store/assistantChatKey';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import Tooltip from '../shared/Tooltip';
|
||||
import { useAssistantHints } from './useAssistantHints';
|
||||
@@ -34,9 +35,12 @@ export default function AssistantLauncher({
|
||||
const available = useAssistantStore((s) => s.status?.available === true);
|
||||
const open = useAssistantStore((s) => s.window.open && !s.window.minimized);
|
||||
const toggle = useAssistantStore((s) => s.toggle);
|
||||
// An answer still being written in a closed window: the floating button breathes until it is opened.
|
||||
// An answer still being written in a closed window: the floating button breathes until it is opened. Only a
|
||||
// trip's chat counts: an answer still running in the Studio is not one this window opens on.
|
||||
const busy = useAssistantStore((s) =>
|
||||
!s.window.open || s.window.minimized ? Object.values(s.byTrip).some((c) => c?.isRunning) : false
|
||||
!s.window.open || s.window.minimized
|
||||
? Object.entries(s.byTrip).some(([key, c]) => c.isRunning && assistantScopeOf(Number(key)).kind === 'trip')
|
||||
: false
|
||||
);
|
||||
const keys = useAssistantShortcutKeys();
|
||||
const hints = useAssistantHints(variant === 'floating' ? tripId : null, { withStays: false });
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
/**
|
||||
* FE-AI-BOOKPAGE-001 to FE-AI-BOOKPAGE-005
|
||||
* FE-AI-BOOKPAGE-001 to FE-AI-BOOKPAGE-006
|
||||
*
|
||||
* The photo book's change rows in the Studio's chat: the pages a change works
|
||||
* on named as the Studio names them, its facts as badges (new pages, where a
|
||||
* moved page lands, the layout, new photos, findings to check), the page
|
||||
* before and after drawn from the book open in the Studio, a thumbnail that
|
||||
* shows its page, each finding named once, and a receipt that opens the
|
||||
* Studio on the page.
|
||||
* before and after drawn from the book open in the Studio (each page of a
|
||||
* delete alone), a thumbnail that shows its page, each finding named once, a
|
||||
* receipt that opens the Studio on the page, and a discarded change struck
|
||||
* through with no page drawn.
|
||||
*/
|
||||
import { type AssistantApplied, type AssistantChangePreview, blankBook, type BookPageCard } from '@trek/shared';
|
||||
import {
|
||||
type AssistantApplied,
|
||||
type AssistantChangePreview,
|
||||
blankBook,
|
||||
blankSpread,
|
||||
type BookPageCard,
|
||||
} from '@trek/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -142,9 +149,21 @@ describe('the book page card', () => {
|
||||
{ spreadId: 'sp-9', index: 3, role: 'inner' as const, number: 2, photos: 1 },
|
||||
];
|
||||
const gone = card({ change: 'willDelete', pages, count: 2, ops: [], layout: null, photosAdded: 0, findings: [] });
|
||||
// The book holds a second double page (sp-9) after sp-3.
|
||||
const doc = book();
|
||||
const two = {
|
||||
...doc,
|
||||
spreads: [...doc.spreads.slice(0, 3), blankSpread('inner', () => 'sp-9'), ...doc.spreads.slice(3)],
|
||||
};
|
||||
useStudioStore.setState({ doc: two, activeSpread: 0 });
|
||||
inView(<AssistantChangeCard items={[item('pending', { ...gone, moreFindings: 0 }, 'removeBookPages')]} />);
|
||||
expect(screen.getByText('Double page 1, Double page 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delete pages from the book')).toBeInTheDocument();
|
||||
const thumbs = await screen.findAllByRole('button', { name: 'Show this page' });
|
||||
expect(thumbs).toHaveLength(2);
|
||||
expect(screen.queryByText('Before')).toBeNull();
|
||||
fireEvent.click(thumbs[1] as HTMLElement);
|
||||
expect(useStudioStore.getState().activeSpread).toBe(3);
|
||||
});
|
||||
|
||||
it('FE-AI-BOOKPAGE-004: a receipt shows the page as it is now and opens the Studio on it; a deleted page opens the book', async () => {
|
||||
@@ -190,4 +209,17 @@ describe('the book page card', () => {
|
||||
expect(await drawsIn(-9)).toBe(0);
|
||||
expect(await drawsIn(4)).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-AI-BOOKPAGE-006: a discarded edit is struck through and draws no page; a warning with no fault reads as a warning', async () => {
|
||||
useStudioStore.setState({ doc: book() });
|
||||
const warned = card({ findings: [{ code: 'low_dpi', severity: 'warn' }], moreFindings: 0 });
|
||||
inView(<AssistantChangeCard items={[item('discarded', warned, 'editBookPage')]} />);
|
||||
expect(screen.getByText('Double page 1')).toHaveClass('line-through');
|
||||
// The words sit in their badge, whose tone is a warning, not a fault.
|
||||
expect(screen.getByText('1 thing to check').parentElement).toHaveClass('text-warning');
|
||||
await act(async () => {
|
||||
await import('./BookPageThumbs');
|
||||
});
|
||||
expect(screen.queryByRole('button', { name: 'Show this page' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,23 +4,25 @@ import { useMemo } from 'react';
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { useStudioStore } from '../../../../store/studioStore';
|
||||
import { SpreadThumb } from '../../../Studio/SpreadThumb';
|
||||
import { useStudioPageHint } from '../../../Studio/useStudioPageHint';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { useAssistantScope } from '../../assistantViewContext';
|
||||
import { pageViewOf } from './bookCardModel';
|
||||
import { type BookPageView, pageViewOf } from './bookCardModel';
|
||||
|
||||
/**
|
||||
* The page of a book change drawn small, from the book open in the Studio:
|
||||
* while the change waits, the page as it is and as the change would leave it;
|
||||
* once applied, the page as it is now. Each is the editor's own view under a
|
||||
* scale, so the card can never look different from the page. Pressing one
|
||||
* shows that page in the Studio. Nothing where the Studio does not hold the
|
||||
* page, outside the Studio or on another book.
|
||||
* while the change waits, the page as it is and as the change would leave it
|
||||
* (a delete draws each page it takes); once applied, the page as it is now.
|
||||
* Each is the editor's own view under a scale, so the card can never look
|
||||
* different from the page. Pressing one shows that page in the Studio.
|
||||
* Nothing where the Studio does not hold the page, outside the Studio or on
|
||||
* another book.
|
||||
*/
|
||||
|
||||
/** A thumbnail's height in pixels; its width follows the page. */
|
||||
const THUMB_HEIGHT = 72;
|
||||
|
||||
/** One page drawn small; pressing it shows the page in the Studio. */
|
||||
/** One page drawn small; pressing it shows the page in the Studio, pointing at it outlines it in the page rail. */
|
||||
function PageThumb({
|
||||
spread,
|
||||
page,
|
||||
@@ -33,11 +35,13 @@ function PageThumb({
|
||||
onShow: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const hint = useStudioPageHint(spread.id);
|
||||
const wide = spread.role === 'inner' ? page.pageWidth * 2 : page.pageWidth;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShow}
|
||||
{...hint}
|
||||
aria-label={t('assistant.card.book.showPage')}
|
||||
className="flex flex-col items-start gap-0.5 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
@@ -52,34 +56,44 @@ function PageThumb({
|
||||
);
|
||||
}
|
||||
|
||||
export default function BookPageThumbs({ card, item }: { card: BookPageCard; item: ChangeItem }) {
|
||||
/** One page of the change: before and after it, "Before" and "After" under them when both are drawn. */
|
||||
function PageViewThumbs({ view, page, onShow }: { view: BookPageView; page: BookPageSetup; onShow: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const scope = useAssistantScope();
|
||||
const doc = useStudioStore((s) => s.doc);
|
||||
const setActiveSpread = useStudioStore((s) => s.setActiveSpread);
|
||||
const view = useMemo(() => pageViewOf(card, doc, item.state), [card, doc, item.state]);
|
||||
// The Studio's book is this card's only in this journey's chat; anywhere else it may be another or an old copy.
|
||||
const ownBook = scope.kind === 'journey' && scope.journeyId === card.journeyId;
|
||||
if (!ownBook || !view || !doc) return null;
|
||||
const show = () => setActiveSpread(view.index);
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<>
|
||||
{view.before && (
|
||||
<PageThumb
|
||||
spread={view.before}
|
||||
page={doc.page}
|
||||
page={page}
|
||||
caption={view.after ? t('assistant.card.book.before') : null}
|
||||
onShow={show}
|
||||
onShow={onShow}
|
||||
/>
|
||||
)}
|
||||
{view.after && (
|
||||
<PageThumb
|
||||
spread={view.after}
|
||||
page={doc.page}
|
||||
page={page}
|
||||
caption={view.before ? t('assistant.card.book.after') : null}
|
||||
onShow={show}
|
||||
onShow={onShow}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BookPageThumbs({ card, item }: { card: BookPageCard; item: ChangeItem }) {
|
||||
const scope = useAssistantScope();
|
||||
const doc = useStudioStore((s) => s.doc);
|
||||
const setActiveSpread = useStudioStore((s) => s.setActiveSpread);
|
||||
const views = useMemo(() => pageViewOf(card, doc, item.state), [card, doc, item.state]);
|
||||
// The Studio's book is this card's only in this journey's chat; anywhere else it may be another or an old copy.
|
||||
const ownBook = scope.kind === 'journey' && scope.journeyId === card.journeyId;
|
||||
if (!ownBook || views.length === 0 || !doc) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
{views.map((view) => (
|
||||
<PageViewThumbs key={view.index} view={view} page={doc.page} onShow={() => setActiveSpread(view.index)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*
|
||||
* The pure side of the book's page card: a page named as the Studio names
|
||||
* it, a layout and a finding by their words, the findings worth a look, the
|
||||
* spreads the card draws from the book on screen for each state of a change,
|
||||
* and where an applied change opens the Studio.
|
||||
* spreads the card draws from the book on screen for each state of a change
|
||||
* (each page of a delete alone), and where an applied change opens the Studio.
|
||||
*/
|
||||
import { blankBook, type BookCardPage, type BookPageCard } from '@trek/shared';
|
||||
import { blankBook, blankSpread, type BookCardPage, type BookPageCard } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
@@ -90,9 +90,9 @@ describe('bookCardModel', () => {
|
||||
expect(findingLabel({ code: 'low_dpi', severity: 'warn' }, t)).toBe('assistant.card.book.finding.low_dpi');
|
||||
});
|
||||
|
||||
it('FE-AI-BOOKPAGE-013: a waiting edit or cover draws before and after; a move alone; new pages, other books and failures none', () => {
|
||||
it('FE-AI-BOOKPAGE-013: a waiting edit or cover draws before and after; a move alone, a delete each page alone; new pages, other books and failures none', () => {
|
||||
const doc = book();
|
||||
const edit = pageViewOf(card(), doc, 'pending');
|
||||
const [edit] = pageViewOf(card(), doc, 'pending');
|
||||
expect(edit?.index).toBe(2);
|
||||
expect(edit?.before?.elements).toEqual([]);
|
||||
expect(edit?.after?.elements.filter((element) => element.kind === 'photo')).toHaveLength(2);
|
||||
@@ -101,20 +101,38 @@ describe('bookCardModel', () => {
|
||||
ops: [],
|
||||
cover: { template: 'cover-full', photoId: null, title: 'Portugal', subtitle: null },
|
||||
});
|
||||
expect(pageViewOf(cover, doc, 'applying')?.after?.id).toBe('sp-1');
|
||||
expect(pageViewOf(card({ change: 'moved', ops: [], movedTo: 1 }), doc, 'pending')).toMatchObject({ after: null });
|
||||
expect(pageViewOf(card({ change: 'new' }), doc, 'pending')).toBeNull();
|
||||
expect(pageViewOf(card({ pages: [{ ...INNER, spreadId: 'sp-elsewhere' }] }), doc, 'pending')).toBeNull();
|
||||
expect(pageViewOf(card(), null, 'pending')).toBeNull();
|
||||
for (const state of ['failed', 'discarded', 'expired'] as const) expect(pageViewOf(card(), doc, state)).toBeNull();
|
||||
expect(pageViewOf(cover, doc, 'applying')[0]?.after?.id).toBe('sp-1');
|
||||
expect(pageViewOf(card({ change: 'moved', ops: [], movedTo: 1 }), doc, 'pending')).toMatchObject([{ after: null }]);
|
||||
expect(pageViewOf(card({ change: 'new' }), doc, 'pending')).toEqual([]);
|
||||
expect(pageViewOf(card({ pages: [] }), doc, 'pending')).toEqual([]);
|
||||
expect(pageViewOf(card({ pages: [{ ...INNER, spreadId: 'sp-elsewhere' }] }), doc, 'pending')).toEqual([]);
|
||||
expect(pageViewOf(card(), null, 'pending')).toEqual([]);
|
||||
for (const state of ['failed', 'discarded', 'expired'] as const) expect(pageViewOf(card(), doc, state)).toEqual([]);
|
||||
// An operation the book on screen refuses draws the page as it is, nothing after it.
|
||||
const refused = card({ ops: [{ op: 'remove', elementIds: ['p-nowhere'], park: true }] });
|
||||
expect(pageViewOf(refused, doc, 'pending')).toMatchObject({ after: null });
|
||||
expect(pageViewOf(refused, doc, 'pending')).toMatchObject([{ after: null }]);
|
||||
|
||||
// A delete draws every page it takes, each alone, in the order it names them; one the book does not hold, none.
|
||||
const two = {
|
||||
...doc,
|
||||
spreads: [...doc.spreads.slice(0, 3), blankSpread('inner', () => 'sp-9'), ...doc.spreads.slice(3)],
|
||||
};
|
||||
const pages = [INNER, { ...INNER, spreadId: 'sp-elsewhere' }, { ...INNER, spreadId: 'sp-9', index: 3, number: 2 }];
|
||||
const gone = card({ change: 'willDelete', ops: [], pages });
|
||||
expect(pageViewOf(gone, two, 'applying')).toMatchObject([
|
||||
{ index: 2, before: { id: 'sp-3' }, after: null },
|
||||
{ index: 3, before: { id: 'sp-9' }, after: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-AI-BOOKPAGE-014: once applied the page is drawn as it is now, and it opens the Studio on that page', () => {
|
||||
const doc = book();
|
||||
expect(pageViewOf(card({ change: 'new' }), doc, 'applied')).toMatchObject({ before: null, after: { id: 'sp-3' } });
|
||||
expect(pageViewOf(card({ change: 'new' }), doc, 'applied')).toMatchObject([
|
||||
{ before: null, after: { id: 'sp-3' } },
|
||||
]);
|
||||
// The pages a delete took are gone from the book: nothing is drawn.
|
||||
const gone = card({ change: 'willDelete', ops: [], pages: [{ ...INNER, spreadId: 'sp-taken' }] });
|
||||
expect(pageViewOf(gone, doc, 'applied')).toEqual([]);
|
||||
expect(studioTarget(3, 'sp-3')).toEqual({ kind: 'page', path: '/journey/3/studio?page=sp-3' });
|
||||
expect(studioTarget(3, null)).toEqual({ kind: 'page', path: '/journey/3/studio' });
|
||||
});
|
||||
|
||||
@@ -104,8 +104,8 @@ export function distinctFindings(card: BookPageCard): BookCardFinding[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** What a page card draws: where the page stands, and the page before the change and after it. */
|
||||
interface BookPageView {
|
||||
/** What a page card draws of one page: where it stands, and the page before the change and after it. */
|
||||
export interface BookPageView {
|
||||
index: number;
|
||||
before: BookSpread | null;
|
||||
after: BookSpread | null;
|
||||
@@ -127,20 +127,33 @@ function afterOf(card: BookPageCard, doc: BookDocument, spreadId: string): BookS
|
||||
return made.doc.spreads.find((spread) => spread.id === spreadId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The spreads a page card draws from the Studio's book: while the change
|
||||
* waits, its page as it is and as the change would leave it (a move and a
|
||||
* delete show the page alone); once applied, the page as it is now. Nothing
|
||||
* for new pages not made yet, for a change that did not run, or where the
|
||||
* book on screen does not hold the page (another book, or none open).
|
||||
*/
|
||||
export function pageViewOf(card: BookPageCard, doc: BookDocument | null, state: ChangeState): BookPageView | null {
|
||||
const spreadId = card.pages[0]?.spreadId;
|
||||
if (!doc || !spreadId || GONE.has(state)) return null;
|
||||
/** Where a page stands in the book on screen, and the page; null where the book does not hold it. */
|
||||
function spreadAt(doc: BookDocument, spreadId: string): { index: number; current: BookSpread } | null {
|
||||
const index = doc.spreads.findIndex((spread) => spread.id === spreadId);
|
||||
const current = doc.spreads[index];
|
||||
if (!current) return null;
|
||||
if (state === 'applied') return { index, before: null, after: current };
|
||||
if (card.change === 'new') return null;
|
||||
return { index, before: current, after: afterOf(card, doc, spreadId) };
|
||||
return current ? { index, current } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The spreads a page card draws from the Studio's book, one view per page:
|
||||
* while the change waits, its page as it is and as the change would leave it
|
||||
* (a move shows its page alone, a delete each page it takes alone); once
|
||||
* applied, the page as it is now. Nothing for new pages not made yet, for a
|
||||
* change that did not run, or where the book on screen does not hold the
|
||||
* page (another book, or none open).
|
||||
*/
|
||||
export function pageViewOf(card: BookPageCard, doc: BookDocument | null, state: ChangeState): BookPageView[] {
|
||||
if (!doc || GONE.has(state)) return [];
|
||||
if (card.change === 'willDelete' && state !== 'applied') {
|
||||
return card.pages.flatMap((page) => {
|
||||
const found = spreadAt(doc, page.spreadId);
|
||||
return found ? [{ index: found.index, before: found.current, after: null }] : [];
|
||||
});
|
||||
}
|
||||
const spreadId = card.pages[0]?.spreadId;
|
||||
const found = spreadId ? spreadAt(doc, spreadId) : null;
|
||||
if (!spreadId || !found) return [];
|
||||
if (state === 'applied') return [{ index: found.index, before: null, after: found.current }];
|
||||
if (card.change === 'new') return [];
|
||||
return [{ index: found.index, before: found.current, after: afterOf(card, doc, spreadId) }];
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { BookImage, BookPlus, FilePen, FilePlus, FileX, MoveVertical } from 'lucide-react';
|
||||
|
||||
import { writtenRows } from '../bookDesign/bookDesignModel';
|
||||
import type { EntityTarget, PackLook } from '../cardTypes';
|
||||
|
||||
/**
|
||||
* What the photo book brings to the chat's pure look: the verbs of its
|
||||
* changes, the reading steps of the Studio's tools, what an applied change
|
||||
* opens and the look of its page card. A look stays pure, icons and literals
|
||||
* only: the chat's main chunk reads every look, so whatever a look imports
|
||||
* ships with it.
|
||||
* changes, the reading steps of the Studio's tools and of the page preview
|
||||
* the server reads, what an applied change opens and the look of its page
|
||||
* card. A look stays pure, icons and literals only: the chat's main chunk
|
||||
* reads every look, so whatever a look imports ships with it.
|
||||
*
|
||||
* An applied change opens the Studio on its page; the Studio is opened on top
|
||||
* of the journey, so the journey stays where it was underneath.
|
||||
* An applied change opens the Studio on its page, an applied whole-book
|
||||
* design on the first page it wrote; the Studio is opened on top of the
|
||||
* journey, so the journey stays where it was underneath.
|
||||
*/
|
||||
|
||||
/** The Studio of a journey, on one of its pages. */
|
||||
@@ -32,6 +34,8 @@ export const BOOK_LOOK = {
|
||||
steps: {
|
||||
get_book: { stepKey: 'assistant.step.readingBook' },
|
||||
describe_book_page: { stepKey: 'assistant.step.lookingAtPage' },
|
||||
// The server's own read: it waits for the Studio to draw the page, and says it looks at it.
|
||||
preview_book_page: { stepKey: 'assistant.step.lookingAtPage' },
|
||||
list_book_layouts: { stepKey: 'assistant.step.bookLayouts' },
|
||||
suggest_book_photos: { stepKey: 'assistant.step.choosingPhotos' },
|
||||
},
|
||||
@@ -39,8 +43,9 @@ export const BOOK_LOOK = {
|
||||
book_page: {
|
||||
Icon: BookImage,
|
||||
openLabelKey: 'assistant.card.book.open',
|
||||
// A deleted page is gone: its receipt opens the book as it is.
|
||||
// A deleted page is gone: its receipt opens the book as it is. A design opens on the first page it wrote.
|
||||
open: (_applied, card) => {
|
||||
if (card?.kind === 'bookDesign') return studioTarget(card.journeyId, writtenRows(card)[0]?.spreadId ?? null);
|
||||
if (card?.kind !== 'bookPage') return null;
|
||||
return studioTarget(card.journeyId, card.change === 'willDelete' ? null : (card.pages[0]?.spreadId ?? null));
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* FE-BOOKDSN-010 to FE-BOOKDSN-017
|
||||
* FE-BOOKDSN-010 to FE-BOOKDSN-019, FE-BOOKDSN-022, FE-BOOKDSN-030 to FE-BOOKDSN-036
|
||||
*
|
||||
* The whole-book design in the Studio's chat: the held apply named by its
|
||||
* style with its facts, each page it changes drawn beside the page it
|
||||
@@ -10,7 +10,12 @@
|
||||
* choice still waiting sent as the card goes away, and a book that cannot be
|
||||
* read; a choice sent at once when the pointer leaves the pages (the way to
|
||||
* Apply), and an older answer arriving last never taken for what the server
|
||||
* kept.
|
||||
* kept; a page pointed at outlined in the Studio's page rail; an answer or a
|
||||
* refusal never overwriting a newer choice, Apply waiting for the choice it
|
||||
* applies, a page kept for a photo gone, one typeface named once, findings
|
||||
* with no fault read as a warning, the receipt opening the Studio on the
|
||||
* first page the design wrote, and Apply on a card that came back waiting
|
||||
* for the choice the card before it sent.
|
||||
*/
|
||||
import {
|
||||
type AssistantApplied,
|
||||
@@ -21,11 +26,14 @@ import {
|
||||
type BookDesignDraftResponse,
|
||||
type BookDocument,
|
||||
type BookSpread,
|
||||
type UiStreamChunk,
|
||||
} from '@trek/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../../../tests/helpers/render';
|
||||
import { assistantRepo } from '../../../../repo/assistantRepo';
|
||||
import { chatOf, useAssistantStore } from '../../../../store/assistantStore';
|
||||
import { useStudioStore } from '../../../../store/studioStore';
|
||||
import AssistantChangeCard from '../../AssistantChangeCard';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
@@ -37,6 +45,7 @@ const repo = vi.hoisted(() => ({ draft: vi.fn(), book: vi.fn(), select: vi.fn()
|
||||
vi.mock('../../../../repo/bookDesignRepo', () => ({ bookDesignRepo: repo }));
|
||||
|
||||
const STUDIO = useStudioStore.getState();
|
||||
const ASSISTANT = useAssistantStore.getState();
|
||||
|
||||
/** sp-1 the cover, sp-2 the first page, sp-3 a double page, sp-4 the last page, sp-5 the back. */
|
||||
function book(): BookDocument {
|
||||
@@ -149,6 +158,15 @@ function pagesArea(): HTMLElement {
|
||||
return screen.getByRole('list', { name: 'Pages of the design' }).parentElement as HTMLElement;
|
||||
}
|
||||
|
||||
async function* streamOf(chunks: UiStreamChunk[]): AsyncGenerator<UiStreamChunk> {
|
||||
for (const chunk of chunks) yield chunk;
|
||||
}
|
||||
|
||||
/** The receipt of the design applied, with the pages it left as they were. */
|
||||
function applied(skipped: BookDesignCard['skipped'] = []): ChangeItem {
|
||||
return item('applied', card({ change: 'applied', revisionId: 8, skipped }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
repo.draft.mockResolvedValue(draft());
|
||||
repo.book.mockResolvedValue(stored());
|
||||
@@ -157,7 +175,9 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
useStudioStore.setState(STUDIO, true);
|
||||
useAssistantStore.setState(ASSISTANT, true);
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('the whole-book design card', () => {
|
||||
@@ -256,6 +276,9 @@ describe('the whole-book design card', () => {
|
||||
const sheets = await screen.findAllByRole('button', { name: 'Show this page' });
|
||||
fireEvent.click(sheets[0] as HTMLElement);
|
||||
expect(useStudioStore.getState().activeSpread).toBe(2);
|
||||
// The new page (sp-9) is not in the book yet: pressing it leaves the Studio on the page it shows.
|
||||
fireEvent.click(sheets[2] as HTMLElement);
|
||||
expect(useStudioStore.getState().activeSpread).toBe(2);
|
||||
expect(onOpen).not.toHaveBeenCalled();
|
||||
own.unmount();
|
||||
|
||||
@@ -365,4 +388,244 @@ describe('the whole-book design card', () => {
|
||||
expect(choice('Double page 2', 0)).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(choice('Double page 2', 1)).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('FE-BOOKDSN-022: pointing at a page outlines the page of the book it stands for in the rail; a new page outlines none', async () => {
|
||||
useStudioStore.setState({ doc: stored() });
|
||||
inView(<AssistantChangeCard items={[item('pending', card())]} />);
|
||||
await screen.findByText('Before');
|
||||
// sp-3 before and after, the new sp-9, the dropped sp-7.
|
||||
const [before, after, added, dropped] = screen.getAllByRole('button', { name: 'Show this page' });
|
||||
const hinted = () => useStudioStore.getState().hintSpread;
|
||||
|
||||
// The design's page points at the page it replaces, as the book's own page does.
|
||||
fireEvent.pointerEnter(after);
|
||||
expect(hinted()).toBe('sp-3');
|
||||
fireEvent.pointerLeave(after);
|
||||
expect(hinted()).toBeNull();
|
||||
fireEvent.pointerEnter(before);
|
||||
expect(hinted()).toBe('sp-3');
|
||||
fireEvent.pointerLeave(before);
|
||||
expect(hinted()).toBeNull();
|
||||
|
||||
// The new page is not in the book yet; the dropped one still is, reached by focus too.
|
||||
fireEvent.pointerEnter(added);
|
||||
expect(hinted()).toBeNull();
|
||||
fireEvent.focus(dropped);
|
||||
expect(hinted()).toBe('sp-7');
|
||||
fireEvent.blur(dropped);
|
||||
expect(hinted()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the whole-book design card: answers, decisions and facts', () => {
|
||||
it('FE-BOOKDSN-030: an answer arriving while a newer choice waits leaves that choice shown, and the next press starts from it', async () => {
|
||||
useStudioStore.setState({ doc: stored() });
|
||||
let release: (ids: string[]) => void = () => undefined;
|
||||
repo.select.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<string[]>((resolve) => {
|
||||
release = resolve;
|
||||
})
|
||||
);
|
||||
inView(<AssistantChangeCard items={[item('pending', card())]} />);
|
||||
await screen.findByText('Before');
|
||||
|
||||
// sp-3 out and sent (its answer held back), then sp-7 out while that request is on its way.
|
||||
fireEvent.click(choice('Double page 1'));
|
||||
fireEvent.pointerLeave(pagesArea());
|
||||
expect(repo.select).toHaveBeenCalledWith(3, 'd-1', ['sp-9', 'sp-7']);
|
||||
fireEvent.click(choice('Double page 2', 1));
|
||||
await act(async () => {
|
||||
release(['sp-9', 'sp-7']);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(choice('Double page 1')).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(choice('Double page 2', 1)).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
// The next press starts from what is shown: sp-3 back in, sp-7 still out. The pause may have sent the waiting
|
||||
// choice by now or not; no request after the first puts sp-7 back.
|
||||
fireEvent.click(choice('Double page 1'));
|
||||
fireEvent.pointerLeave(pagesArea());
|
||||
const later = repo.select.mock.calls.slice(1).map((call) => call[2] as string[]);
|
||||
expect(later.flat()).not.toContain('sp-7');
|
||||
expect(repo.select).toHaveBeenLastCalledWith(3, 'd-1', ['sp-3', 'sp-9']);
|
||||
});
|
||||
|
||||
it('FE-BOOKDSN-031: a refusal arriving while a newer choice waits, or after a newer request went out, puts nothing back', async () => {
|
||||
useStudioStore.setState({ doc: stored() });
|
||||
const refusals: ((err: Error) => void)[] = [];
|
||||
const held = () =>
|
||||
new Promise<string[]>((_resolve, reject) => {
|
||||
refusals.push(reject);
|
||||
});
|
||||
repo.select.mockImplementationOnce(held).mockImplementationOnce(held);
|
||||
inView(<AssistantChangeCard items={[item('pending', card())]} />);
|
||||
await screen.findByText('Before');
|
||||
const putBack = 'The choice of pages could not be saved and was put back.';
|
||||
|
||||
// sp-3 out and sent, then sp-7 out while that request is on its way; the first request is refused.
|
||||
fireEvent.click(choice('Double page 1'));
|
||||
fireEvent.pointerLeave(pagesArea());
|
||||
fireEvent.click(choice('Double page 2', 1));
|
||||
await act(async () => {
|
||||
refusals[0]?.(new Error('down'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.queryByText(putBack)).toBeNull();
|
||||
expect(choice('Double page 1')).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(choice('Double page 2', 1)).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
// That choice goes out (refused later), then sp-7 back in, answered at once.
|
||||
fireEvent.pointerLeave(pagesArea());
|
||||
fireEvent.click(choice('Double page 2', 1));
|
||||
fireEvent.pointerLeave(pagesArea());
|
||||
expect(repo.select).toHaveBeenCalledTimes(3);
|
||||
expect(repo.select).toHaveBeenLastCalledWith(3, 'd-1', ['sp-9', 'sp-7']);
|
||||
await act(async () => {
|
||||
refusals[1]?.(new Error('down'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.queryByText(putBack)).toBeNull();
|
||||
expect(choice('Double page 1')).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(choice('Double page 2', 1)).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('FE-BOOKDSN-032: Apply sends a choice still waiting at once and goes out once it landed; a running row holds nothing', async () => {
|
||||
useStudioStore.setState({ doc: stored() });
|
||||
useAssistantStore.setState({ byTrip: { [-3]: { ...chatOf(useAssistantStore.getState(), -3), threadId: 9 } } });
|
||||
let land: (ids: string[]) => void = () => undefined;
|
||||
repo.select.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<string[]>((resolve) => {
|
||||
land = resolve;
|
||||
})
|
||||
);
|
||||
const decide = vi
|
||||
.spyOn(assistantRepo, 'decide')
|
||||
.mockImplementation(async () => streamOf([{ type: 'finish', finishReason: 'stop' }]));
|
||||
const view = inView(<AssistantChangeCard items={[item('pending', card())]} />);
|
||||
await screen.findByText('Before');
|
||||
|
||||
// sp-3 out, then Apply straight away: neither the pointer nor the focus left the pages.
|
||||
fireEvent.click(choice('Double page 1'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
expect(repo.select).toHaveBeenCalledTimes(1);
|
||||
expect(repo.select).toHaveBeenCalledWith(3, 'd-1', ['sp-9', 'sp-7']);
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
expect(decide).not.toHaveBeenCalled();
|
||||
|
||||
// The selection landed: the decision goes out.
|
||||
await act(async () => {
|
||||
land(['sp-9', 'sp-7']);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() => expect(decide).toHaveBeenCalledTimes(1));
|
||||
expect(decide.mock.calls[0]?.[2]).toMatchObject({ decisions: [{ toolCallId: 'w1', approved: true }] });
|
||||
await waitFor(() => expect(chatOf(useAssistantStore.getState(), -3).isRunning).toBe(false));
|
||||
|
||||
// The row runs: its pages can no longer be chosen, so it holds no decision back.
|
||||
const running = <AssistantChangeCard items={[item('applying', card())]} />;
|
||||
view.rerender(
|
||||
<AssistantViewContext.Provider value={{ tripId: -3, variant: 'window' }}>{running}</AssistantViewContext.Provider>
|
||||
);
|
||||
expect(useAssistantStore.getState().decisionHolds[-3]).toEqual([]);
|
||||
expect(repo.select).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-BOOKDSN-033: a receipt names a page kept for a photo gone, and draws nothing where it wrote no page', async () => {
|
||||
useStudioStore.setState({ doc: designed() });
|
||||
const skipped: BookDesignCard['skipped'] = [
|
||||
{ spreadId: 'sp-3', reason: 'photo_gone' },
|
||||
{ spreadId: 'sp-9', reason: 'changed_since' },
|
||||
];
|
||||
inView(<AssistantChangeCard items={[applied(skipped)]} />);
|
||||
expect(screen.getByText('1 page kept, a photo left the gallery')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 page changed since, kept as it is')).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
await import('./BookDesignSpreadStrip');
|
||||
});
|
||||
expect(screen.queryByRole('list', { name: 'Pages of the design' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Show this page' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-BOOKDSN-034: one typeface for both is named once, and findings with no fault read as a warning', async () => {
|
||||
useStudioStore.setState({ doc: stored() });
|
||||
const quiet = card().spreads.map((spread) => ({ ...spread, severe: 0 }));
|
||||
const held = card({ heading: 'garamond', body: 'garamond', spreads: quiet });
|
||||
inView(<AssistantChangeCard items={[item('pending', held)]} />);
|
||||
expect(screen.getAllByText('EB Garamond')).toHaveLength(1);
|
||||
expect(screen.queryByText(/Playfair/)).toBeNull();
|
||||
// The words sit in their badge, whose tone is a warning, not a fault.
|
||||
expect(screen.getByText('3 things to check').parentElement).toHaveClass('text-warning');
|
||||
expect(await screen.findByText('Before')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-BOOKDSN-035: an applied design opens the Studio on the first page it wrote, past the ones it left', async () => {
|
||||
useStudioStore.setState({ doc: designed() });
|
||||
const onOpen = vi.fn();
|
||||
const open = () => fireEvent.click(screen.getByRole('button', { name: 'Show in the Studio' }));
|
||||
const first = inView(<AssistantChangeCard items={[applied()]} />, { onOpen });
|
||||
expect(await screen.findAllByRole('button', { name: 'Show this page' })).toHaveLength(2);
|
||||
open();
|
||||
// The cover was kept as it was: the first page written is the double page laid out again.
|
||||
expect(onOpen).toHaveBeenLastCalledWith({ kind: 'page', path: '/journey/3/studio?page=sp-3' });
|
||||
first.unmount();
|
||||
|
||||
const changed: BookDesignCard['skipped'] = [{ spreadId: 'sp-3', reason: 'changed_since' }];
|
||||
const second = inView(<AssistantChangeCard items={[applied(changed)]} />, { onOpen });
|
||||
open();
|
||||
expect(onOpen).toHaveBeenLastCalledWith({ kind: 'page', path: '/journey/3/studio?page=sp-9' });
|
||||
second.unmount();
|
||||
|
||||
// Every page it would write was left as it was: the book opens as it is.
|
||||
const all: BookDesignCard['skipped'] = [...changed, { spreadId: 'sp-9', reason: 'photo_gone' }];
|
||||
inView(<AssistantChangeCard items={[applied(all)]} />, { onOpen });
|
||||
open();
|
||||
expect(onOpen).toHaveBeenLastCalledWith({ kind: 'page', path: '/journey/3/studio' });
|
||||
});
|
||||
|
||||
it('FE-BOOKDSN-036: Apply on a card that came back waits for the choice the card before it sent as it went away', async () => {
|
||||
useStudioStore.setState({ doc: stored() });
|
||||
useAssistantStore.setState({ byTrip: { [-3]: { ...chatOf(useAssistantStore.getState(), -3), threadId: 9 } } });
|
||||
let land: (ids: string[]) => void = () => undefined;
|
||||
repo.select.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<string[]>((resolve) => {
|
||||
land = resolve;
|
||||
})
|
||||
);
|
||||
const decide = vi
|
||||
.spyOn(assistantRepo, 'decide')
|
||||
.mockImplementation(async () => streamOf([{ type: 'finish', finishReason: 'stop' }]));
|
||||
const first = inView(<AssistantChangeCard items={[item('pending', card())]} />);
|
||||
await screen.findByText('Before');
|
||||
|
||||
// sp-3 out, then the card goes away (the window closed): the choice goes out, its answer on its way.
|
||||
fireEvent.click(choice('Double page 1'));
|
||||
first.unmount();
|
||||
expect(repo.select).toHaveBeenCalledWith(3, 'd-1', ['sp-9', 'sp-7']);
|
||||
|
||||
// The card comes back and Apply is pressed before that answer: the decision waits for it.
|
||||
inView(<AssistantChangeCard items={[item('pending', card())]} />);
|
||||
await screen.findByText('Before');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
expect(decide).not.toHaveBeenCalled();
|
||||
// The request the first card sent holds the chat, beside the hold of the card that came back.
|
||||
expect(useAssistantStore.getState().decisionHolds[-3]).toHaveLength(2);
|
||||
|
||||
await act(async () => {
|
||||
land(['sp-9', 'sp-7']);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() => expect(decide).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(chatOf(useAssistantStore.getState(), -3).isRunning).toBe(false));
|
||||
// Landed, that request holds nothing any more; the card that came back sent nothing itself.
|
||||
expect(useAssistantStore.getState().decisionHolds[-3]).toHaveLength(1);
|
||||
expect(repo.select).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,8 @@ import type { PackLook } from '../cardTypes';
|
||||
*
|
||||
* The changes touch pages of the book, so they name the photo book's own
|
||||
* entity (`book_page`, bookLook.ts) and add none; the card opens its pages
|
||||
* itself, each one in the Studio.
|
||||
* itself, each one in the Studio, and the entity's look opens an applied
|
||||
* design's receipt on the first page it wrote.
|
||||
*/
|
||||
export const BOOK_DESIGN_LOOK = {
|
||||
verbs: {
|
||||
|
||||
@@ -138,16 +138,24 @@ export function draftPairs(draft: BookDesignDraftResponse, book: BookDocument |
|
||||
}
|
||||
|
||||
/**
|
||||
* The pages an applied design wrote, as the book holds them now: every page
|
||||
* it replaced or added and did not leave as it was. A dropped page is gone,
|
||||
* and the draft is gone with the apply, so nothing is drawn before.
|
||||
* The rows of the pages an applied design wrote, in the card's order: every
|
||||
* page it replaced or added and did not leave as it was. The receipt draws
|
||||
* them and opens the Studio on the first (bookLook.ts), so both read this.
|
||||
*/
|
||||
export function appliedPairs(card: BookDesignCard, book: BookDocument | null): DesignPair[] {
|
||||
export function writtenRows(card: BookDesignCard): BookDesignCard['spreads'] {
|
||||
const skipped = new Set(card.skipped.map((page) => page.spreadId));
|
||||
const written = card.spreads.filter(
|
||||
return card.spreads.filter(
|
||||
(row) => (row.action === 'replace' || row.action === 'insert') && !skipped.has(row.spreadId)
|
||||
);
|
||||
return written.flatMap((row) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* The pages an applied design wrote, as the book holds them now. A dropped
|
||||
* page is gone, and the draft is gone with the apply, so nothing is drawn
|
||||
* before.
|
||||
*/
|
||||
export function appliedPairs(card: BookDesignCard, book: BookDocument | null): DesignPair[] {
|
||||
return writtenRows(card).flatMap((row) => {
|
||||
const now = spreadIn(book, row.spreadId);
|
||||
return now.spread ? [{ ...pairOf(row, null, null), page: now.page, after: now.spread }] : [];
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { BookDesignCard, BookDesignDraftResponse, BookDocument, BookPageSet
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { bookDesignRepo } from '../../../../repo/bookDesignRepo';
|
||||
import { useAssistantStore } from '../../../../store/assistantStore';
|
||||
import { useStudioStore } from '../../../../store/studioStore';
|
||||
import { useAssistantScope, useAssistantView } from '../../assistantViewContext';
|
||||
import { studioTarget } from '../book/bookLook';
|
||||
@@ -15,8 +16,12 @@ import { appliedPairs, draftPairs, toggledSelection, type DesignPair } from './b
|
||||
*
|
||||
* A choice is shown at once and sent 300 ms after the last press, the whole
|
||||
* selection in one request; a refusal puts back what the server last kept and
|
||||
* says so. A choice still waiting is sent at once when the pointer or the
|
||||
* focus leaves the pages (the way to Apply) and when the card goes away.
|
||||
* says so, and an answer never overwrites a newer choice. A choice still
|
||||
* waiting is sent at once when the pointer or the focus leaves the pages (the
|
||||
* way to Apply) and when the card goes away. A decision of the chat waits for
|
||||
* the choice: it sends one still waiting, and goes out once every request sent
|
||||
* for the pages has landed, one sent by a card that went away since too, so
|
||||
* Apply applies the pages shown.
|
||||
*/
|
||||
|
||||
/** How long the card waits after the last press before it sends the selection. */
|
||||
@@ -104,12 +109,18 @@ interface Outbox {
|
||||
function useSelection(card: BookDesignCard, draft: BookDesignDraftResponse | null, editable: boolean) {
|
||||
const { journeyId, designId } = card;
|
||||
const key = draft ? `${journeyId}:${designId}` : null;
|
||||
const chatKey = useAssistantView().tripId;
|
||||
const holdDecisions = useAssistantStore((s) => s.holdDecisions);
|
||||
const [chosen, setChosen] = useState<{ key: string; ids: string[] } | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const outbox = useRef<Outbox>({ timer: null, flush: null });
|
||||
// The newest selection the server answered, with the request it answered: an older answer arriving last never wins.
|
||||
const confirmed = useRef<{ seq: number; ids: string[] } | null>(null);
|
||||
const sent = useRef(0);
|
||||
// The choices made here: an answer to a request made before the newest choice never overwrites it.
|
||||
const edits = useRef(0);
|
||||
// The newest request sent for the pages: a decision of the chat waits for it once it sent the choice still waiting.
|
||||
const latest = useRef<Promise<unknown> | null>(null);
|
||||
const selection = useMemo(
|
||||
() => (key !== null && chosen?.key === key ? chosen.ids : (draft?.selection ?? [])),
|
||||
[key, chosen, draft]
|
||||
@@ -125,24 +136,32 @@ function useSelection(card: BookDesignCard, draft: BookDesignDraftResponse | nul
|
||||
const proposed = draft.selection;
|
||||
sent.current += 1;
|
||||
const mine = sent.current;
|
||||
bookDesignRepo.select(journeyId, designId, ids).then(
|
||||
const edit = edits.current;
|
||||
const request = bookDesignRepo.select(journeyId, designId, ids).then(
|
||||
(kept) => {
|
||||
if (confirmed.current === null || mine > confirmed.current.seq) confirmed.current = { seq: mine, ids: kept };
|
||||
if (mine === sent.current) setChosen({ key, ids: kept });
|
||||
// A newer request, or a newer choice still waiting for its pause, decides what is shown.
|
||||
if (mine === sent.current && edit === edits.current) setChosen({ key, ids: kept });
|
||||
},
|
||||
() => {
|
||||
if (mine !== sent.current) return;
|
||||
if (mine !== sent.current || edit !== edits.current) return;
|
||||
setChosen({ key, ids: confirmed.current?.ids ?? proposed });
|
||||
setFailed(true);
|
||||
}
|
||||
);
|
||||
latest.current = request;
|
||||
// Each request holds the chat's decisions back until it landed, past the card: one that went away and came back
|
||||
// still waits for what it sent before.
|
||||
const release = holdDecisions(chatKey, () => request);
|
||||
void request.finally(release);
|
||||
},
|
||||
[key, draft, journeyId, designId]
|
||||
[key, draft, journeyId, designId, chatKey, holdDecisions]
|
||||
);
|
||||
|
||||
const toggle = useCallback(
|
||||
(spreadId: string) => {
|
||||
if (key === null || !editable) return;
|
||||
edits.current += 1;
|
||||
const next = toggledSelection(selection, spreadId, order);
|
||||
setChosen({ key, ids: next });
|
||||
setFailed(false);
|
||||
@@ -168,6 +187,15 @@ function useSelection(card: BookDesignCard, draft: BookDesignDraftResponse | nul
|
||||
}, []);
|
||||
useEffect(() => flush, [flush]);
|
||||
|
||||
// While the pages can be chosen, a decision of this chat first sends a choice still waiting: the apply reads the
|
||||
// selection the server holds. The store asks the holds it found before this one sends, so it answers for the request
|
||||
// it sent itself; the requests sent before hold by themselves.
|
||||
const settle = useCallback(() => {
|
||||
flush();
|
||||
return latest.current ?? Promise.resolve();
|
||||
}, [flush]);
|
||||
useEffect(() => (editable ? holdDecisions(chatKey, settle) : undefined), [editable, chatKey, holdDecisions, settle]);
|
||||
|
||||
const chosenSet = useMemo(() => new Set(selection), [selection]);
|
||||
return { chosen: chosenSet, toggle, flush, failed };
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ describe('cardLooks', () => {
|
||||
for (const [tool, look] of Object.entries(TOOL_LABELS)) expect(look?.labelKey).toBe(`assistant.tool.${tool}`);
|
||||
for (const look of Object.values(STEP_LOOKS)) expect(look?.stepKey).toMatch(/^assistant\.step\./);
|
||||
expect(STEP_LOOKS.get_trip_summary?.stepKey).toBe('assistant.step.reading');
|
||||
// The Studio's page preview is the server's own read; it waits for the page and says so.
|
||||
expect(STEP_LOOKS.preview_book_page?.stepKey).toBe('assistant.step.lookingAtPage');
|
||||
});
|
||||
|
||||
it('FE-AI-LOOK-003: a place, a stop, a note, a booking and a stay show on the map; a kept note never', () => {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
/**
|
||||
* FE-AI-VACAY-010 to FE-AI-VACAY-015
|
||||
* FE-AI-VACAY-010 to FE-AI-VACAY-016
|
||||
*
|
||||
* The vacation card as the chat draws it: an answer as a month calendar with
|
||||
* the days left and the way into Vacay, a change card's vacation rows under
|
||||
* one calendar whose held days are taken in or left out with a tap, the days
|
||||
* left following the choice, each row's facts, a decided card, and the phone,
|
||||
* where one month fills the row.
|
||||
* left following the choice, each row's facts, a decided card, the phone,
|
||||
* where one month fills the row, and held days that wait while the chat runs.
|
||||
*/
|
||||
import type { AssistantApplied, AssistantChangeVerbKey, VacayCard, VacayCardProposal } from '@trek/shared';
|
||||
import type { ReactElement } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { buildUser } from '../../../../../tests/helpers/factories';
|
||||
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||
import { act, fireEvent, render, screen } from '../../../../../tests/helpers/render';
|
||||
import { chatOf, useAssistantStore } from '../../../../store/assistantStore';
|
||||
import { useAuthStore } from '../../../../store/authStore';
|
||||
import AssistantChangeCard from '../../AssistantChangeCard';
|
||||
@@ -212,4 +212,18 @@ describe('the vacation card', () => {
|
||||
expect(container.querySelector('li.w-full')).not.toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Tue, Oct 27: Take a day off' })).toHaveClass('size-9');
|
||||
});
|
||||
|
||||
it('FE-AI-VACAY-016: while the chat runs a held day waits, and takes a tap again once it ends', () => {
|
||||
inView(<AssistantChangeCard items={[row('c1', 'addVacationDay', proposal())]} />);
|
||||
const cell = { name: 'Tue, Oct 27: Take a day off' };
|
||||
const running = (isRunning: boolean) =>
|
||||
act(() => useAssistantStore.setState((s) => ({ byTrip: { [TRIP]: { ...chatOf(s, TRIP), isRunning } } })));
|
||||
expect(screen.getByRole('button', cell)).toBeInTheDocument();
|
||||
running(true);
|
||||
expect(screen.queryByRole('button', cell)).toBeNull();
|
||||
// The day stays on the calendar and in the list read aloud.
|
||||
expect(screen.getByText('Tue, Oct 27: Take a day off')).toBeInTheDocument();
|
||||
running(false);
|
||||
expect(screen.getByRole('button', cell)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../../../store/assistantStore';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import { verbLookOf } from '../cardLooks';
|
||||
import VacayBalance from './VacayBalance';
|
||||
import {
|
||||
@@ -36,17 +34,18 @@ function dateLabel(locale: string, date: string): string {
|
||||
export default function VacayChangeGroup({
|
||||
items,
|
||||
excluded,
|
||||
disabled,
|
||||
onToggle,
|
||||
place,
|
||||
}: {
|
||||
items: readonly ChangeItem[];
|
||||
excluded: ReadonlySet<string>;
|
||||
/** The chat runs: the held days wait, as the change card's own choices do. */
|
||||
disabled: boolean;
|
||||
onToggle(id: string): void;
|
||||
place: 'head' | 'foot';
|
||||
}) {
|
||||
const { t, locale } = useTranslation();
|
||||
const { tripId } = useAssistantView();
|
||||
const isRunning = useAssistantStore((s) => chatOf(s, tripId).isRunning);
|
||||
if (place !== 'head') return null;
|
||||
const view = mergeVacayCards(vacayCardsOf(items).map((entry) => entry.card));
|
||||
const placed = placedProposals(items, excluded);
|
||||
@@ -65,7 +64,7 @@ export default function VacayChangeGroup({
|
||||
<VacayMonthStrip
|
||||
view={view}
|
||||
placed={placed}
|
||||
onToggle={waiting && !isRunning ? onToggle : undefined}
|
||||
onToggle={waiting && !disabled ? onToggle : undefined}
|
||||
labelOf={labelOf}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* FE-AI-STORE-STUDIO-001 to FE-AI-STORE-STUDIO-005
|
||||
* FE-AI-STORE-STUDIO-001 to FE-AI-STORE-STUDIO-006
|
||||
*
|
||||
* A journey's chat hosted by TREK Studio (journey-studio.md 3.1, 4.5): before
|
||||
* a question or a decision goes out the Studio writes the book's local work,
|
||||
* and the turn says the Studio draws pages; the page a turn asks for goes to
|
||||
* the host with the turn's signal; an unhosted chat sends neither; a host
|
||||
* that ends leaves a newer one in place; agreeing to page images is stored
|
||||
* for the connection. The repos are faked, nothing reaches Dexie.
|
||||
* for the connection; a decision waits for what a card of its chat still
|
||||
* sends (a book design's pages). The repos are faked, nothing reaches Dexie.
|
||||
*/
|
||||
import type { AiStatusResponse, AssistantPageRender, UiStreamChunk } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -60,7 +61,7 @@ function fakeHost(order: string[], landed = true) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAssistantStore.setState({ status: null, byTrip: {}, studioHosts: {}, lastApplied: null });
|
||||
useAssistantStore.setState({ status: null, byTrip: {}, studioHosts: {}, decisionHolds: {}, lastApplied: null });
|
||||
useSettingsStore.setState((s) => ({ settings: { ...s.settings, language: 'en' } }));
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
@@ -149,4 +150,48 @@ describe('assistantStore: a chat hosted by the Studio', () => {
|
||||
// The messages' own acknowledgement is a different one and stays as it was.
|
||||
expect(store().status?.privacyAcked).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-AI-STORE-STUDIO-006: a decision waits for what a card of its chat still sends; a hold ended or of another chat is not asked', async () => {
|
||||
const order: string[] = [];
|
||||
let land: () => void = () => undefined;
|
||||
const held = vi.fn(() => {
|
||||
order.push('hold');
|
||||
return new Promise<void>((resolve) => {
|
||||
land = () => {
|
||||
order.push('landed');
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
});
|
||||
// A hold whose send fails still lets the decision go on.
|
||||
const refused = vi.fn(() => Promise.reject(new Error('offline')));
|
||||
const ended = vi.fn(async () => undefined);
|
||||
const elsewhere = vi.fn(async () => undefined);
|
||||
store().holdDecisions(KEY, held);
|
||||
store().holdDecisions(KEY, refused);
|
||||
store().holdDecisions(KEY, ended)();
|
||||
store().holdDecisions(4, elsewhere);
|
||||
store().hostStudio(KEY, fakeHost(order));
|
||||
useAssistantStore.setState({ byTrip: { [KEY]: { ...chatOf(store(), KEY), threadId: 9 } } });
|
||||
const decide = vi.spyOn(assistantRepo, 'decide').mockImplementationOnce(async () => {
|
||||
order.push('decide');
|
||||
return streamOf([{ type: 'finish', finishReason: 'stop' }]);
|
||||
});
|
||||
|
||||
const deciding = store().decide(KEY, [{ toolCallId: WRITE_ID, approved: true }]);
|
||||
// The hold is asked at once, and nothing goes out while what it sends is on its way.
|
||||
expect(held).toHaveBeenCalledTimes(1);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(decide).not.toHaveBeenCalled();
|
||||
expect(chatOf(store(), KEY).isRunning).toBe(true);
|
||||
|
||||
land();
|
||||
await deciding;
|
||||
|
||||
expect(order).toEqual(['hold', 'landed', 'flush', 'decide']);
|
||||
expect(refused).toHaveBeenCalledTimes(1);
|
||||
expect(ended).not.toHaveBeenCalled();
|
||||
expect(elsewhere).not.toHaveBeenCalled();
|
||||
expect(chatOf(store(), KEY).error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,6 +149,8 @@ interface AssistantState {
|
||||
mapView: { tripId: number; view: AssistantMapView } | null;
|
||||
/** The TREK Studio hosting a journey's chat, by the chat's key, while that chat is open there. */
|
||||
studioHosts: Record<AssistantChatKey, AssistantStudioHost>;
|
||||
/** What the cards of a chat still send before its decisions go out (a book design's pages), by the chat's key. */
|
||||
decisionHolds: Partial<Record<AssistantChatKey, readonly AssistantDecisionHold[]>>;
|
||||
|
||||
loadStatus: () => Promise<void>;
|
||||
ackPrivacy: () => Promise<void>;
|
||||
@@ -156,6 +158,8 @@ interface AssistantState {
|
||||
ackStudioImages: () => Promise<void>;
|
||||
/** Hosts a journey's chat in the Studio; the returned function ends it, unless another host took the key since. */
|
||||
hostStudio: (key: AssistantChatKey, host: AssistantStudioHost) => () => void;
|
||||
/** Makes the decisions of a chat wait for `settle` first; the returned function ends it. */
|
||||
holdDecisions: (key: AssistantChatKey, settle: AssistantDecisionHold) => () => void;
|
||||
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
@@ -228,6 +232,24 @@ export interface AssistantStudioHost {
|
||||
flush: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a card sends before a decision of its chat goes out, a choice the
|
||||
* decision reads on the server (the pages a book design applies): it sends
|
||||
* what still waits and settles once that landed. A request already on its
|
||||
* way holds the chat by itself until it lands, past the card that sent it.
|
||||
*/
|
||||
type AssistantDecisionHold = () => Promise<unknown>;
|
||||
|
||||
/**
|
||||
* Asks every hold of the chat under `key` at once and settles when they did; a failed one lets the decision go on.
|
||||
* The holds are read once, before any is asked: one a hold adds while it sends is not asked, so a hold answers for
|
||||
* what it sends itself.
|
||||
*/
|
||||
async function settleHolds(ctx: Ctx, key: AssistantChatKey): Promise<void> {
|
||||
const holds = ctx.get().decisionHolds[key] ?? [];
|
||||
await Promise.allSettled(holds.map((settle) => settle()));
|
||||
}
|
||||
|
||||
/** The Studio hosting the chat under `key`, if one does. */
|
||||
const studioHostOf = (ctx: Ctx, key: AssistantChatKey): AssistantStudioHost | undefined => ctx.get().studioHosts[key];
|
||||
|
||||
@@ -553,6 +575,13 @@ function shellActions(ctx: Ctx) {
|
||||
}));
|
||||
};
|
||||
},
|
||||
holdDecisions: (key: AssistantChatKey, settle: AssistantDecisionHold) => {
|
||||
ctx.set((s) => ({ decisionHolds: { ...s.decisionHolds, [key]: [...(s.decisionHolds[key] ?? []), settle] } }));
|
||||
return () =>
|
||||
ctx.set((s) => ({
|
||||
decisionHolds: { ...s.decisionHolds, [key]: (s.decisionHolds[key] ?? []).filter((held) => held !== settle) },
|
||||
}));
|
||||
},
|
||||
open: () => ctx.set({ window: { open: true, minimized: false } }),
|
||||
// Closing leaves the map as the plan is now: "after", nothing hovered, no preview pin.
|
||||
close: () =>
|
||||
@@ -842,16 +871,20 @@ function turnActions(ctx: Ctx) {
|
||||
const c = chat(ctx, tripId);
|
||||
const threadId = c.threadId;
|
||||
if (c.isRunning || threadId === null || decisions.length === 0) return;
|
||||
// Asked before anything re-renders: a card's choice still on its way lands first, so the decision reads it.
|
||||
const settled = settleHolds(ctx, tripId);
|
||||
const body = { ...clientContext(), decisions };
|
||||
patch(ctx, tripId, { compare: compareFor(tripId, c.messages, decisions) });
|
||||
await runTurn(ctx, tripId, {
|
||||
messages: markDecisions(c.messages, decisions),
|
||||
before: c.messages,
|
||||
unsent: null,
|
||||
open: (signal) =>
|
||||
hostedOpen(ctx, tripId, (renders) =>
|
||||
open: async (signal) => {
|
||||
await settled;
|
||||
return hostedOpen(ctx, tripId, (renders) =>
|
||||
assistantRepo.decide(assistantTargetOf(tripId), threadId, { ...body, ...renders }, signal)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
// Refused before anything ran: there is no "after" to compare with.
|
||||
if (chat(ctx, tripId).error) patch(ctx, tripId, { compare: null });
|
||||
@@ -885,6 +918,7 @@ export const useAssistantStore = create<AssistantState>((set, get) => {
|
||||
windowRect: null,
|
||||
mapView: null,
|
||||
studioHosts: {},
|
||||
decisionHolds: {},
|
||||
...NO_ROUTE,
|
||||
...shellActions(ctx),
|
||||
...threadActions(ctx),
|
||||
|
||||
Reference in New Issue
Block a user