+ <>
{view.before && (
)}
{view.after && (
)}
+ >
+ );
+}
+
+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 (
+
+ {views.map((view) => (
+
setActiveSpread(view.index)} />
+ ))}
);
}
diff --git a/client/src/components/Assistant/cards/book/bookCardModel.test.ts b/client/src/components/Assistant/cards/book/bookCardModel.test.ts
index 0c9166095..5075b273a 100644
--- a/client/src/components/Assistant/cards/book/bookCardModel.test.ts
+++ b/client/src/components/Assistant/cards/book/bookCardModel.test.ts
@@ -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' });
});
diff --git a/client/src/components/Assistant/cards/book/bookCardModel.ts b/client/src/components/Assistant/cards/book/bookCardModel.ts
index 3f481799f..ae7dbfe12 100644
--- a/client/src/components/Assistant/cards/book/bookCardModel.ts
+++ b/client/src/components/Assistant/cards/book/bookCardModel.ts
@@ -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) }];
}
diff --git a/client/src/components/Assistant/cards/book/bookLook.ts b/client/src/components/Assistant/cards/book/bookLook.ts
index 51ea3057f..485276ef2 100644
--- a/client/src/components/Assistant/cards/book/bookLook.ts
+++ b/client/src/components/Assistant/cards/book/bookLook.ts
@@ -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));
},
diff --git a/client/src/components/Assistant/cards/bookDesign/BookDesignCard.test.tsx b/client/src/components/Assistant/cards/bookDesign/BookDesignCard.test.tsx
index 48bd02980..a77fa5b01 100644
--- a/client/src/components/Assistant/cards/bookDesign/BookDesignCard.test.tsx
+++ b/client/src/components/Assistant/cards/bookDesign/BookDesignCard.test.tsx
@@ -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
{
+ 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();
+ 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((resolve) => {
+ release = resolve;
+ })
+ );
+ inView();
+ 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((_resolve, reject) => {
+ refusals.push(reject);
+ });
+ repo.select.mockImplementationOnce(held).mockImplementationOnce(held);
+ inView();
+ 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((resolve) => {
+ land = resolve;
+ })
+ );
+ const decide = vi
+ .spyOn(assistantRepo, 'decide')
+ .mockImplementation(async () => streamOf([{ type: 'finish', finishReason: 'stop' }]));
+ const view = inView();
+ 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 = ;
+ view.rerender(
+ {running}
+ );
+ 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();
+ 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();
+ 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(, { 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(, { 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(, { 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((resolve) => {
+ land = resolve;
+ })
+ );
+ const decide = vi
+ .spyOn(assistantRepo, 'decide')
+ .mockImplementation(async () => streamOf([{ type: 'finish', finishReason: 'stop' }]));
+ const first = inView();
+ 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();
+ 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);
+ });
});
diff --git a/client/src/components/Assistant/cards/bookDesign/bookDesignLook.ts b/client/src/components/Assistant/cards/bookDesign/bookDesignLook.ts
index 6551f8f68..3706c807f 100644
--- a/client/src/components/Assistant/cards/bookDesign/bookDesignLook.ts
+++ b/client/src/components/Assistant/cards/bookDesign/bookDesignLook.ts
@@ -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: {
diff --git a/client/src/components/Assistant/cards/bookDesign/bookDesignModel.ts b/client/src/components/Assistant/cards/bookDesign/bookDesignModel.ts
index 472ee5a50..0ecefe395 100644
--- a/client/src/components/Assistant/cards/bookDesign/bookDesignModel.ts
+++ b/client/src/components/Assistant/cards/bookDesign/bookDesignModel.ts
@@ -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 }] : [];
});
diff --git a/client/src/components/Assistant/cards/bookDesign/useBookDesignDraft.ts b/client/src/components/Assistant/cards/bookDesign/useBookDesignDraft.ts
index d618ed1e6..9ce7cc487 100644
--- a/client/src/components/Assistant/cards/bookDesign/useBookDesignDraft.ts
+++ b/client/src/components/Assistant/cards/bookDesign/useBookDesignDraft.ts
@@ -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({ 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 | 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 };
}
diff --git a/client/src/components/Assistant/cards/cardLooks.test.ts b/client/src/components/Assistant/cards/cardLooks.test.ts
index 042c2fb89..21e12eeb0 100644
--- a/client/src/components/Assistant/cards/cardLooks.test.ts
+++ b/client/src/components/Assistant/cards/cardLooks.test.ts
@@ -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', () => {
diff --git a/client/src/components/Assistant/cards/vacay/VacayCard.test.tsx b/client/src/components/Assistant/cards/vacay/VacayCard.test.tsx
index 8ec8bbd2b..a69d6f198 100644
--- a/client/src/components/Assistant/cards/vacay/VacayCard.test.tsx
+++ b/client/src/components/Assistant/cards/vacay/VacayCard.test.tsx
@@ -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();
+ 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();
+ });
});
diff --git a/client/src/components/Assistant/cards/vacay/VacayChangeGroup.tsx b/client/src/components/Assistant/cards/vacay/VacayChangeGroup.tsx
index 4246b01ae..b0f5e979e 100644
--- a/client/src/components/Assistant/cards/vacay/VacayChangeGroup.tsx
+++ b/client/src/components/Assistant/cards/vacay/VacayChangeGroup.tsx
@@ -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;
+ /** 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({
)}
diff --git a/client/src/store/assistantStore.studio.test.ts b/client/src/store/assistantStore.studio.test.ts
index 7414904d9..1bbf1d2de 100644
--- a/client/src/store/assistantStore.studio.test.ts
+++ b/client/src/store/assistantStore.studio.test.ts
@@ -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((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();
+ });
});
diff --git a/client/src/store/assistantStore.ts b/client/src/store/assistantStore.ts
index 559d44b39..4690cc79e 100644
--- a/client/src/store/assistantStore.ts
+++ b/client/src/store/assistantStore.ts
@@ -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;
+ /** 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>;
loadStatus: () => Promise;
ackPrivacy: () => Promise;
@@ -156,6 +158,8 @@ interface AssistantState {
ackStudioImages: () => Promise;
/** 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;
}
+/**
+ * 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;
+
+/**
+ * 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 {
+ 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((set, get) => {
windowRect: null,
mapView: null,
studioHosts: {},
+ decisionHolds: {},
...NO_ROUTE,
...shellActions(ctx),
...threadActions(ctx),