From 0095f2105a7bf22b15cc65ed3c74d5fb89f6174c Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 23 Oct 2026 01:45:16 +0000 Subject: [PATCH] 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. --- .../Assistant/AssistantLauncher.test.tsx | 27 +- .../Assistant/AssistantLauncher.tsx | 8 +- .../cards/book/BookPageCard.test.tsx | 42 ++- .../Assistant/cards/book/BookPageThumbs.tsx | 56 ++-- .../cards/book/bookCardModel.test.ts | 44 ++- .../Assistant/cards/book/bookCardModel.ts | 45 +-- .../Assistant/cards/book/bookLook.ts | 19 +- .../cards/bookDesign/BookDesignCard.test.tsx | 267 +++++++++++++++++- .../cards/bookDesign/bookDesignLook.ts | 3 +- .../cards/bookDesign/bookDesignModel.ts | 20 +- .../cards/bookDesign/useBookDesignDraft.ts | 40 ++- .../Assistant/cards/cardLooks.test.ts | 2 + .../Assistant/cards/vacay/VacayCard.test.tsx | 22 +- .../cards/vacay/VacayChangeGroup.tsx | 9 +- .../src/store/assistantStore.studio.test.ts | 51 +++- client/src/store/assistantStore.ts | 40 ++- 16 files changed, 596 insertions(+), 99 deletions(-) diff --git a/client/src/components/Assistant/AssistantLauncher.test.tsx b/client/src/components/Assistant/AssistantLauncher.test.tsx index aab9f4778..38b6f2f0e 100644 --- a/client/src/components/Assistant/AssistantLauncher.test.tsx +++ b/client/src/components/Assistant/AssistantLauncher.test.tsx @@ -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(); + 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', () => { diff --git a/client/src/components/Assistant/AssistantLauncher.tsx b/client/src/components/Assistant/AssistantLauncher.tsx index 39041efb4..9ea247cc4 100644 --- a/client/src/components/Assistant/AssistantLauncher.tsx +++ b/client/src/components/Assistant/AssistantLauncher.tsx @@ -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 }); diff --git a/client/src/components/Assistant/cards/book/BookPageCard.test.tsx b/client/src/components/Assistant/cards/book/BookPageCard.test.tsx index d2b3a2c01..134944c42 100644 --- a/client/src/components/Assistant/cards/book/BookPageCard.test.tsx +++ b/client/src/components/Assistant/cards/book/BookPageCard.test.tsx @@ -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(); 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(); + 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(); + }); }); diff --git a/client/src/components/Assistant/cards/book/BookPageThumbs.tsx b/client/src/components/Assistant/cards/book/BookPageThumbs.tsx index 7aaae4005..87106f74b 100644 --- a/client/src/components/Assistant/cards/book/BookPageThumbs.tsx +++ b/client/src/components/Assistant/cards/book/BookPageThumbs.tsx @@ -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 (