From 237e4e48978f3ce9d08c6171f48f14bc819c50eb Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 22 Oct 2026 15:50:51 +0200 Subject: [PATCH] feat(assistant): chat, note and poll cards drawn like the Collab tab --- client/scripts/rtl-baseline.json | 1 - client/scripts/strict-baseline.json | 1 - .../cards/collab/CollabCards.test.tsx | 473 ++++++++++++++ .../Assistant/cards/collab/CollabChatCard.tsx | 54 ++ .../cards/collab/CollabChatThread.tsx | 279 ++++++++ .../cards/collab/CollabNotesCard.tsx | 198 ++++++ .../cards/collab/CollabPollsCard.tsx | 150 +++++ .../Assistant/cards/collab/collabCardModel.ts | 108 ++++ .../Assistant/cards/collab/collabLook.ts | 69 +- .../Assistant/cards/collab/collabViews.tsx | 25 +- .../components/Collab/CollabChatBubble.tsx | 202 ++++++ .../Collab/CollabChatMessageText.tsx | 19 +- .../components/Collab/CollabChatMessages.tsx | 113 +--- .../src/components/Collab/CollabNoteHead.tsx | 58 ++ .../src/components/Collab/CollabNotesCard.tsx | 13 +- .../components/Collab/CollabParts.test.tsx | 105 ++++ .../components/Collab/CollabPollOption.tsx | 161 +++++ client/src/components/Collab/CollabPolls.tsx | 87 +-- client/src/components/Collab/collabModel.ts | 14 + .../unit/mobile/trip/collabModel.test.ts | 12 + .../assistant/cards/collab/collab-chat.ts | 205 ++++++ .../assistant/cards/collab/collab-describe.ts | 92 +++ .../assistant/cards/collab/collab-notes.ts | 179 ++++++ .../assistant/cards/collab/collab-polls.ts | 197 ++++++ .../assistant/cards/collab/collab-undo.ts | 65 ++ .../assistant/cards/collab/collab-values.ts | 147 +++++ .../src/nest/assistant/cards/collab/index.ts | 123 +++- server/src/nest/collab/collab.mcp.ts | 24 +- server/tests/e2e/collab-mcp.e2e.test.ts | 109 ++++ server/tests/unit/mcp/tools-notes.test.ts | 63 +- .../cards/collab/collab-cards.test.ts | 594 ++++++++++++++++++ .../src/assistant/cards/collab-chat.card.ts | 65 +- .../src/assistant/cards/collab-notes.card.ts | 43 +- .../src/assistant/cards/collab-polls.card.ts | 52 +- .../src/assistant/cards/collab.card.spec.ts | 130 ++++ shared/src/assistant/cards/collab.contract.ts | 9 +- shared/src/i18n/ar/assistantCollab.ts | 62 +- shared/src/i18n/az/assistantCollab.ts | 42 +- shared/src/i18n/br/assistantCollab.ts | 42 +- shared/src/i18n/ca/assistantCollab.ts | 42 +- shared/src/i18n/cs/assistantCollab.ts | 47 +- shared/src/i18n/de/assistantCollab.ts | 42 +- shared/src/i18n/en/assistantCollab.ts | 42 +- shared/src/i18n/es/assistantCollab.ts | 42 +- shared/src/i18n/et/assistantCollab.ts | 42 +- shared/src/i18n/fr/assistantCollab.ts | 42 +- shared/src/i18n/gr/assistantCollab.ts | 42 +- shared/src/i18n/hu/assistantCollab.ts | 42 +- shared/src/i18n/id/assistantCollab.ts | 37 +- shared/src/i18n/it/assistantCollab.ts | 42 +- shared/src/i18n/ja/assistantCollab.ts | 37 +- shared/src/i18n/ko/assistantCollab.ts | 37 +- shared/src/i18n/nl/assistantCollab.ts | 42 +- shared/src/i18n/pl/assistantCollab.ts | 52 +- shared/src/i18n/ru/assistantCollab.ts | 52 +- shared/src/i18n/sk/assistantCollab.ts | 47 +- shared/src/i18n/sv/assistantCollab.ts | 42 +- shared/src/i18n/th/assistantCollab.ts | 37 +- shared/src/i18n/tr/assistantCollab.ts | 42 +- shared/src/i18n/uk/assistantCollab.ts | 52 +- shared/src/i18n/vi/assistantCollab.ts | 37 +- shared/src/i18n/zh-TW/assistantCollab.ts | 37 +- shared/src/i18n/zh/assistantCollab.ts | 37 +- 63 files changed, 5147 insertions(+), 251 deletions(-) create mode 100644 client/src/components/Assistant/cards/collab/CollabCards.test.tsx create mode 100644 client/src/components/Assistant/cards/collab/CollabChatCard.tsx create mode 100644 client/src/components/Assistant/cards/collab/CollabChatThread.tsx create mode 100644 client/src/components/Assistant/cards/collab/CollabNotesCard.tsx create mode 100644 client/src/components/Assistant/cards/collab/CollabPollsCard.tsx create mode 100644 client/src/components/Assistant/cards/collab/collabCardModel.ts create mode 100644 client/src/components/Collab/CollabChatBubble.tsx create mode 100644 client/src/components/Collab/CollabNoteHead.tsx create mode 100644 client/src/components/Collab/CollabParts.test.tsx create mode 100644 client/src/components/Collab/CollabPollOption.tsx create mode 100644 server/src/nest/assistant/cards/collab/collab-chat.ts create mode 100644 server/src/nest/assistant/cards/collab/collab-describe.ts create mode 100644 server/src/nest/assistant/cards/collab/collab-notes.ts create mode 100644 server/src/nest/assistant/cards/collab/collab-polls.ts create mode 100644 server/src/nest/assistant/cards/collab/collab-undo.ts create mode 100644 server/src/nest/assistant/cards/collab/collab-values.ts create mode 100644 server/tests/e2e/collab-mcp.e2e.test.ts create mode 100644 server/tests/unit/nest/assistant/cards/collab/collab-cards.test.ts create mode 100644 shared/src/assistant/cards/collab.card.spec.ts diff --git a/client/scripts/rtl-baseline.json b/client/scripts/rtl-baseline.json index 078942f13..1acd24681 100644 --- a/client/scripts/rtl-baseline.json +++ b/client/scripts/rtl-baseline.json @@ -6,7 +6,6 @@ "components/Collab/CollabChatEmojiPicker.tsx": 3, "components/Collab/CollabChatReactionBadge.tsx": 3, "components/Collab/CollabChatReactionMenu.tsx": 1, - "components/Collab/CollabPolls.tsx": 1, "components/Collections/CollectionPlaceDetail.tsx": 2, "components/Files/FileManagerAvatarChip.tsx": 3, "components/Journey/JourneyDayJump.tsx": 1, diff --git a/client/scripts/strict-baseline.json b/client/scripts/strict-baseline.json index b08b5b08b..66e5baee4 100644 --- a/client/scripts/strict-baseline.json +++ b/client/scripts/strict-baseline.json @@ -9,7 +9,6 @@ "src/components/Collab/CollabChat.helpers.ts": 1, "src/components/Collab/CollabChat.tsx": 3, "src/components/Collab/CollabChatEmojiPicker.tsx": 5, - "src/components/Collab/CollabChatMessageText.tsx": 2, "src/components/Collab/CollabChatMessages.tsx": 4, "src/components/Collab/CollabChatReactionBadge.tsx": 1, "src/components/Collab/CollabChatReactionMenu.tsx": 2, diff --git a/client/src/components/Assistant/cards/collab/CollabCards.test.tsx b/client/src/components/Assistant/cards/collab/CollabCards.test.tsx new file mode 100644 index 000000000..c7d06d052 --- /dev/null +++ b/client/src/components/Assistant/cards/collab/CollabCards.test.tsx @@ -0,0 +1,473 @@ +/** + * FE-AI-COLLAB-001 to FE-AI-COLLAB-033 + * + * The trip chat's cards as the assistant chat draws them: messages as the + * chat draws them (sides, faces, names, quotes, reactions, a held send, a + * removal), polls as the Collab tab draws them (results only after a vote or + * the end, the winner, a held vote), shared notes with their colour, facts + * and changes, the phone's sizes, and the pure look and model behind them. + */ +import type { + AssistantMessage, + CollabBubbleCard, + CollabChatCard, + CollabNoteCard, + CollabNotesCard, + CollabPollCard, + CollabPollsCard, + TrekCard, +} from '@trek/shared'; +import type { ReactNode } 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 { useAuthStore } from '../../../../store/authStore'; +import { formatChatClockTime } from '../../../Collab/collabModel'; +import type { ChangeItem, ChangeState } from '../../assistantParts'; +import { type AssistantView, AssistantViewContext } from '../../assistantViewContext'; +import { CARD_LOOKS, CARD_RULES, ENTITY_LOOK, STEP_LOOKS, TOOL_LABELS } from '../cardLooks'; +import type { CardEntry } from '../cardTypes'; +import { bubbleStatus, mergedChat, pollOutcome, shareOf, spotOf } from './collabCardModel'; +import CollabChatBlock, { CollabChatBody } from './CollabChatCard'; +import { CHAT_TARGET, NOTES_TARGET, POLLS_TARGET, readServedChange } from './collabLook'; +import CollabNotesBlock, { CollabNoteFacts, CollabNotesBody } from './CollabNotesCard'; +import CollabPollsBlock, { CollabPollsBody } from './CollabPollsCard'; + +const AUTH = useAuthStore.getState(); +const MESSAGE = { id: 'm1', role: 'assistant', parts: [] } as unknown as AssistantMessage; +const ANNA = { id: 7, name: 'Anna', avatarKey: 'anna.png' }; +const JON = { id: 8, name: 'Jon', avatarKey: null }; + +beforeEach(() => { + useAuthStore.setState({ user: buildUser({ id: 7 }) }); +}); + +afterEach(() => { + useAuthStore.setState(AUTH, true); + vi.useRealTimers(); +}); + +function inView(ui: ReactNode, view: Partial = {}) { + const value: AssistantView = { tripId: 4, variant: 'window', density: 'compact', ...view }; + return render({ui}); +} + +const entries = (...cards: TrekCard[]): CardEntry[] => + cards.map((card, index) => ({ toolCallId: `call_${index}`, card })); + +/** A change row holding `card`, decided as `state`, with the change's own rows. */ +const itemOf = (card: TrekCard, state: ChangeState, rows: unknown[] = []): ChangeItem => + ({ + toolCallId: 'call_x', + preview: { + toolCallId: 'call_x', + verbKey: 'genericUpdate', + subject: 'x', + rows, + dayId: null, + placeIds: [], + undoable: false, + }, + applied: null, + state, + card, + }) as unknown as ChangeItem; + +const bubble = (over: Partial = {}): CollabBubbleCard => ({ + id: 1, + author: JON, + text: 'Shall we take the early train?', + cut: false, + at: '2026-10-21T09:12:00Z', + reply: null, + reactions: [], + files: [], + fromAssistant: false, + change: 'none', + reactEmoji: null, + ...over, +}); + +const chat = (messages: CollabBubbleCard[], earlier = 0): CollabChatCard => ({ kind: 'collabChat', messages, earlier }); + +const poll = (over: Partial = {}): CollabPollCard => ({ + pollId: 5, + question: 'Where do we eat on Friday?', + options: [ + { text: 'Trattoria Roma', votes: 2, mine: false, voters: [ANNA, JON] }, + { text: 'Night market', votes: 1, mine: false, voters: [JON] }, + ], + moreOptions: 0, + multiple: false, + closed: false, + deadline: null, + author: JON, + change: 'none', + votedIndex: null, + votedAdds: false, + warning: null, + ...over, +}); + +const polls = (...list: CollabPollCard[]): CollabPollsCard => ({ kind: 'collabPolls', polls: list, more: 0 }); + +const note = (over: Partial = {}): CollabNoteCard => ({ + noteId: 9, + title: 'Restaurants to try', + body: 'Pasta at **Luigi** and https://luigis.example/menu', + category: 'Ideas', + color: '#10b981', + pinned: false, + websiteHost: 'luigis.example', + files: 2, + author: ANNA, + change: 'none', + changed: [], + ...over, +}); + +const notes = (...list: CollabNoteCard[]): CollabNotesCard => ({ kind: 'collabNotes', notes: list, more: 0 }); + +describe('the chat card', () => { + it('FE-AI-COLLAB-001: puts the others on one side with face and name, the user on the other in the accent', () => { + const thread = chat([bubble(), bubble({ id: 2, author: ANNA, text: 'Yes, 7:40 from the main station.' })]); + inView(); + const other = screen.getByText('Shall we take the early train?').closest('div[style]'); + const own = screen.getByText('Yes, 7:40 from the main station.').closest('div[style]'); + expect(other?.getAttribute('style')).toContain('background: var(--bg-card)'); + expect(own?.getAttribute('style')).toContain('background: var(--accent)'); + expect(screen.getByText('Jon')).toBeInTheDocument(); + expect(screen.queryByText('Anna')).toBeNull(); + expect(screen.getByText('J')).toBeInTheDocument(); + }); + + it('FE-AI-COLLAB-002: quotes a reply, names who reacted, keeps links as words and draws emoji alone large', () => { + const thread = chat([ + bubble({ + reply: { name: 'Anna', text: 'Train or bus?' }, + text: 'See https://rail.example/times', + reactions: [{ emoji: '❤️', count: 2, mine: true, names: ['Anna', 'Jon'] }], + }), + bubble({ id: 2, text: '🎉' }), + ]); + const { container } = inView(); + expect(screen.getByText('Train or bus?')).toBeInTheDocument(); + expect(screen.getByText('https://rail.example/times').tagName).toBe('SPAN'); + expect(container.querySelector('a')).toBeNull(); + expect(screen.getByLabelText('❤️ 2: Anna, Jon')).toBeInTheDocument(); + expect(screen.getByText('🎉').getAttribute('style')).toContain('font-size: calc(40px'); + }); + + it('FE-AI-COLLAB-003: merges the reads of one answer, each message once in order, counts the older ones and opens the chat', () => { + const onOpen = vi.fn(); + const first = chat([bubble({ id: 2, at: '2026-10-21T09:13:00Z', text: 'second' }), bubble({ text: 'first' })], 3); + const second = chat([bubble({ id: 2, at: '2026-10-21T09:13:00Z', text: 'second' })], 1); + inView(, { onOpen }); + expect(screen.getAllByText('second')).toHaveLength(1); + const texts = [...document.querySelectorAll('div[style]')].map((el) => el.textContent); + expect(texts.indexOf('first')).toBeLessThan(texts.indexOf('second')); + expect(screen.getByText('3 earlier messages')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Open the chat/ })); + expect(onOpen).toHaveBeenCalledWith(CHAT_TARGET); + }); + + it('FE-AI-COLLAB-004: a held send says it is not sent yet, then its time and sent, or not sent', () => { + const held = chat([bubble({ id: null, at: null, author: ANNA, text: 'Booked!', change: 'send' })]); + const { rerender } = inView(); + expect(screen.getByText('Not sent yet')).toBeInTheDocument(); + const sent = chat([bubble({ id: 40, author: ANNA, text: 'Booked!', change: 'send' })]); + rerender( + + + + ); + expect(screen.getByText(formatChatClockTime('2026-10-21T09:12:00Z', false))).toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'Sent' })).toBeInTheDocument(); + rerender( + + + + ); + expect(screen.getByText('Not sent')).toBeInTheDocument(); + }); + + it('FE-AI-COLLAB-005: a removal strikes the message, and once applied reads as the chat placeholder under its day', () => { + vi.useFakeTimers({ now: new Date('2026-10-21T12:00:00Z'), toFake: ['Date'] }); + const removing = chat([bubble({ author: ANNA, text: 'Wrong train', change: 'remove' })]); + const { rerender } = inView(); + expect(screen.getByText('Wrong train').closest('.line-through')).not.toBeNull(); + rerender( + + + + ); + expect(screen.queryByText('Wrong train')).toBeNull(); + expect(screen.getByText('You deleted a message')).toBeInTheDocument(); + expect(screen.getByText('Today')).toBeInTheDocument(); + }); + + it('FE-AI-COLLAB-006: a held reaction rings the emoji it toggles until it is applied', () => { + const reacting = chat([ + bubble({ + change: 'react', + reactEmoji: '👍', + reactions: [ + { emoji: '👍', count: 1, mine: true, names: ['Anna'] }, + { emoji: '❤️', count: 1, mine: false, names: ['Jon'] }, + ], + }), + ]); + const { rerender } = inView(); + expect(screen.getByLabelText('👍 1: Anna').className).toContain('border-dashed'); + expect(screen.getByLabelText('❤️ 1: Jon').className).not.toContain('border-dashed'); + rerender( + + + + ); + expect(screen.getByLabelText('👍 1: Anna').className).not.toContain('border-dashed'); + }); + + it('FE-AI-COLLAB-033: a held reaction that takes away the last of its emoji keeps it faint until applied', () => { + const takingBack = chat([ + bubble({ + change: 'react', + reactEmoji: '🎉', + reactions: [{ emoji: '❤️', count: 1, mine: false, names: ['Jon'] }], + }), + ]); + const { rerender } = inView(); + const faint = screen.getByLabelText('Reaction 🎉 taken back'); + expect(faint.className).toContain('border-dashed'); + expect(faint.className).toContain('opacity-40'); + rerender( + + + + ); + expect(screen.queryByLabelText('Reaction 🎉 taken back')).toBeNull(); + expect(screen.getByLabelText('❤️ 1: Jon')).toBeInTheDocument(); + }); + + it('FE-AI-COLLAB-007: an image is drawn from TREK by its id, another file named, a cut message says so', () => { + const files = chat([ + bubble({ + cut: true, + files: [ + { fileId: 31, image: true, name: 'ticket.jpg' }, + { fileId: 32, image: false, name: 'plan.pdf' }, + ], + }), + ]); + inView(); + expect(screen.getByAltText('ticket.jpg').getAttribute('src')).toBe('/api/trips/4/files/31/download'); + expect(screen.getByText('plan.pdf')).toBeInTheDocument(); + expect(screen.getByText('Continues in the chat')).toBeInTheDocument(); + }); + + it('FE-AI-COLLAB-008: on the phone the faces are smaller and the bubbles wider', () => { + const thread = chat([bubble()]); + inView(, { variant: 'fullscreen' }); + const face = screen.getByText('J'); + expect(face.getAttribute('style')).toContain('width: 24px'); + expect(face.parentElement?.nextElementSibling?.getAttribute('style')).toContain('max-width: 85%'); + }); + + it('FE-AI-COLLAB-009: a message shared from the assistant carries the mark', () => { + inView(); + expect(screen.getByText('from the assistant')).toBeInTheDocument(); + }); +}); + +describe('the poll card', () => { + it('FE-AI-COLLAB-010: hides the results until the user voted, then shows the shares and voters', () => { + const { rerender } = inView(); + expect(screen.queryByText('67%')).toBeNull(); + expect(screen.getByText('3 votes')).toBeInTheDocument(); + const voted = poll({ + options: [ + { text: 'Trattoria Roma', votes: 2, mine: true, voters: [ANNA, JON] }, + { text: 'Night market', votes: 1, mine: false, voters: [JON] }, + ], + }); + rerender( + + + + ); + expect(screen.getByText('67%')).toBeInTheDocument(); + expect(screen.getByText('33%')).toBeInTheDocument(); + }); + + it('FE-AI-COLLAB-011: an ended poll shows its results, marks the winner and says it is closed', () => { + const { container } = inView(); + expect(screen.getByText('Closed')).toBeInTheDocument(); + expect(screen.getByText('67%')).toBeInTheDocument(); + const bars = [...container.querySelectorAll('span[aria-hidden="true"][style]')]; + expect(bars.some((bar) => bar.getAttribute('style')?.includes('var(--success-soft)'))).toBe(true); + }); + + it('FE-AI-COLLAB-012: a held vote rings its option, and nothing on the card votes', () => { + const held = polls( + poll({ + change: 'vote', + votedIndex: 1, + votedAdds: true, + options: [ + { text: 'Trattoria Roma', votes: 2, mine: false, voters: [ANNA, JON] }, + { text: 'Night market', votes: 2, mine: true, voters: [JON, ANNA] }, + ], + }) + ); + const { container } = inView(); + expect(screen.getByText('Your vote')).toBeInTheDocument(); + expect(container.querySelector('.border-dashed')?.textContent).toContain('Night market'); + expect(screen.queryAllByRole('button')).toHaveLength(0); + }); + + it('FE-AI-COLLAB-013: a refused vote says why, a new poll says it is new, a removed one is struck', () => { + const refused = polls(poll({ change: 'vote', closed: true, warning: 'closed', votedIndex: 0 })); + const { rerender } = inView(); + expect(screen.getByText('This poll is closed')).toBeInTheDocument(); + expect(screen.queryByText('Your vote')).toBeNull(); + const created = polls(poll({ pollId: null, change: 'create', moreOptions: 2 })); + rerender( + + + + ); + expect(screen.getByText('New')).toBeInTheDocument(); + expect(screen.getByText('2 more options')).toBeInTheDocument(); + const removed = polls(poll({ change: 'remove' })); + rerender( + + + + ); + expect(screen.getByText('Where do we eat on Friday?').className).toContain('line-through'); + }); + + it('FE-AI-COLLAB-014: counts the polls left out and leads to them', () => { + const onOpen = vi.fn(); + const card: CollabPollsCard = { ...polls(poll()), more: 2 }; + inView(, { onOpen }); + fireEvent.click(screen.getByRole('button', { name: '2 more polls' })); + expect(onOpen).toHaveBeenCalledWith(POLLS_TARGET); + }); +}); + +describe('the note card', () => { + it('FE-AI-COLLAB-020: tints the head by the category colour and states the link host, files and category', () => { + inView(); + expect(screen.getByTestId('note-category-dot').style.background).toBe('rgb(16, 185, 129)'); + expect(screen.getByText('luigis.example')).toBeInTheDocument(); + expect(screen.getByText('2 files')).toBeInTheDocument(); + expect(screen.getByText('https://luigis.example/menu').tagName).toBe('SPAN'); + }); + + it('FE-AI-COLLAB-021: a colour that is no colour falls back to the accent, a pinned note wears its pin', () => { + const odd = note({ noteId: 1, title: 'Odd', color: 'url(x)' }); + const pinned = note({ noteId: 2, title: 'Pinned one', pinned: true }); + inView(); + expect(screen.getByRole('article', { name: 'Odd' }).querySelector('[style*="var(--accent)"]')).not.toBeNull(); + expect(screen.getByRole('article', { name: 'Pinned one' }).querySelector('.lucide-pin')).not.toBeNull(); + expect(screen.getByRole('article', { name: 'Odd' }).querySelector('.lucide-pin')).toBeNull(); + }); + + it('FE-AI-COLLAB-022: a held new note has a dashed frame, a note about to go is struck', () => { + const created = notes(note({ noteId: null, change: 'create' })); + const { rerender } = inView(); + expect(screen.getByRole('article').className).toContain('border-dashed'); + const removed = notes(note({ change: 'remove' })); + rerender( + + + + ); + expect(screen.getByText('Restaurants to try').closest('.line-through')).not.toBeNull(); + }); + + it('FE-AI-COLLAB-023: two notes stand side by side where the chat has room, one under the other where it has not', () => { + const pair = entries(notes(note(), note({ noteId: 10, title: 'Second' }))); + const { container, rerender } = inView(, { density: 'roomy' }); + expect(container.querySelector('.grid-cols-2')).not.toBeNull(); + rerender( + + + + ); + expect(container.querySelector('.grid-cols-2')).toBeNull(); + }); + + it('FE-AI-COLLAB-024: a note change states its title and category before and after, and its pin', () => { + const changed = notes(note({ change: 'update', changed: ['title', 'pinned'], pinned: true })); + const rows = [ + { op: 'change', labelKey: 'name', before: 'Food', after: 'Restaurants to try' }, + { op: 'change', labelKey: 'category', before: 'General', after: 'Ideas' }, + { op: 'change', labelKey: 'field', field: 'pinned', before: 'no', after: 'yes' }, + ]; + const { unmount } = inView(); + expect(screen.getByText('Food')).toBeInTheDocument(); + expect(screen.getByText('General')).toBeInTheDocument(); + expect(screen.getByText('Pinned')).toBeInTheDocument(); + unmount(); + // The pin follows the note the change leaves, whatever glyph the row carries. + const unpinned = notes(note({ change: 'update', changed: ['pinned'], pinned: false })); + const pinRow = [{ op: 'change', labelKey: 'field', field: 'pinned', before: 'yes', after: 'no' }]; + inView(); + expect(screen.queryByText('Pinned')).toBeNull(); + expect(screen.getByText('No longer pinned')).toBeInTheDocument(); + }); + + it('FE-AI-COLLAB-025: leads to the notes from its head', () => { + const onOpen = vi.fn(); + inView(, { onOpen }); + fireEvent.click(screen.getByRole('button', { name: /Open the notes/ })); + expect(onOpen).toHaveBeenCalledWith(NOTES_TARGET); + }); +}); + +describe('the look and the model', () => { + it('FE-AI-COLLAB-030: names every collab tool and read, and opens the Collab tab on its part', () => { + for (const tool of ['delete_collab_message', 'create_collab_poll', 'vote_collab_poll', 'update_collab_note']) { + expect(TOOL_LABELS[tool]?.labelKey).toBe(`assistant.tool.${tool}`); + } + expect(STEP_LOOKS.list_collab_messages?.stepKey).toBe('assistant.step.collabChat'); + expect(STEP_LOOKS.list_collab_notes?.stepKey).toBe('assistant.step.collabNotes'); + const applied = { toolCallId: 'c', ids: [1], dayId: null, undoUntil: null }; + expect(ENTITY_LOOK.collab_message.open?.({ ...applied, entity: 'collab_message' }, null)).toEqual(CHAT_TARGET); + expect(ENTITY_LOOK.collab_poll.open?.({ ...applied, entity: 'collab_poll' }, null)).toEqual(POLLS_TARGET); + expect(ENTITY_LOOK.collab_note.open?.({ ...applied, entity: 'collab_note' }, null)).toEqual(NOTES_TARGET); + expect(CARD_LOOKS.collabPolls.entity?.(polls(poll()))).toBe('collab_poll'); + }); + + it('FE-AI-COLLAB-031: a read of the one poll or note a change touches gives way to it; a list or the chat stays', () => { + const vote = polls(poll({ change: 'vote', votedIndex: 0, votedAdds: true })); + expect(readServedChange(polls(poll()), vote)).toBe(true); + expect(CARD_RULES.yieldsToChange(polls(poll()), vote)).toBe(true); + expect(readServedChange(polls(poll(), poll({ pollId: 6 })), vote)).toBe(false); + expect(readServedChange(polls(poll({ pollId: 6 })), vote)).toBe(false); + expect(readServedChange(notes(note()), notes(note({ change: 'update' })))).toBe(true); + expect(CARD_RULES.yieldsToChange(chat([bubble()]), chat([bubble({ change: 'react' })]))).toBe(false); + }); + + it('FE-AI-COLLAB-032: groups bubbles by sender and day, and reads a poll by its outcome', () => { + vi.useFakeTimers({ now: new Date('2026-10-22T12:00:00Z') }); + const run = [bubble(), bubble({ id: 2 }), bubble({ id: 3, author: ANNA, at: '2026-10-22T09:00:00Z' })]; + expect(spotOf(run, 0)).toEqual({ newGroup: true, lastInGroup: false, newDay: true }); + expect(spotOf(run, 1)).toEqual({ newGroup: false, lastInGroup: true, newDay: false }); + expect(spotOf(run, 2)).toEqual({ newGroup: true, lastInGroup: true, newDay: true }); + const merged = mergedChat(entries(chat([bubble({ id: null, at: null }), bubble({ id: 4 })], 2), chat(run))); + expect(merged.messages.map((b) => b.id)).toEqual([1, 2, 4, 3, null]); + expect(merged.earlier).toBe(2); + expect(bubbleStatus(bubble({ change: 'send', at: null }), 'pending')).toBe('waiting'); + expect(bubbleStatus(bubble({ change: 'send' }), 'discarded')).toBe('none'); + expect(bubbleStatus(bubble(), null)).toBe('time'); + const ended = pollOutcome(poll({ deadline: '2026-10-21T00:00:00Z' })); + expect(ended).toMatchObject({ ended: true, showResults: true, top: 2 }); + expect(pollOutcome(poll())).toMatchObject({ total: 3, ended: false, showResults: false }); + expect(shareOf(2, 3)).toBe(67); + expect(shareOf(0, 0)).toBe(0); + }); +}); diff --git a/client/src/components/Assistant/cards/collab/CollabChatCard.tsx b/client/src/components/Assistant/cards/collab/CollabChatCard.tsx new file mode 100644 index 000000000..b954de0ef --- /dev/null +++ b/client/src/components/Assistant/cards/collab/CollabChatCard.tsx @@ -0,0 +1,54 @@ +import type { AssistantMessage, CollabChatCard as ChatCard } from '@trek/shared'; +import { MessageCircle } from 'lucide-react'; + +import { useTranslation } from '../../../../i18n'; +import type { ChangeItem } from '../../assistantParts'; +import type { CardEntry } from '../cardTypes'; +import CardFrame from '../parts/CardFrame'; +import CardHeader from '../parts/CardHeader'; +import { CardOpenButton } from '../parts/CardOpen'; +import { mergedChat } from './collabCardModel'; +import CollabChatThread from './CollabChatThread'; +import { CHAT_TARGET } from './collabLook'; + +/** + * The trip chat in the assistant chat: an answer as the last messages a read + * found, on the chat panel's own quieter surface, with the older ones counted + * and a way into the chat; a change row as the one bubble it sends, removes + * or reacts to, as it will look. + */ + +/** The messages of an answer's chat reads, in one thread. */ +export default function CollabChatBlock({ cards }: { cards: readonly CardEntry[]; message: AssistantMessage }) { + const { t } = useTranslation(); + const { messages, earlier } = mergedChat(cards); + if (messages.length === 0) return null; + const title = t('assistant.card.collab.chatTitle'); + return ( + + } + /> +
+ {earlier > 0 && ( + + {t('assistant.card.collab.earlier', { count: earlier })} + + )} + +
+
+ ); +} + +/** A change row's bubble, on the chat panel's surface, marked by how the change stands. */ +export function CollabChatBody({ card, item }: { card: ChatCard; item: ChangeItem }) { + return ( +
+ +
+ ); +} diff --git a/client/src/components/Assistant/cards/collab/CollabChatThread.tsx b/client/src/components/Assistant/cards/collab/CollabChatThread.tsx new file mode 100644 index 000000000..cbd15dc87 --- /dev/null +++ b/client/src/components/Assistant/cards/collab/CollabChatThread.tsx @@ -0,0 +1,279 @@ +import type { CollabBubbleCard } from '@trek/shared'; +import { Check, Clock, Paperclip, TriangleAlert } from 'lucide-react'; + +import { useTranslation } from '../../../../i18n'; +import { useSettingsStore } from '../../../../store/settingsStore'; +import { avatarSrc } from '../../../../utils/avatarSrc'; +import CollabAssistantBadge from '../../../Collab/CollabAssistantBadge'; +import { + ChatAvatar, + ChatBigEmoji, + ChatBubble, + ChatDateSeparator, + ChatReactions, + ChatSenderName, + ChatTime, +} from '../../../Collab/CollabChatBubble'; +import { MessageText } from '../../../Collab/CollabChatMessageText'; +import { TwemojiImg } from '../../../Collab/CollabChatTwemojiImg'; +import { formatChatClockTime, formatChatDateSeparator, isEmojiOnlyText } from '../../../Collab/collabModel'; +import { SoftPill } from '../../../Planner/planParts'; +import { Tooltip } from '../../../shared/Tooltip'; +import type { ChangeState } from '../../assistantParts'; +import { useAssistantView } from '../../assistantViewContext'; +import { useViewerId } from '../parts/useViewerId'; +import { type BubbleSpot, bubbleStatus, isDropped, spotOf } from './collabCardModel'; + +/** + * Messages of the trip chat drawn as the chat draws them, from the pieces the + * Collab tab uses: the others on one side with their face and name, the + * user's own on the other in the accent, a day between days, the reactions + * floating under a bubble, the time under the last of a run. A read-only + * copy: no hover actions, links read as words, only the tooltips answer. A + * held change marks its bubble: a send says it is not sent yet (then sent or + * not sent), a delete strikes it, a reaction rings the emoji it toggles (and + * keeps a faint pill for one it takes away). + */ + +/** One reaction as the chat's floating pill shows it, the names in its tooltip. */ +function Reaction({ reaction, held }: { reaction: CollabBubbleCard['reactions'][number]; held: boolean }) { + const names = reaction.names.join(', '); + return ( + + + + {reaction.count > 1 && ( + {reaction.count} + )} + + + ); +} + +/** + * The reaction a held toggle takes away when it was the user's only one on + * that emoji: the server drops its pill, so the card keeps it faint in a + * dashed ring until the change is applied, rather than showing nothing. + */ +function TakenBack({ emoji }: { emoji: string }) { + const { t } = useTranslation(); + const label = t('assistant.card.collab.reactionRemoved', { emoji }); + return ( + + + + + + ); +} + +/** A file sent with a message: an image in the bubble, any other file by its name. */ +function ChatFile({ file, tripId }: { file: CollabBubbleCard['files'][number]; tripId: number }) { + const { t } = useTranslation(); + if (file.image) { + return ( + {file.name + ); + } + return ( + + + + ); +} + +/** The time slot under the last bubble of a run: the time, or how a held send stands. */ +function Status({ bubble, state }: { bubble: CollabBubbleCard; state: ChangeState | null }) { + const { t } = useTranslation(); + const is12h = useSettingsStore((s) => s.settings.time_format === '12h'); + const status = bubbleStatus(bubble, state); + const time = bubble.at ? formatChatClockTime(bubble.at, is12h) : null; + if (status === 'none') return null; + if (status === 'waiting') { + return ( + + + + + ); + } + if (status === 'failed') { + return ( + + + + + ); + } + return ( + + + {time} + {status === 'sent' && ( + + )} + + + ); +} + +/** A removed message once the removal was applied: the chat's own placeholder line. */ +function Removed() { + const { t } = useTranslation(); + return ( +
+ {t('assistant.card.collab.youDeleted')} +
+ ); +} + +/** What one bubble of a thread is drawn with. */ +interface BubbleProps { + bubble: CollabBubbleCard; + spot: BubbleSpot; + own: boolean; + state: ChangeState | null; +} + +/** The face beside someone else's bubble, on the last of their run; smaller on the phone. */ +function Face({ bubble, spot }: Pick) { + const { variant } = useAssistantView(); + const size = variant === 'fullscreen' ? 24 : 28; + return ( +
+ {spot.lastInGroup && ( + + )} +
+ ); +} + +/** The bubble itself (or emoji alone, drawn large) with its files. */ +function Words({ bubble, spot, own }: Pick) { + const { tripId } = useAssistantView(); + if (bubble.files.length === 0 && isEmojiOnlyText(bubble.text)) return {bubble.text}; + return ( + } + > + {bubble.files.map((file) => )} + + ); +} + +/** Everything in the column of one bubble: the name, the assistant mark, the bubble, its reactions and its time. */ +function Column({ bubble, spot, own, state }: BubbleProps) { + const { t } = useTranslation(); + const { variant } = useAssistantView(); + const reacting = bubble.change === 'react' && state !== 'applied'; + const held = (emoji: string) => reacting && emoji === bubble.reactEmoji; + const takenBack = + reacting && bubble.reactEmoji && !bubble.reactions.some((r) => r.emoji === bubble.reactEmoji) + ? bubble.reactEmoji + : null; + return ( +
+ {!own && spot.newGroup && {bubble.author.name}} + {bubble.fromAssistant && ( + + + + )} +
+ +
+ {(bubble.reactions.length > 0 || takenBack) && ( + + {bubble.reactions.map((reaction) => ( + + ))} + {takenBack && } + + )} + {bubble.cut && ( + {t('assistant.card.collab.continues')} + )} + {spot.lastInGroup && } +
+ ); +} + +/** One message: the day before it when it changes, then its row on its sender's side. */ +function BubbleRow({ bubble, spot, own, state }: BubbleProps) { + const { t, locale } = useTranslation(); + const removing = bubble.change === 'remove'; + const day = spot.newDay && bubble.at && ; + if (removing && state === 'applied') { + return ( + <> + {day} + + + ); + } + const dimmed = removing || (bubble.change === 'send' && isDropped(state)); + return ( + <> + {day} +
+ {!own && } + +
+ + ); +} + +export default function CollabChatThread({ + messages, + state = null, +}: { + messages: readonly CollabBubbleCard[]; + /** The decision on the change the thread shows; null for an answer. */ + state?: ChangeState | null; +}) { + const viewerId = useViewerId(); + return ( +
+ {messages.map((bubble, index) => ( + + ))} +
+ ); +} diff --git a/client/src/components/Assistant/cards/collab/CollabNotesCard.tsx b/client/src/components/Assistant/cards/collab/CollabNotesCard.tsx new file mode 100644 index 000000000..560e08f23 --- /dev/null +++ b/client/src/components/Assistant/cards/collab/CollabNotesCard.tsx @@ -0,0 +1,198 @@ +import type { AssistantMessage, CollabNoteCard, CollabNotesCard as NotesCard } from '@trek/shared'; +import { Link2, Paperclip, Pin, PinOff, StickyNote, Tag, Type } from 'lucide-react'; +import type { ReactNode } from 'react'; +import Markdown, { type Components } from 'react-markdown'; +import remarkBreaks from 'remark-breaks'; +import remarkGfm from 'remark-gfm'; + +import { useTranslation } from '../../../../i18n'; +import { safeHexColor } from '../../../../utils/safeColor'; +import { NoteHeadBand } from '../../../Collab/CollabNoteHead'; +import { SoftPill, tintOf } from '../../../Planner/planParts'; +import { sanitizedMarkdownComponents, sanitizedMarkdownPlugins } from '../../../shared/markdownSanitize'; +import type { ChangeItem, ChangeState } from '../../assistantParts'; +import { useAssistantView } from '../../assistantViewContext'; +import type { CardEntry } from '../cardTypes'; +import { Fact, FactChange } from '../parts/CardFacts'; +import CardFrame from '../parts/CardFrame'; +import CardHeader from '../parts/CardHeader'; +import { CardOpenButton } from '../parts/CardOpen'; +import { PersonFace } from '../parts/CardPeople'; +import { CardMore } from '../parts/CardRows'; +import { isChanged, isOpen } from './collabCardModel'; +import { NOTES_TARGET } from './collabLook'; + +/** + * Shared notes of the trip in the assistant chat, drawn like the Collab + * tab's note cards: a head band tinted by the category's colour with its + * title and pin, the start of the text in three lines, and the facts (the + * link's host, the files, the category) as badges. A link in a note reads as + * a word here, since the text may be cut. A held new note has a dashed + * frame, a held change marks what it changes, a note about to go is struck. + */ + +const ICON = { size: 10, strokeWidth: 2.2, 'aria-hidden': true } as const; + +/** The note's markdown as the note card renders it, its links as words. */ +const NOTE_MARKDOWN: Components = { + ...sanitizedMarkdownComponents, + a: ({ children }) => {children}, +}; + +/** A small accent dot before what a held change sets anew. */ +function ChangedDot({ on }: { on: boolean }) { + if (!on) return null; + return