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 (