feat(assistant): photo changes of a journal get their own card and ways back

The four new photo link tools join the journal's tools: the journey chat
offers them to editors, the trip chat loads them with the journey group and
the home chat keeps none. A journeyPhotos card shows the photos as
thumbnails with the entries they leave and reach, the first one marked as
the cover in a new order, and a caption old to new; a library add shows the
photos once they landed. A linked photo comes off again, an unlinked one
goes back, moved photos go back and a caption is written back, each while
nothing changed since. A new order of an entry's photos and a library add
have no way back yet. The journal prompt names the photo tools.
This commit is contained in:
Maurice
2026-10-24 22:07:46 +02:00
parent da43a81d26
commit 0050072b76
43 changed files with 1257 additions and 10 deletions
@@ -108,7 +108,10 @@ function ListMarks({ look }: { look: JourneyEntryLook }) {
return (
<>
{(look.tags ?? []).map((tag, index) => (
<SoftPill key={`${tag}-${index}`} icon={<Tag size={10} strokeWidth={2.2} aria-hidden="true" className={ICON} />}>
<SoftPill
key={`${tag}-${index}`}
icon={<Tag size={10} strokeWidth={2.2} aria-hidden="true" className={ICON} />}
>
<bdi className="truncate">{tag}</bdi>
</SoftPill>
))}
@@ -0,0 +1,105 @@
/**
* FE-AI-JOURNEYPHOTOS-001 to FE-AI-JOURNEYPHOTOS-004
*
* A change of a journal's photos in the chat: the entries the photos leave
* and reach, the photos as thumbnails, a caption old to new, what stays
* where, and the receipt that opens the entry.
*/
import type { AssistantApplied, AssistantChangePreview, JourneyPhotosCard } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
import AssistantChangeCard from '../../AssistantChangeCard';
import type { ChangeItem } from '../../assistantParts';
import { AssistantViewContext } from '../../assistantViewContext';
const MOVE: JourneyPhotosCard = {
kind: 'journeyPhotos',
journeyId: 3,
mode: 'move',
from: { entryId: 1, title: 'Vik', date: '2026-07-01' },
to: { entryId: 2, title: 'Reynisfjara', date: '2026-07-02' },
photos: [
{ photoId: 101, video: false, caption: 'Black sand' },
{ photoId: 102, video: true, caption: null },
],
more: 4,
kept: false,
caption: null,
};
function photoChange(card: JourneyPhotosCard, verbKey: AssistantChangePreview['verbKey'], applied = false): ChangeItem {
const preview: AssistantChangePreview = {
toolCallId: 'p1',
verbKey,
subject: 'Reynisfjara',
rows: [],
dayId: null,
placeIds: [],
undoable: false,
domain: 'journey',
card,
};
const receipt: AssistantApplied = {
toolCallId: 'p1',
entity: 'journey_entry',
ids: [2],
dayId: null,
undoUntil: null,
};
return { toolCallId: 'p1', preview, applied: applied ? receipt : null, state: applied ? 'applied' : 'pending', card };
}
function draw(item: ChangeItem, onOpen?: (target: unknown) => void) {
return render(
<AssistantViewContext.Provider value={{ tripId: -3, variant: 'window', onOpen }}>
<AssistantChangeCard items={[item]} />
</AssistantViewContext.Provider>
);
}
describe('a change of a journal photos', () => {
it('FE-AI-JOURNEYPHOTOS-001: a move names both entries, shows the photos and says they stay in the gallery', () => {
const { container } = draw(photoChange(MOVE, 'moveJournalPhotos'));
expect(screen.getByText('Move photos to another entry')).toBeInTheDocument();
expect(screen.getByText('Vik')).toBeInTheDocument();
expect(screen.getAllByText('Reynisfjara').length).toBeGreaterThan(0);
const thumbs = [...container.querySelectorAll('img')].map((img) => img.getAttribute('src'));
expect(thumbs).toEqual(['/api/photos/101/thumbnail', '/api/photos/102/thumbnail']);
expect(screen.getByText('4 more')).toBeInTheDocument();
expect(screen.getByText("The photos stay in the journal's gallery.")).toBeInTheDocument();
});
it('FE-AI-JOURNEYPHOTOS-002: a kept move and a new order say what stays where', () => {
const first = draw(photoChange({ ...MOVE, kept: true }, 'moveJournalPhotos'));
expect(screen.getByText('They also stay on the first entry.')).toBeInTheDocument();
first.unmount();
draw(photoChange({ ...MOVE, mode: 'order', from: null, more: 0 }, 'orderJournalPhotos'));
expect(screen.getByText("The first photo becomes the entry's cover.")).toBeInTheDocument();
expect(screen.getByText('Entry cover')).toBeInTheDocument();
});
it('FE-AI-JOURNEYPHOTOS-003: a caption reads old to new, a cleared one as removed', () => {
const caption = {
...MOVE,
mode: 'caption' as const,
from: null,
to: null,
more: 0,
photos: [{ photoId: 101, video: false, caption: 'Old' }],
};
const first = draw(photoChange({ ...caption, caption: { before: 'Old', after: 'New' } }, 'captionJournalPhoto'));
expect(screen.getByText('Old')).toHaveClass('line-through');
expect(screen.getByText('New')).toBeInTheDocument();
first.unmount();
draw(photoChange({ ...caption, caption: { before: 'Old', after: null } }, 'captionJournalPhoto'));
expect(screen.getByText('Removed')).toBeInTheDocument();
});
it('FE-AI-JOURNEYPHOTOS-004: the receipt opens the entry on the journal page', () => {
const onOpen = vi.fn();
draw(photoChange(MOVE, 'moveJournalPhotos', true), onOpen);
fireEvent.click(screen.getByRole('button', { name: 'Open in the journal' }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'page', path: '/journey/3?entry=2' });
});
});
@@ -0,0 +1,107 @@
import type { JourneyPhotosCard, JourneyPhotosEntry } from '@trek/shared';
import { ArrowRight, Images, MessageSquareText, Play } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { cardDateLabel } from '../../../Journey/journeyCard';
import { SoftPill } from '../../../Planner/planParts';
import type { ChangeItem } from '../../assistantParts';
import { FactChange } from '../parts/CardFacts';
/**
* What a change row of a journal's photos adds under the row: the entries
* the photos leave and reach, the photos themselves as small thumbnails (the
* first marked as the entry's cover in a new order), a caption old to new,
* and what the change leaves where. A change that failed, was discarded or
* ran out shows the row alone.
*/
const GONE: ReadonlySet<ChangeItem['state']> = new Set(['failed', 'discarded', 'expired']);
const NOTE = 'm-0 mt-1.5 text-caption text-content-muted';
function EntryName({ entry }: { entry: JourneyPhotosEntry }) {
const { locale } = useTranslation();
const day = entry.date ? cardDateLabel(entry.date, locale) : null;
return <bdi className="min-w-0 truncate">{entry.title ?? day ?? ''}</bdi>;
}
/** The entries the photos leave and reach, the way between them marked. */
function Route({ card }: { card: JourneyPhotosCard }) {
if (!card.from && !card.to) return null;
return (
<p className="m-0 flex min-w-0 items-center gap-1.5 text-caption text-content-secondary">
{card.from && <EntryName entry={card.from} />}
{card.from && card.to && (
<ArrowRight size={11} aria-hidden="true" className="flex-none text-content-faint rtl:-scale-x-100" />
)}
{card.to && <EntryName entry={card.to} />}
</p>
);
}
function Thumbs({ card }: { card: JourneyPhotosCard }) {
const { t } = useTranslation();
if (card.photos.length === 0 && card.more === 0) return null;
return (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{card.photos.map((photo, index) => (
<span key={`${photo.photoId}-${index}`} className="relative size-11 flex-none">
<img
src={`/api/photos/${photo.photoId}/thumbnail`}
alt={photo.caption ?? ''}
loading="lazy"
className="size-full rounded-lg bg-surface-secondary object-cover"
/>
{photo.video && (
<SoftPill className="absolute bottom-0.5 end-0.5" icon={<Play size={9} aria-hidden="true" />}>
<span className="sr-only">{t('assistant.card.journey.video')}</span>
</SoftPill>
)}
{card.mode === 'order' && index === 0 && (
<span className="sr-only">{t('assistant.card.journeyPhotos.cover')}</span>
)}
</span>
))}
{card.more > 0 && (
<SoftPill icon={<Images size={10} strokeWidth={2.2} aria-hidden="true" />}>
{t('assistant.card.common.more', { count: card.more })}
</SoftPill>
)}
</div>
);
}
/** What the change leaves where: on the first entry too, in the gallery, the first photo the cover. */
function Notes({ card }: { card: JourneyPhotosCard }) {
const { t } = useTranslation();
if (card.mode === 'move' && card.kept) return <p className={NOTE}>{t('assistant.card.journeyPhotos.kept')}</p>;
if (card.mode === 'unlink' || card.mode === 'move') {
return <p className={NOTE}>{t('assistant.card.journeyPhotos.staysInGallery')}</p>;
}
if (card.mode === 'order' && card.photos.length > 0) {
return <p className={NOTE}>{t('assistant.card.journeyPhotos.firstIsCover')}</p>;
}
if (card.mode === 'add') return <p className={NOTE}>{t('assistant.card.journeyPhotos.fromLibrary')}</p>;
return null;
}
export function JourneyPhotosBody({ card, item }: { card: JourneyPhotosCard; item: ChangeItem }) {
const { t } = useTranslation();
if (GONE.has(item.state)) return null;
return (
<div>
<Route card={card} />
<Thumbs card={card} />
{card.caption && (
<span className="mt-1.5 flex">
<FactChange
icon={MessageSquareText}
before={card.caption.before}
after={card.caption.after ?? t('assistant.card.common.removed')}
tone="info"
/>
</span>
)}
<Notes card={card} />
</div>
);
}
@@ -1,12 +1,17 @@
import {
ArrowDownUp,
ArrowRightLeft,
BookCheck,
BookOpen,
BookOpenText,
BookPlus,
BookX,
Eye,
ImageMinus,
ImagePlus,
Images,
Link,
MessageSquareText,
RotateCcw,
Settings2,
Type,
@@ -48,6 +53,16 @@ export const JOURNEY_LOOK = {
labelKey: 'assistant.change.verb.restoreJournalSuggestions',
},
setJournalPreferences: { Icon: Eye, tone: 'info', labelKey: 'assistant.change.verb.setJournalPreferences' },
linkJournalPhoto: { Icon: ImagePlus, tone: 'success', labelKey: 'assistant.change.verb.linkJournalPhoto' },
unlinkJournalPhoto: { Icon: ImageMinus, tone: 'warning', labelKey: 'assistant.change.verb.unlinkJournalPhoto' },
moveJournalPhotos: { Icon: ArrowRightLeft, tone: 'info', labelKey: 'assistant.change.verb.moveJournalPhotos' },
orderJournalPhotos: { Icon: ArrowDownUp, tone: 'info', labelKey: 'assistant.change.verb.orderJournalPhotos' },
captionJournalPhoto: {
Icon: MessageSquareText,
tone: 'info',
labelKey: 'assistant.change.verb.captionJournalPhoto',
},
addJournalPhotos: { Icon: Images, tone: 'success', labelKey: 'assistant.change.verb.addJournalPhotos' },
},
tools: {
create_journey: { labelKey: 'assistant.tool.create_journey' },
@@ -66,16 +81,19 @@ export const JOURNEY_LOOK = {
journey_entry: {
Icon: BookOpen,
openLabelKey: 'assistant.card.journey.open',
open: (applied, card) =>
card?.kind === 'journeyEntries'
open: (applied, card) => {
if (card?.kind === 'journeyPhotos') return journalTarget(card.journeyId, applied.ids[0] ?? null, false);
return card?.kind === 'journeyEntries'
? journalTarget(card.journeyId, applied.ids[0] ?? card.entries[0]?.entryId ?? null, card.tripJournal)
: null,
: null;
},
},
journey: {
Icon: BookOpen,
openLabelKey: 'assistant.card.journeyHeader.open',
open: (applied, card) => {
const journeyId = applied.ids[0] ?? (card?.kind === 'journeyHeader' ? card.journeyId : null);
const fromCard = card?.kind === 'journeyHeader' || card?.kind === 'journeyPhotos' ? card.journeyId : null;
const journeyId = applied.ids[0] ?? fromCard;
return journeyId ? journalTarget(journeyId, null, false) : null;
},
},
@@ -101,5 +119,8 @@ export const JOURNEY_LOOK = {
journeyHeader: {
entity: () => 'journey',
},
journeyPhotos: {
entity: (card) => (card.mode === 'caption' ? 'journey' : 'journey_entry'),
},
},
} satisfies PackLook<'journeyEntries' | 'journeyHeader'>;
} satisfies PackLook<'journeyEntries' | 'journeyHeader' | 'journeyPhotos'>;
@@ -2,6 +2,7 @@ import type { CardView } from '../cardTypes';
import { JourneyEntryAside, JourneyEntryBody, JourneyEntryFacts } from './JourneyChangeSlots';
import JourneyEntriesBlock from './JourneyEntriesBlock';
import { JourneyHeaderBody, JourneyHeaderFacts } from './JourneyHeaderSlots';
import { JourneyPhotosBody } from './JourneyPhotosSlots';
/**
* How the kinds of the journeys draw in the chat: the entries a read found
@@ -24,4 +25,7 @@ export const JOURNEY_VIEWS = {
Facts: JourneyHeaderFacts,
Body: JourneyHeaderBody,
},
} satisfies { [K in 'journeyEntries' | 'journeyHeader']: CardView<K> };
journeyPhotos: {
Body: JourneyPhotosBody,
},
} satisfies { [K in 'journeyEntries' | 'journeyHeader' | 'journeyPhotos']: CardView<K> };