feat(assistant): chat, note and poll cards drawn like the Collab tab

This commit is contained in:
Maurice
2026-10-22 15:50:51 +02:00
parent b4e188fbfc
commit 6e92c2777c
63 changed files with 5147 additions and 251 deletions
@@ -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<AssistantView> = {}) {
const value: AssistantView = { tripId: 4, variant: 'window', density: 'compact', ...view };
return render(<AssistantViewContext.Provider value={value}>{ui}</AssistantViewContext.Provider>);
}
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> = {}): 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> = {}): 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> = {}): 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(<CollabChatBlock cards={entries(thread)} message={MESSAGE} />);
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(<CollabChatBlock cards={entries(thread)} message={MESSAGE} />);
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(<CollabChatBlock cards={entries(first, second)} message={MESSAGE} />, { 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(<CollabChatBody card={held} item={itemOf(held, 'pending')} />);
expect(screen.getByText('Not sent yet')).toBeInTheDocument();
const sent = chat([bubble({ id: 40, author: ANNA, text: 'Booked!', change: 'send' })]);
rerender(
<AssistantViewContext.Provider value={{ tripId: 4, variant: 'window' }}>
<CollabChatBody card={sent} item={itemOf(sent, 'applied')} />
</AssistantViewContext.Provider>
);
expect(screen.getByText(formatChatClockTime('2026-10-21T09:12:00Z', false))).toBeInTheDocument();
expect(screen.getByRole('img', { name: 'Sent' })).toBeInTheDocument();
rerender(
<AssistantViewContext.Provider value={{ tripId: 4, variant: 'window' }}>
<CollabChatBody card={held} item={itemOf(held, 'failed')} />
</AssistantViewContext.Provider>
);
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(<CollabChatBody card={removing} item={itemOf(removing, 'pending')} />);
expect(screen.getByText('Wrong train').closest('.line-through')).not.toBeNull();
rerender(
<AssistantViewContext.Provider value={{ tripId: 4, variant: 'window' }}>
<CollabChatBody card={removing} item={itemOf(removing, 'applied')} />
</AssistantViewContext.Provider>
);
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(<CollabChatBody card={reacting} item={itemOf(reacting, 'pending')} />);
expect(screen.getByLabelText('👍 1: Anna').className).toContain('border-dashed');
expect(screen.getByLabelText('❤️ 1: Jon').className).not.toContain('border-dashed');
rerender(
<AssistantViewContext.Provider value={{ tripId: 4, variant: 'window' }}>
<CollabChatBody card={reacting} item={itemOf(reacting, 'applied')} />
</AssistantViewContext.Provider>
);
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(<CollabChatBody card={takingBack} item={itemOf(takingBack, 'pending')} />);
const faint = screen.getByLabelText('Reaction 🎉 taken back');
expect(faint.className).toContain('border-dashed');
expect(faint.className).toContain('opacity-40');
rerender(
<AssistantViewContext.Provider value={{ tripId: 4, variant: 'window' }}>
<CollabChatBody card={takingBack} item={itemOf(takingBack, 'applied')} />
</AssistantViewContext.Provider>
);
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(<CollabChatBlock cards={entries(files)} message={MESSAGE} />);
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(<CollabChatBlock cards={entries(thread)} message={MESSAGE} />, { 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(<CollabChatBlock cards={entries(chat([bubble({ fromAssistant: true })]))} message={MESSAGE} />);
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(<CollabPollsBlock cards={entries(polls(poll()))} message={MESSAGE} />);
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(
<AssistantViewContext.Provider value={{ tripId: 4, variant: 'window' }}>
<CollabPollsBlock cards={entries(polls(voted))} message={MESSAGE} />
</AssistantViewContext.Provider>
);
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(<CollabPollsBlock cards={entries(polls(poll({ closed: true })))} message={MESSAGE} />);
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(<CollabPollsBody card={held} item={itemOf(held, 'pending')} />);
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(<CollabPollsBody card={refused} item={itemOf(refused, 'pending')} />);
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(
<AssistantViewContext.Provider value={{ tripId: 4, variant: 'window' }}>
<CollabPollsBody card={created} item={itemOf(created, 'pending')} />
</AssistantViewContext.Provider>
);
expect(screen.getByText('New')).toBeInTheDocument();
expect(screen.getByText('2 more options')).toBeInTheDocument();
const removed = polls(poll({ change: 'remove' }));
rerender(
<AssistantViewContext.Provider value={{ tripId: 4, variant: 'window' }}>
<CollabPollsBody card={removed} item={itemOf(removed, 'pending')} />
</AssistantViewContext.Provider>
);
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(<CollabPollsBlock cards={entries(card)} message={MESSAGE} />, { 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(<CollabNotesBlock cards={entries(notes(note()))} message={MESSAGE} />);
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(<CollabNotesBlock cards={entries(notes(odd, pinned))} message={MESSAGE} />);
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(<CollabNotesBody card={created} item={itemOf(created, 'pending')} />);
expect(screen.getByRole('article').className).toContain('border-dashed');
const removed = notes(note({ change: 'remove' }));
rerender(
<AssistantViewContext.Provider value={{ tripId: 4, variant: 'window' }}>
<CollabNotesBody card={removed} item={itemOf(removed, 'pending')} />
</AssistantViewContext.Provider>
);
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(<CollabNotesBlock cards={pair} message={MESSAGE} />, { density: 'roomy' });
expect(container.querySelector('.grid-cols-2')).not.toBeNull();
rerender(
<AssistantViewContext.Provider value={{ tripId: 4, variant: 'window', density: 'compact' }}>
<CollabNotesBlock cards={pair} message={MESSAGE} />
</AssistantViewContext.Provider>
);
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(<CollabNoteFacts card={changed} item={itemOf(changed, 'pending', rows)} />);
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(<CollabNoteFacts card={unpinned} item={itemOf(unpinned, 'pending', pinRow)} />);
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(<CollabNotesBlock cards={entries(notes(note()))} message={MESSAGE} />, { 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);
});
});
@@ -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 (
<CardFrame label={title} surface="secondary">
<CardHeader
icon={MessageCircle}
tone="info"
title={title}
actions={<CardOpenButton target={CHAT_TARGET} labelKey="assistant.card.collab.openChat" />}
/>
<div className="mt-2 flex flex-col">
{earlier > 0 && (
<span className="self-center rounded-full bg-surface-card px-3 py-0.5 text-caption text-content-faint">
{t('assistant.card.collab.earlier', { count: earlier })}
</span>
)}
<CollabChatThread messages={messages} />
</div>
</CardFrame>
);
}
/** 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 (
<div className="rounded-xl bg-surface-secondary px-2 pb-1.5 pt-0.5">
<CollabChatThread messages={card.messages} state={item.state} />
</div>
);
}
@@ -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 (
<Tooltip label={names || reaction.emoji}>
<span
aria-label={`${reaction.emoji} ${reaction.count}${names ? `: ${names}` : ''}`}
className={`inline-flex items-center gap-0.5 rounded-full px-[3px] py-px ${held ? 'border border-dashed border-accent' : ''}`}
>
<TwemojiImg emoji={reaction.emoji} size={16} />
{reaction.count > 1 && (
<span className="min-w-2 text-caption font-bold tabular-nums text-content-muted">{reaction.count}</span>
)}
</span>
</Tooltip>
);
}
/**
* 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 (
<Tooltip label={label}>
<span
aria-label={label}
className="inline-flex items-center rounded-full border border-dashed border-accent px-[3px] py-px opacity-40"
>
<TwemojiImg emoji={emoji} size={16} />
</span>
</Tooltip>
);
}
/** 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 (
<img
src={`/api/trips/${tripId}/files/${file.fileId}/download`}
alt={file.name ?? t('collab.chat.attachedImage')}
className="mt-1 block max-h-40 w-[140px] max-w-full rounded-[10px] object-cover"
/>
);
}
return (
<span className="mt-1 flex max-w-full" title={file.name ?? undefined}>
<SoftPill icon={<Paperclip size={10} strokeWidth={2.2} aria-hidden="true" />} className="max-w-full">
<bdi className="truncate">{file.name ?? t('collab.chat.attachedImage')}</bdi>
</SoftPill>
</span>
);
}
/** 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 (
<ChatTime>
<span className="inline-flex items-center gap-1">
<Clock size={10} aria-hidden="true" />
{t('assistant.card.collab.notSent')}
</span>
</ChatTime>
);
}
if (status === 'failed') {
return (
<ChatTime>
<span className="inline-flex items-center gap-1 text-danger">
<TriangleAlert size={10} aria-hidden="true" />
{t('assistant.card.collab.failed')}
</span>
</ChatTime>
);
}
return (
<ChatTime>
<span className="inline-flex items-center gap-1">
{time}
{status === 'sent' && (
<Check
size={10}
strokeWidth={2.6}
role="img"
aria-label={t('assistant.card.collab.sent')}
className="text-success"
/>
)}
</span>
</ChatTime>
);
}
/** A removed message once the removal was applied: the chat's own placeholder line. */
function Removed() {
const { t } = useTranslation();
return (
<div className="flex justify-center py-1">
<span className="text-caption italic text-content-faint">{t('assistant.card.collab.youDeleted')}</span>
</div>
);
}
/** 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<BubbleProps, 'bubble' | 'spot'>) {
const { variant } = useAssistantView();
const size = variant === 'fullscreen' ? 24 : 28;
return (
<div className="flex-none self-end" style={{ width: size }}>
{spot.lastInGroup && (
<ChatAvatar name={bubble.author.name} src={avatarSrc(bubble.author.avatarKey)} size={size} />
)}
</div>
);
}
/** The bubble itself (or emoji alone, drawn large) with its files. */
function Words({ bubble, spot, own }: Pick<BubbleProps, 'bubble' | 'spot' | 'own'>) {
const { tripId } = useAssistantView();
if (bubble.files.length === 0 && isEmojiOnlyText(bubble.text)) return <ChatBigEmoji>{bubble.text}</ChatBigEmoji>;
return (
<ChatBubble
own={own}
lastInGroup={spot.lastInGroup}
reply={bubble.reply}
text={<MessageText text={bubble.text} plain />}
>
{bubble.files.map((file) => <ChatFile key={file.fileId} file={file} tripId={tripId} />)}
</ChatBubble>
);
}
/** 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 (
<div
className={`flex min-w-0 flex-col ${own ? 'items-end' : 'items-start'}`}
style={{ maxWidth: variant === 'fullscreen' ? '85%' : '78%' }}
>
{!own && spot.newGroup && <ChatSenderName>{bubble.author.name}</ChatSenderName>}
{bubble.fromAssistant && (
<span className="mb-0.5 ps-1">
<CollabAssistantBadge name={bubble.author.name} />
</span>
)}
<div className={bubble.change === 'remove' ? 'line-through' : ''}>
<Words bubble={bubble} spot={spot} own={own} />
</div>
{(bubble.reactions.length > 0 || takenBack) && (
<ChatReactions own={own}>
{bubble.reactions.map((reaction) => (
<Reaction key={reaction.emoji} reaction={reaction} held={held(reaction.emoji)} />
))}
{takenBack && <TakenBack emoji={takenBack} />}
</ChatReactions>
)}
{bubble.cut && (
<span className="px-1 text-caption text-content-faint">{t('assistant.card.collab.continues')}</span>
)}
{spot.lastInGroup && <Status bubble={bubble} state={state} />}
</div>
);
}
/** 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 && <ChatDateSeparator label={formatChatDateSeparator(bubble.at, t, locale)} />;
if (removing && state === 'applied') {
return (
<>
{day}
<Removed />
</>
);
}
const dimmed = removing || (bubble.change === 'send' && isDropped(state));
return (
<>
{day}
<div
className={`flex gap-1.5 ${own ? 'flex-row-reverse items-end' : 'flex-row items-start'} ${dimmed ? 'opacity-60' : ''}`}
style={{ marginTop: spot.newGroup ? 10 : 1, paddingInlineStart: own ? 40 : 0, paddingInlineEnd: own ? 0 : 40 }}
>
{!own && <Face bubble={bubble} spot={spot} />}
<Column bubble={bubble} spot={spot} own={own} state={state} />
</div>
</>
);
}
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 (
<div className="flex flex-col gap-px">
{messages.map((bubble, index) => (
<BubbleRow
key={bubble.id ?? `new-${index}`}
bubble={bubble}
spot={spotOf(messages, index)}
own={bubble.author.id === viewerId}
state={state}
/>
))}
</div>
);
}
@@ -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 }) => <span className="underline underline-offset-2">{children}</span>,
};
/** A small accent dot before what a held change sets anew. */
function ChangedDot({ on }: { on: boolean }) {
if (!on) return null;
return <span aria-hidden="true" className="me-1 inline-block h-1.5 w-1.5 rounded-full bg-accent align-middle" />;
}
function NoteView({ note, state }: { note: CollabNoteCard; state: ChangeState | null }) {
const { t } = useTranslation();
const color = safeHexColor(note.color, 'var(--accent)');
const removing = note.change === 'remove';
const dashed = note.change === 'create' && isOpen(state);
const facts: ReactNode[] = [];
if (note.websiteHost) {
facts.push(
<SoftPill key="link" icon={<Link2 {...ICON} />} className="max-w-full">
<ChangedDot on={isChanged(note, 'website')} />
<bdi className="truncate">{note.websiteHost}</bdi>
</SoftPill>
);
}
if (note.files > 0) {
facts.push(
<SoftPill key="files" icon={<Paperclip {...ICON} />}>
{t('assistant.card.collab.files', { count: note.files })}
</SoftPill>
);
}
if (note.category) {
facts.push(
<SoftPill key="category" icon={<Tag {...ICON} />} className="max-w-full">
<ChangedDot on={isChanged(note, 'category')} />
<bdi className="truncate">{note.category}</bdi>
</SoftPill>
);
}
const hasBody = !!note.body || facts.length > 0;
return (
<article
aria-label={note.title}
className={`flex min-w-0 flex-col overflow-hidden rounded-2xl border bg-surface-card ${dashed ? 'border-dashed' : ''} ${removing ? 'opacity-60' : ''}`}
// A pinned note keeps its category colour on the frame, as on the Collab tab.
style={{
borderColor: note.pinned ? tintOf(color, 45) : dashed ? 'var(--border-primary)' : 'var(--border-faint)',
}}
>
<NoteHeadBand
title={
<>
<ChangedDot on={isChanged(note, 'title')} />
{note.title}
</>
}
color={color}
category={note.category}
pinned={note.pinned}
bordered={hasBody}
titleClassName={removing ? 'line-through' : ''}
trailing={note.author && <PersonFace person={note.author} size={20} />}
/>
{hasBody && (
<div className="flex flex-1 flex-col gap-2 px-3 pb-2.5 pt-2">
{note.body && (
<div className="collab-note-md line-clamp-3 break-words text-caption text-content-muted">
<Markdown
remarkPlugins={[remarkGfm, remarkBreaks]}
rehypePlugins={sanitizedMarkdownPlugins}
components={NOTE_MARKDOWN}
>
{note.body}
</Markdown>
</div>
)}
{facts.length > 0 && <span className="flex flex-wrap items-center gap-1">{facts}</span>}
</div>
)}
</article>
);
}
/** The notes of an answer's reads, each once; side by side where the chat has room, the rest counted. */
export default function CollabNotesBlock({ cards }: { cards: readonly CardEntry[]; message: AssistantMessage }) {
const { t } = useTranslation();
const { density } = useAssistantView();
const seen = new Set<number>();
const notes: CollabNoteCard[] = [];
let more = 0;
for (const { card } of cards) {
if (card.kind !== 'collabNotes') continue;
more = Math.max(more, card.more);
for (const note of card.notes) {
if (note.noteId !== null && seen.has(note.noteId)) continue;
if (note.noteId !== null) seen.add(note.noteId);
notes.push(note);
}
}
if (notes.length === 0) return null;
const title = t('collab.tabs.notes');
return (
<CardFrame label={title}>
<CardHeader
icon={StickyNote}
tone="info"
title={title}
count={notes.length + more}
actions={<CardOpenButton target={NOTES_TARGET} labelKey="assistant.card.collab.openNotes" />}
/>
<div className={`mt-2 gap-2 ${density === 'roomy' ? 'grid grid-cols-2' : 'flex flex-col'}`}>
{notes.map((note, index) => <NoteView key={note.noteId ?? `new-${index}`} note={note} state={null} />)}
</div>
<CardMore count={more} labelKey="assistant.card.collab.moreNotes" target={NOTES_TARGET} />
</CardFrame>
);
}
/** A change row's note, after the change, marked by how it stands. */
export function CollabNotesBody({ card, item }: { card: NotesCard; item: ChangeItem }) {
return (
<div className="flex flex-col gap-2">
{card.notes.map((note, index) => <NoteView key={note.noteId ?? `new-${index}`} note={note} state={item.state} />)}
</div>
);
}
/**
* The faint line of a note change: its title and category before and after
* from the change's own rows, and whether it gets pinned or unpinned, read
* from the note the change leaves.
*/
export function CollabNoteFacts({ card, item }: { card: NotesCard; item: ChangeItem }) {
const { t } = useTranslation();
const rows = item.preview?.rows ?? [];
// The pin reads from the note as the change leaves it, not from the row's check glyph.
const pinned = card.notes[0]?.pinned === true;
return (
<>
{rows.map((row, index) => {
if (row.labelKey === 'name') {
const key = `${row.labelKey}-${index}`;
return <FactChange key={key} icon={Type} before={row.before} after={row.after} tone="info" />;
}
if (row.labelKey === 'category') {
return <FactChange key={`${row.labelKey}-${index}`} icon={Tag} before={row.before} after={row.after} />;
}
if (row.labelKey === 'field' && row.field === 'pinned') {
return (
<Fact
key={`${row.labelKey}-${index}`}
icon={pinned ? Pin : PinOff}
label={t(pinned ? 'assistant.card.collab.pinned' : 'assistant.card.collab.unpinned')}
/>
);
}
return null;
})}
</>
);
}
@@ -0,0 +1,150 @@
import type { AssistantMessage, CollabPollCard, CollabPollsCard as PollsCard } from '@trek/shared';
import { BarChart3, Clock, Lock, TriangleAlert } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { avatarSrc } from '../../../../utils/avatarSrc';
import { formatPollCountdown } from '../../../Collab/collabModel';
import { PollChip, PollOptionRow } from '../../../Collab/CollabPollOption';
import { SoftPill } from '../../../Planner/planParts';
import { NEUTRAL_TINT } from '../../../shared/DialogShell';
import type { ChangeItem, ChangeState } from '../../assistantParts';
import type { CardEntry } from '../cardTypes';
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 { isOpen, pollOutcome, shareOf } from './collabCardModel';
import { POLLS_TARGET } from './collabLook';
/**
* Polls of the trip in the assistant chat, drawn like the Collab tab's poll
* cards: the question on a tinted band with its facts as chips, the options
* with their bars, shares and voters once the user voted or the poll ended,
* the winner of an ended poll in green. Nothing here votes: the options are
* rows, not buttons. A held vote rings the option it toggles, a held poll
* that does not exist yet says it is new, one about to go is struck.
*/
/** Why a held vote would be refused, in words. A table, so each key is named where it is read. */
const WARNING = {
closed: { labelKey: 'assistant.card.collab.pollClosedWarning' },
unknownOption: { labelKey: 'assistant.card.collab.unknownOption' },
} as const satisfies Record<NonNullable<CollabPollCard['warning']>, { labelKey: string }>;
function PollView({ poll, state }: { poll: CollabPollCard; state: ChangeState | null }) {
const { t } = useTranslation();
const { total, ended, showResults, top } = pollOutcome(poll);
const remaining = formatPollCountdown(poll.deadline, t);
const removing = poll.change === 'remove';
const heldVote = poll.change === 'vote' && isOpen(state) && poll.warning === null;
const marked = (index: number) =>
heldVote && index === poll.votedIndex
? { label: t(poll.votedAdds ? 'assistant.card.collab.yourVote' : 'assistant.card.collab.voteTakenBack') }
: null;
return (
<article
aria-label={poll.question}
className={`overflow-hidden rounded-2xl border border-edge-faint bg-surface-card ${removing ? 'opacity-60' : ''}`}
>
<div
className="flex items-start gap-2 border-b border-edge-faint px-3 py-2.5"
style={{ background: ended ? 'var(--bg-secondary)' : NEUTRAL_TINT }}
>
<div className="min-w-0 flex-1">
<p
className={`m-0 whitespace-pre-wrap break-words text-body font-bold leading-snug text-content ${removing ? 'line-through' : ''}`}
>
{poll.question}
</p>
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{poll.change === 'create' && <PollChip tone="info">{t('assistant.card.collab.newPoll')}</PollChip>}
{ended && <PollChip icon={<Lock size={9} strokeWidth={2.4} />}>{t('collab.polls.closed')}</PollChip>}
{remaining && !ended && (
<PollChip tone="warning" icon={<Clock size={9} strokeWidth={2.4} />}>
{remaining}
</PollChip>
)}
{poll.multiple && <PollChip>{t('collab.polls.multiChoice')}</PollChip>}
<PollChip>{t('collab.polls.votes', { n: total })}</PollChip>
</div>
</div>
{poll.author && <PersonFace person={poll.author} size={20} />}
</div>
{poll.warning && (
<div className="px-3 pt-2.5">
<SoftPill tone="warning" icon={<TriangleAlert size={10} strokeWidth={2.2} aria-hidden="true" />}>
{t(WARNING[poll.warning].labelKey)}
</SoftPill>
</div>
)}
<div className="flex flex-col gap-1.5 p-3">
{poll.options.map((option, index) => (
<PollOptionRow
key={`${index}-${option.text}`}
as="div"
text={option.text}
pct={shareOf(option.votes, total)}
mine={option.mine}
winner={ended && option.votes > 0 && option.votes === top}
showResults={showResults}
voters={option.voters.map((voter) => ({
key: voter.id,
name: voter.name,
src: avatarSrc(voter.avatarKey),
}))}
pending={marked(index)}
/>
))}
{poll.moreOptions > 0 && (
<p className="m-0 px-1 text-caption text-content-faint">
{t('assistant.card.collab.moreOptions', { count: poll.moreOptions })}
</p>
)}
</div>
</article>
);
}
/** The polls of an answer's reads, open ones first as they came, each once; the rest counted with a way to them. */
export default function CollabPollsBlock({ cards }: { cards: readonly CardEntry[]; message: AssistantMessage }) {
const { t } = useTranslation();
const seen = new Set<number>();
const polls: CollabPollCard[] = [];
let more = 0;
for (const { card } of cards) {
if (card.kind !== 'collabPolls') continue;
more = Math.max(more, card.more);
for (const poll of card.polls) {
if (poll.pollId !== null && seen.has(poll.pollId)) continue;
if (poll.pollId !== null) seen.add(poll.pollId);
polls.push(poll);
}
}
if (polls.length === 0) return null;
const title = t('collab.tabs.polls');
return (
<CardFrame label={title}>
<CardHeader
icon={BarChart3}
tone="info"
title={title}
count={polls.length + more}
actions={<CardOpenButton target={POLLS_TARGET} labelKey="assistant.card.collab.openPolls" />}
/>
<div className="mt-2 flex flex-col gap-2">
{polls.map((poll, index) => <PollView key={poll.pollId ?? `new-${index}`} poll={poll} state={null} />)}
</div>
<CardMore count={more} labelKey="assistant.card.collab.morePolls" target={POLLS_TARGET} />
</CardFrame>
);
}
/** A change row's poll, after the change, marked by how it stands. */
export function CollabPollsBody({ card, item }: { card: PollsCard; item: ChangeItem }) {
return (
<div className="flex flex-col gap-2">
{card.polls.map((poll, index) => <PollView key={poll.pollId ?? `new-${index}`} poll={poll} state={item.state} />)}
</div>
);
}
@@ -0,0 +1,108 @@
import type { CollabBubbleCard, CollabNoteCard, CollabPollCard } from '@trek/shared';
import { isPollExpired, parseUtcDate } from '../../../Collab/collabModel';
import type { ChangeState } from '../../assistantParts';
import type { CardEntry } from '../cardTypes';
/**
* The pure side of the trip chat's cards: how the bubbles of several reads
* of one answer merge, how a run of bubbles groups the way the chat groups
* them, and what a poll shows of its results. No component here, so the
* tests read it without a render.
*/
/**
* The bubbles of an answer's chat cards in one thread: each message once,
* in the order they were written, a message still waiting to be sent last;
* the older messages the reads left out are counted once.
*/
export function mergedChat(cards: readonly CardEntry[]): { messages: CollabBubbleCard[]; earlier: number } {
const byId = new Map<number, CollabBubbleCard>();
const waiting: CollabBubbleCard[] = [];
let earlier = 0;
for (const { card } of cards) {
if (card.kind !== 'collabChat') continue;
earlier = Math.max(earlier, card.earlier);
for (const bubble of card.messages) {
if (bubble.id === null) waiting.push(bubble);
else byId.set(bubble.id, bubble);
}
}
const sent = [...byId.values()].sort((a, b) => (a.at ?? '').localeCompare(b.at ?? '') || (a.id ?? 0) - (b.id ?? 0));
return { messages: [...sent, ...waiting], earlier };
}
/** Where a bubble stands in its run, as the chat lays it out. */
export interface BubbleSpot {
/** The first of the sender's run: their name goes over it. */
newGroup: boolean;
/** The last of the sender's run: it gets the tail, the face and the time. */
lastInGroup: boolean;
/** The day changes before it. */
newDay: boolean;
}
function dayOf(at: string | null): string | null {
return at === null ? null : parseUtcDate(at).toDateString();
}
export function spotOf(messages: readonly CollabBubbleCard[], index: number): BubbleSpot {
const bubble = messages[index];
const before = messages[index - 1];
const after = messages[index + 1];
const day = dayOf(bubble?.at ?? null);
return {
newGroup: !before || before.author.id !== bubble?.author.id,
lastInGroup: !after || after.author.id !== bubble?.author.id,
newDay: day !== null && (!before || dayOf(before.at) !== day),
};
}
/** What stands in a bubble's time slot while its change is open or once it was decided. */
type BubbleStatus = 'time' | 'waiting' | 'sent' | 'failed' | 'none';
/** The time slot of a bubble: a held send says it is not sent yet, then sent or failed, a dropped one nothing. */
export function bubbleStatus(bubble: CollabBubbleCard, state: ChangeState | null): BubbleStatus {
if (bubble.change !== 'send' || state === null) return bubble.at ? 'time' : 'none';
if (state === 'pending' || state === 'applying') return 'waiting';
if (state === 'applied') return 'sent';
if (state === 'failed') return 'failed';
return 'none';
}
/** What a poll shows of its outcome, by PollCard's rules. */
interface PollOutcome {
total: number;
/** Closed by hand or past its deadline. */
ended: boolean;
/** Shares and voters show once the user voted or the poll ended. */
showResults: boolean;
/** The most votes one option has. */
top: number;
}
export function pollOutcome(poll: CollabPollCard): PollOutcome {
const total = poll.options.reduce((sum, option) => sum + option.votes, 0);
const ended = poll.closed || isPollExpired(poll.deadline);
const top = poll.options.reduce((max, option) => Math.max(max, option.votes), 0);
return { total, ended, showResults: ended || poll.options.some((option) => option.mine), top };
}
/** An option's share of the votes, in whole percent. */
export function shareOf(votes: number, total: number): number {
return total > 0 ? Math.round((votes / total) * 100) : 0;
}
/** Whether a change row's card is still waiting, or settled without being applied. */
export function isOpen(state: ChangeState | null): boolean {
return state === 'pending' || state === 'applying';
}
export function isDropped(state: ChangeState | null): boolean {
return state === 'discarded' || state === 'expired' || state === 'failed';
}
/** Whether a note's part is one the held change sets to something new. */
export function isChanged(note: CollabNoteCard, field: CollabNoteCard['changed'][number]): boolean {
return note.change === 'update' && note.changed.includes(field);
}
@@ -1,20 +1,65 @@
import type { PackLook } from '../cardTypes';
import type { TrekCard } from '@trek/shared';
import { BarChart3, MessageCircle, StickyNote } from 'lucide-react';
import type { EntityTarget, PackLook } from '../cardTypes';
/**
* What the trip chat, polls and notes bring to the chat's pure look: the verbs,
* tool names and steps they add, what an applied change of theirs opens, and
* the look of each of their kinds. Empty until the collab cards fill it. A look
* stays pure, icons and literals only: the chat's main chunk reads every look,
* so whatever a look imports ships with it.
* What the trip chat, polls and notes bring to the chat's pure look: the tool
* names of their changes (the verbs stay add, change and remove), the steps
* of their reads, what an applied change of theirs opens (the Collab tab, on
* its chat, polls or notes) and the look of each of their kinds. A look stays
* pure, icons and literals only: the chat's main chunk reads every look, so
* whatever a look imports ships with it.
*/
export const CHAT_TARGET: EntityTarget = { kind: 'tab', tab: 'collab', sub: 'chat' };
export const POLLS_TARGET: EntityTarget = { kind: 'tab', tab: 'collab', sub: 'polls' };
export const NOTES_TARGET: EntityTarget = { kind: 'tab', tab: 'collab', sub: 'notes' };
/** The one poll or note a card holds, by its id; none for a list or a thing not saved yet. */
function soleId(card: TrekCard): number | null {
if (card.kind === 'collabPolls') return card.polls.length === 1 ? (card.polls[0]?.pollId ?? null) : null;
if (card.kind === 'collabNotes') return card.notes.length === 1 ? (card.notes[0]?.noteId ?? null) : null;
return null;
}
/**
* A read that only looked up the one poll or note a change of the same
* answer then touches (the poll it votes in, the note it edits) gives way to
* the change card, which shows it already. A list stays, and so does the
* chat: the messages around a reaction are what the user asked to see.
*/
export function readServedChange(answer: TrekCard, change: TrekCard): boolean {
const id = soleId(answer);
return id !== null && answer.kind === change.kind && soleId(change) === id;
}
export const COLLAB_LOOK = {
verbs: {},
tools: {},
steps: {},
entities: {},
tools: {
delete_collab_message: { labelKey: 'assistant.tool.delete_collab_message' },
react_collab_message: { labelKey: 'assistant.tool.react_collab_message' },
create_collab_poll: { labelKey: 'assistant.tool.create_collab_poll' },
vote_collab_poll: { labelKey: 'assistant.tool.vote_collab_poll' },
close_collab_poll: { labelKey: 'assistant.tool.close_collab_poll' },
delete_collab_poll: { labelKey: 'assistant.tool.delete_collab_poll' },
create_collab_note: { labelKey: 'assistant.tool.create_collab_note' },
update_collab_note: { labelKey: 'assistant.tool.update_collab_note' },
delete_collab_note: { labelKey: 'assistant.tool.delete_collab_note' },
},
steps: {
list_collab_messages: { stepKey: 'assistant.step.collabChat' },
list_collab_polls: { stepKey: 'assistant.step.collabPolls' },
list_collab_notes: { stepKey: 'assistant.step.collabNotes' },
},
entities: {
collab_message: { Icon: MessageCircle, openLabelKey: 'assistant.card.collab.openChat', open: () => CHAT_TARGET },
collab_poll: { Icon: BarChart3, openLabelKey: 'assistant.card.collab.openPolls', open: () => POLLS_TARGET },
collab_note: { Icon: StickyNote, openLabelKey: 'assistant.card.collab.openNotes', open: () => NOTES_TARGET },
},
cards: {
collabChat: {},
collabPolls: {},
collabNotes: {},
collabChat: { entity: () => 'collab_message' },
collabPolls: { entity: () => 'collab_poll', yieldsToChange: readServedChange },
collabNotes: { entity: () => 'collab_note', yieldsToChange: readServedChange },
},
} satisfies PackLook<'collabChat' | 'collabPolls' | 'collabNotes'>;
@@ -1,14 +1,21 @@
import { type CardView, PENDING_VIEW } from '../cardTypes';
import type { CardView } from '../cardTypes';
import CollabChatBlock, { CollabChatBody } from './CollabChatCard';
import CollabNotesBlock, { CollabNoteFacts, CollabNotesBody } from './CollabNotesCard';
import CollabPollsBlock, { CollabPollsBody } from './CollabPollsCard';
/**
* How the kinds of the trip chat, polls and notes draw in the chat; each one
* draws nothing until its cards are built. This file only maps each kind to its
* view: the components live in sibling files, since a file that exports
* anything besides components breaks fast refresh, and lint:warnings holds
* react-refresh/only-export-components at zero for a new file.
* How the kinds of the trip chat, polls and notes draw in the chat: an
* answer as the chat, the polls or the notes the way the Collab tab shows
* them; a change row as the one bubble, poll or note it will leave, under
* its faint line, the card naming what the row would otherwise name in bold.
* A note change states what it changes on the faint line. This file only
* maps each kind to its view: the components live in sibling files, since a
* file that exports anything besides components breaks fast refresh, and
* lint:warnings holds react-refresh/only-export-components at zero for a new
* file.
*/
export const COLLAB_VIEWS = {
collabChat: PENDING_VIEW,
collabPolls: PENDING_VIEW,
collabNotes: PENDING_VIEW,
collabChat: { Block: CollabChatBlock, Body: CollabChatBody, ownsSubject: true },
collabPolls: { Block: CollabPollsBlock, Body: CollabPollsBody, ownsSubject: true },
collabNotes: { Block: CollabNotesBlock, Body: CollabNotesBody, Facts: CollabNoteFacts, ownsSubject: true },
} satisfies { [K in 'collabChat' | 'collabPolls' | 'collabNotes']: CardView<K> };
@@ -0,0 +1,202 @@
import type { ReactNode } from 'react';
import { bubbleRadius } from './collabModel';
/**
* The pieces a message of the trip chat is drawn with, shared by the Collab
* tab and the assistant chat's cards so a message looks the same in both:
* the bubble with its tail and the quote it answers, the face beside it, the
* name over a group, the day between groups, the floating reactions and the
* time under the last message. Each piece only draws; what it does on a
* click or a hover stays with the caller.
*/
/** A face beside the last message of a group: the picture, else the first letter. */
export function ChatAvatar({ name, src, size = 28 }: { name: string; src: string | null; size?: number }) {
if (src) {
return <img src={src} alt="" style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover' }} />;
}
return (
<div
style={{
width: size,
height: size,
borderRadius: '50%',
background: 'var(--bg-tertiary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
fontWeight: 700,
color: 'var(--text-muted)',
}}
>
{(name || '?').charAt(0).toUpperCase()}
</div>
);
}
/** "Today", "Yesterday" or a date between two groups of messages. */
export function ChatDateSeparator({ label }: { label: string }) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
<span
style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
fontWeight: 600,
color: 'var(--text-faint)',
background: 'var(--bg-card)',
padding: '3px 12px',
borderRadius: 99,
letterSpacing: 0.3,
textTransform: 'uppercase',
}}
>
{label}
</span>
</div>
);
}
/** The name over the first message of someone else's group. */
export function ChatSenderName({ children }: { children: ReactNode }) {
return (
<span
style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
fontWeight: 600,
color: 'var(--text-faint)',
marginBottom: 2,
paddingInlineStart: 4,
}}
>
{children}
</span>
);
}
/** One to three emoji alone, drawn large without a bubble. */
export function ChatBigEmoji({ children }: { children: ReactNode }) {
return (
<div style={{ fontSize: 'calc(40px * var(--fs-scale-title, 1))', lineHeight: 1.2, padding: '2px 0' }}>
{children}
</div>
);
}
/**
* The bubble: the user's own in the accent with its tail on their side, the
* others as cards on the tinted panel; the tail only on the last message of
* a group. A reply carries the quote of the message it answers on top.
*/
export function ChatBubble({
own,
lastInGroup,
reply,
text,
children,
}: {
own: boolean;
lastInGroup: boolean;
reply: { name: string; text: string } | null;
/** The message's words. */
text: ReactNode;
/** What comes under the words: images, a link preview. */
children?: ReactNode;
}) {
return (
<div
style={{
// Own messages in the accent, the others as cards on the tinted panel.
background: own ? 'var(--accent)' : 'var(--bg-card)',
color: own ? 'var(--accent-text)' : 'var(--text-primary)',
border: own ? 'none' : '1px solid var(--border-faint)',
...bubbleRadius(own, lastInGroup),
padding: reply ? '4px 4px 8px 4px' : '8px 14px',
fontSize: 'calc(14px * var(--fs-scale-body, 1))',
lineHeight: 1.4,
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
}}
>
{reply && (
<div
style={{
padding: '5px 10px',
marginBottom: 4,
borderRadius: 12,
background: own ? 'color-mix(in srgb, var(--accent-text) 15%, transparent)' : 'var(--bg-secondary)',
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
lineHeight: 1.3,
}}
>
<div
style={{
fontWeight: 600,
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
opacity: 0.7,
marginBottom: 1,
}}
>
{reply.name}
</div>
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{reply.text}
</div>
</div>
)}
{reply ? <div style={{ padding: '0 10px 4px' }}>{text}</div> : text}
{children}
</div>
);
}
/** The reactions floating under a bubble, on its side, in one pill. */
export function ChatReactions({ own, children }: { own: boolean; children: ReactNode }) {
return (
<div
style={{
display: 'flex',
gap: 3,
marginTop: -6,
marginBottom: 4,
justifyContent: own ? 'flex-end' : 'flex-start',
paddingInlineStart: own ? 0 : 8,
paddingInlineEnd: own ? 8 : 0,
position: 'relative',
zIndex: 1,
}}
>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 2,
padding: '3px 6px',
borderRadius: 99,
background: 'var(--bg-card)',
boxShadow: '0 1px 6px rgba(0,0,0,0.12)',
border: '1px solid var(--border-faint)',
}}
>
{children}
</div>
</div>
);
}
/** The time under the last message of a group, or what stands in its place. */
export function ChatTime({ children }: { children: ReactNode }) {
return (
<span
style={{
fontSize: 'calc(9px * var(--fs-scale-caption, 1))',
color: 'var(--text-faint)',
marginTop: 2,
padding: '0 4px',
}}
>
{children}
</span>
);
}
@@ -1,19 +1,28 @@
import type { ReactNode } from 'react'
import { URL_REGEX } from './CollabChat.constants'
/* ── Message Text with clickable URLs ── */
interface MessageTextProps {
text: string
/** Links read as underlined words that open nothing, as in the assistant chat's copy of a message. */
plain?: boolean
}
export function MessageText({ text }: MessageTextProps) {
export function MessageText({ text, plain = false }: MessageTextProps) {
const parts = text.split(URL_REGEX)
const urls = text.match(URL_REGEX) || []
const result = []
const result: ReactNode[] = []
parts.forEach((part, i) => {
if (part) result.push(part)
if (urls[i]) result.push(
<a key={i} href={urls[i]} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline', textUnderlineOffset: 2, opacity: 0.85 }}>
{urls[i]}
const url = urls[i]
if (url && plain) result.push(
<span key={i} style={{ textDecoration: 'underline', textUnderlineOffset: 2, opacity: 0.85, overflowWrap: 'anywhere' }}>
{url}
</span>
)
else if (url) result.push(
<a key={i} href={url} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline', textUnderlineOffset: 2, opacity: 0.85 }}>
{url}
</a>
)
})
@@ -4,6 +4,7 @@ import { Tooltip } from '../shared/Tooltip'
import { URL_REGEX } from './CollabChat.constants'
import { formatChatClockTime, formatChatDateSeparator, isFromAssistant, shouldShowChatDateSeparator } from './collabModel'
import CollabAssistantBadge from './CollabAssistantBadge'
import { ChatAvatar, ChatBigEmoji, ChatBubble, ChatDateSeparator, ChatReactions, ChatSenderName, ChatTime } from './CollabChatBubble'
import { MessageText } from './CollabChatMessageText'
import { CollabChatAttachment } from './CollabChatAttachment'
import { LinkPreview } from './CollabChatLinkPreview'
@@ -52,13 +53,7 @@ export function ChatMessages(props: any) {
if (msg._deleted) {
return (
<React.Fragment key={msg.id}>
{showDate && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-card)', padding: '3px 12px', borderRadius: 99, letterSpacing: 0.3, textTransform: 'uppercase' }}>
{formatChatDateSeparator(msg.created_at, t)}
</span>
</div>
)}
{showDate && <ChatDateSeparator label={formatChatDateSeparator(msg.created_at, t)} />}
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}>
<span style={{ display: 'inline-flex', gap: 6, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontStyle: 'italic' }}>
<span>{msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'}</span>
@@ -69,27 +64,10 @@ export function ChatMessages(props: any) {
)
}
// Bubble border radius — iMessage style tails
const tail = isLastInGroup ? 4 : 18
const br = {
borderStartStartRadius: 18, borderStartEndRadius: 18,
borderEndEndRadius: own ? tail : 18, borderEndStartRadius: own ? 18 : tail,
}
return (
<React.Fragment key={msg.id}>
{/* Date separator */}
{showDate && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
<span style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)',
background: 'var(--bg-card)', padding: '3px 12px', borderRadius: 99,
letterSpacing: 0.3, textTransform: 'uppercase',
}}>
{formatChatDateSeparator(msg.created_at, t)}
</span>
</div>
)}
{showDate && <ChatDateSeparator label={formatChatDateSeparator(msg.created_at, t)} />}
<div style={{
display: 'flex', alignItems: own ? 'flex-end' : 'flex-start',
@@ -102,29 +80,13 @@ export function ChatMessages(props: any) {
{/* Avatar slot for others */}
{!own && (
<div style={{ width: 28, flexShrink: 0, alignSelf: 'flex-end' }}>
{showAvatar && (
msg.user_avatar ? (
<img src={msg.user_avatar} alt="" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} />
) : (
<div style={{
width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: 'var(--text-muted)',
}}>
{(msg.username || '?')[0].toUpperCase()}
</div>
)
)}
{showAvatar && <ChatAvatar name={msg.username} src={msg.user_avatar || null} />}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: own ? 'flex-end' : 'flex-start', maxWidth: '78%', minWidth: 0 }}>
{/* Username for others at group start */}
{!own && isNewGroup && (
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingInlineStart: 4 }}>
{msg.username}
</span>
)}
{!own && isNewGroup && <ChatSenderName>{msg.username}</ChatSenderName>}
{isFromAssistant(msg) && (
<span className="mb-0.5 ps-1">
<CollabAssistantBadge name={msg.username} />
@@ -149,41 +111,19 @@ export function ChatMessages(props: any) {
}}
>
{bigEmoji ? (
<div style={{ fontSize: 'calc(40px * var(--fs-scale-title, 1))', lineHeight: 1.2, padding: '2px 0' }}>
{msg.text}
</div>
<ChatBigEmoji>{msg.text}</ChatBigEmoji>
) : (
<div style={{
// Own messages in the accent, the others as cards on the tinted panel.
background: own ? 'var(--accent)' : 'var(--bg-card)',
color: own ? 'var(--accent-text)' : 'var(--text-primary)',
border: own ? 'none' : '1px solid var(--border-faint)',
...br, padding: hasReply ? '4px 4px 8px 4px' : '8px 14px',
fontSize: 'calc(14px * var(--fs-scale-body, 1))', lineHeight: 1.4, wordBreak: 'break-word', whiteSpace: 'pre-wrap',
}}>
{/* Inline reply quote */}
{hasReply && (
<div style={{
padding: '5px 10px', marginBottom: 4, borderRadius: 12,
background: own ? 'color-mix(in srgb, var(--accent-text) 15%, transparent)' : 'var(--bg-secondary)',
fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.3,
}}>
<div style={{ fontWeight: 600, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', opacity: 0.7, marginBottom: 1 }}>
{msg.reply_username || ''}
</div>
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{(msg.reply_text || '').slice(0, 80)}
</div>
</div>
)}
{hasReply ? (
<div style={{ padding: '0 10px 4px' }}><MessageText text={msg.text} /></div>
) : <MessageText text={msg.text} />}
<ChatBubble
own={own}
lastInGroup={isLastInGroup}
reply={hasReply ? { name: msg.reply_username || '', text: (msg.reply_text || '').slice(0, 80) } : null}
text={<MessageText text={msg.text} />}
>
{msg.attachments?.map((attachment: any) => <CollabChatAttachment key={attachment.id} attachment={attachment} />)}
{(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => (
<LinkPreview key={url} url={url} tripId={tripId} own={own} onLoad={() => { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} />
))}
</div>
</ChatBubble>
)}
{/* Hover actions: above the reactions of the message before, which reach into this row */}
@@ -228,30 +168,15 @@ export function ChatMessages(props: any) {
{/* Reactions — iMessage style floating badge */}
{msg.reactions?.length > 0 && (
<div style={{
display: 'flex', gap: 3, marginTop: -6, marginBottom: 4,
justifyContent: own ? 'flex-end' : 'flex-start',
paddingInlineStart: own ? 0 : 8, paddingInlineEnd: own ? 8 : 0,
position: 'relative', zIndex: 1,
}}>
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '3px 6px',
borderRadius: 99, background: 'var(--bg-card)',
boxShadow: '0 1px 6px rgba(0,0,0,0.12)', border: '1px solid var(--border-faint)',
}}>
{msg.reactions.map(r => (
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
))}
</div>
</div>
<ChatReactions own={own}>
{msg.reactions.map(r => (
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
))}
</ChatReactions>
)}
{/* Timestamp — only on last message of group */}
{isLastInGroup && (
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}>
{formatChatClockTime(msg.created_at, is12h)}
</span>
)}
{isLastInGroup && <ChatTime>{formatChatClockTime(msg.created_at, is12h)}</ChatTime>}
</div>
</div>
</React.Fragment>
@@ -0,0 +1,58 @@
import { Pin } from 'lucide-react';
import type { ReactNode } from 'react';
import { fs } from '../Planner/bookings/bookingParts';
import { tintOf } from '../Planner/planParts';
import { Tooltip } from '../shared/Tooltip';
/**
* The head band of a shared note, tinted by its category's colour: the
* category as a dot (its name in the tooltip), the title, a pin when it is
* pinned and whatever the caller puts at the end (the note card's link, menu
* and author). The Collab tab and the assistant chat's note cards draw it.
*/
export function NoteHeadBand({
title,
color,
category,
pinned,
bordered,
trailing,
titleClassName = '',
}: {
title: ReactNode;
/** The category's colour, already checked. */
color: string;
category: string | null;
pinned: boolean;
/** A line under the band, when a body follows. */
bordered: boolean;
trailing?: ReactNode;
/** Extra classes for the title (struck through for a note about to go). */
titleClassName?: string;
}) {
return (
<div
className={`flex items-center gap-2 py-1.5 ps-3 pe-1.5 ${bordered ? 'border-b border-edge-faint' : ''}`}
style={{ background: tintOf(color) }}
>
{/* The category is only its colour here; the name is in the tooltip and the filter above. */}
{category && (
<Tooltip label={category}>
<span
role="img"
aria-label={category}
data-testid="note-category-dot"
className="h-2.5 w-2.5 flex-none rounded-full"
style={{ background: color }}
/>
</Tooltip>
)}
<span className={`min-w-0 flex-1 truncate font-bold text-content ${titleClassName}`} style={fs(13, 'body')}>
{title}
</span>
{pinned && <Pin size={12} strokeWidth={2.2} className="flex-none" style={{ color }} />}
{trailing}
</div>
);
}
@@ -11,6 +11,7 @@ import { ContextMenu, useContextMenu } from '../shared/ContextMenu'
import { Eyebrow, fs } from '../Planner/bookings/bookingParts'
import { AuthedImg } from './CollabNotesAuthedImg'
import { UserAvatar } from './CollabNotesUserAvatar'
import { NoteHeadBand } from './CollabNoteHead'
import type { CollabNote, NoteFile } from './CollabNotes.types'
import type { User } from '../../types'
@@ -77,15 +78,7 @@ export function NoteCard({ note, canEdit, onUpdate, onDelete, onEdit, onView, on
// A pinned note keeps its category colour on the frame, the way a selected booking card does.
style={{ borderColor: note.pinned ? `color-mix(in srgb, ${color} 45%, transparent)` : 'var(--border-faint)' }}
>
<div className={`flex items-center gap-2 py-1.5 ps-3 pe-1.5 ${hasBody ? 'border-b border-edge-faint' : ''}`} style={{ background: `color-mix(in srgb, ${color} 11%, transparent)` }}>
{/* The category is only its colour here; the name is in the tooltip and the filter above. */}
{note.category && (
<Tooltip label={note.category}>
<span role="img" aria-label={note.category} data-testid="note-category-dot" className="h-2.5 w-2.5 flex-none rounded-full" style={{ background: color }} />
</Tooltip>
)}
<span className="min-w-0 flex-1 truncate font-bold text-content" style={fs(13, 'body')}>{note.title}</span>
{!!note.pinned && <Pin size={12} strokeWidth={2.2} className="flex-none" style={{ color }} />}
<NoteHeadBand title={note.title} color={color} category={note.category || null} pinned={!!note.pinned} bordered={hasBody} trailing={<>
{/* The link rides in the title row as one round button; the host is in its tooltip. */}
{note.website && (
<Tooltip label={websiteHref ? linkHost(websiteHref) : note.website}>
@@ -111,7 +104,7 @@ export function NoteCard({ note, canEdit, onUpdate, onDelete, onEdit, onView, on
<Tooltip label={author.username}>
<span role="img" aria-label={author.username} className="flex flex-none"><UserAvatar user={author} size={20} /></span>
</Tooltip>
</div>
</>} />
{hasBody && (
<div className="flex flex-1 flex-col gap-2 px-3 pb-2.5 pt-2">
@@ -0,0 +1,105 @@
/**
* FE-COLLAB-PARTS-001 to FE-COLLAB-PARTS-005
*
* The pieces the Collab tab and the assistant chat's cards draw a message, a
* poll option and a note's head band with: the bubble's sides, tail and
* quote, the face or its letter, the option as a button or a row with its
* results and a held vote, the tinted band with its dot and pin, and links
* that read as words where they must not open.
*/
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ChatAvatar, ChatBubble, ChatDateSeparator, ChatReactions, ChatTime } from './CollabChatBubble';
import { MessageText } from './CollabChatMessageText';
import { NoteHeadBand } from './CollabNoteHead';
import { PollChip, PollOptionRow } from './CollabPollOption';
describe('the Collab pieces', () => {
it('FE-COLLAB-PARTS-001: a bubble takes its side colour, its tail on the last of a run and the quote it answers', () => {
const { rerender } = render(<ChatBubble own lastInGroup reply={null} text="mine" />);
const own = screen.getByText('mine');
expect(own.getAttribute('style')).toContain('background: var(--accent)');
expect(own.getAttribute('style')).toContain('border-end-end-radius: 4px');
rerender(<ChatBubble own={false} lastInGroup={false} reply={{ name: 'Anna', text: 'Train?' }} text="yours" />);
const quoted = screen.getByText('yours');
expect(quoted.parentElement?.getAttribute('style')).toContain('background: var(--bg-card)');
expect(screen.getByText('Anna')).toBeInTheDocument();
expect(screen.getByText('Train?')).toBeInTheDocument();
});
it('FE-COLLAB-PARTS-002: a face is the picture, else the first letter; day, reactions and time draw their children', () => {
const { container, rerender } = render(<ChatAvatar name="jon" src={null} size={24} />);
expect(screen.getByText('J').getAttribute('style')).toContain('width: 24px');
rerender(<ChatAvatar name="jon" src="/uploads/avatars/jon.png" />);
expect(container.querySelector('img')?.getAttribute('src')).toBe('/uploads/avatars/jon.png');
rerender(
<>
<ChatDateSeparator label="Today" />
<ChatReactions own>
<span>❤️</span>
</ChatReactions>
<ChatTime>09:12</ChatTime>
</>
);
expect(screen.getByText('Today')).toBeInTheDocument();
expect(screen.getByText('❤️').parentElement?.parentElement?.getAttribute('style')).toContain('flex-end');
expect(screen.getByText('09:12')).toBeInTheDocument();
});
it('FE-COLLAB-PARTS-003: links open in a new tab in the chat and read as words where they must not open', () => {
const { container, rerender } = render(<MessageText text="see https://a.example/x now" />);
expect(container.querySelector('a')?.getAttribute('href')).toBe('https://a.example/x');
rerender(<MessageText text="see https://a.example/x now" plain />);
expect(container.querySelector('a')).toBeNull();
expect(screen.getByText('https://a.example/x').tagName).toBe('SPAN');
});
it('FE-COLLAB-PARTS-004: an option votes as a button, shows as a row, and marks a held vote', () => {
const onClick = vi.fn();
const voters = [{ key: 7, name: 'anna', src: null }];
const { rerender } = render(
<PollOptionRow
text="Pizza"
pct={50}
mine={false}
winner={false}
showResults={false}
voters={voters}
onClick={onClick}
/>
);
fireEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalled();
expect(screen.queryByText('50%')).toBeNull();
rerender(
<PollOptionRow
as="div"
text="Pizza"
pct={50}
mine
winner={false}
showResults
voters={voters}
pending={{ label: 'Your vote' }}
/>
);
expect(screen.queryByRole('button')).toBeNull();
expect(screen.getByText('50%')).toBeInTheDocument();
expect(screen.getByText('Your vote').className).toContain('sr-only');
expect(screen.getByText('Pizza').closest('div')?.className).toContain('border-dashed');
render(<PollChip tone="info">New</PollChip>);
expect(screen.getByText('New').className).toContain('text-info');
});
it("FE-COLLAB-PARTS-005: a note's band shows its category as a dot, its title and its pin", () => {
const { container } = render(
<NoteHeadBand title="Food" color="#10b981" category="Ideas" pinned bordered trailing={<span>end</span>} />
);
expect(screen.getByTestId('note-category-dot').style.background).toBe('rgb(16, 185, 129)');
expect(screen.getByText('Food')).toBeInTheDocument();
expect(screen.getByText('end')).toBeInTheDocument();
expect(container.querySelector('.lucide-pin')).not.toBeNull();
expect(container.firstElementChild?.className).toContain('border-b');
});
});
@@ -0,0 +1,161 @@
import { Check } from 'lucide-react';
import type { ReactNode } from 'react';
import { fs } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
/**
* The pieces a poll of the trip is drawn with, shared by the Collab tab and
* the assistant chat's poll cards: an option with its bar, its check, its
* voters and its share, a voter's face and a fact on the poll's head band.
* An option is a button where it can be voted for and a plain row where it
* only shows; a vote the assistant holds marks its option with a dashed
* frame until it is decided.
*/
/** A voter as an option shows them: a name and the picture TREK serves, if any. */
interface PollVoterFace {
key: string | number;
name: string;
src: string | null;
}
/** One voter's face; faces after the first overlap the one before. */
function VoterChip({ name, src, offset }: { name: string; src: string | null; offset: boolean }) {
return (
<Tooltip label={name || '?'}>
<span
className="grid h-[18px] w-[18px] flex-none place-items-center overflow-hidden rounded-full border-[1.5px] border-surface-card bg-surface-tertiary font-bold text-content-muted"
style={{ ...fs(7), marginInlineStart: offset ? -5 : 0 }}
>
{src ? (
<img src={src} alt={name} className="h-full w-full object-cover" />
) : (
(name || '?').charAt(0).toUpperCase()
)}
</span>
</Tooltip>
);
}
/** A fact about the poll on its head band, set like the type chip on a booking card. */
export function PollChip({
icon,
tone = 'muted',
children,
}: {
icon?: ReactNode;
tone?: 'muted' | 'warning' | 'info';
children: ReactNode;
}) {
const color = tone === 'warning' ? 'text-warning' : tone === 'info' ? 'text-info' : 'text-content-muted';
return (
<span
className={`inline-flex flex-none items-center gap-1 rounded-full border border-edge-faint bg-surface-card px-2 py-[2px] font-geist font-bold uppercase tracking-[.06em] ${color}`}
style={fs(9.5)}
>
{icon}
{children}
</span>
);
}
interface PollOptionRowProps {
text: string;
/** Its share of all votes, which the bar draws. */
pct: number;
/** The signed-in user voted for it. */
mine: boolean;
/** The poll ended and it has the most votes. */
winner: boolean;
/** Shares and voters show once the user voted or the poll ended. */
showResults: boolean;
voters: readonly PollVoterFace[];
/** A button to vote with, or a row that only shows. */
as?: 'button' | 'div';
/** A vote waiting for its decision: the frame is dashed, and says so to a screen reader. */
pending?: { label: string } | null;
disabled?: boolean;
onClick?: () => void;
}
/** One option of a poll, as the Collab tab draws it. */
export function PollOptionRow({
text,
pct,
mine,
winner,
showResults,
voters,
as = 'button',
pending = null,
disabled = false,
onClick,
}: PollOptionRowProps) {
const frame = pending
? 'border-dashed border-accent'
: mine
? 'border-accent'
: 'border-edge-faint enabled:hover:border-edge';
const body = (
<>
{/* The bar behind it: the user's choice in the accent, the winner in green. */}
<span
aria-hidden="true"
className="absolute inset-y-0 start-0 transition-[width] duration-500"
style={{
width: `${pct}%`,
background: mine
? 'color-mix(in srgb, var(--accent) 12%, transparent)'
: winner
? 'var(--success-soft)'
: 'var(--bg-tertiary)',
}}
/>
<span
className={`relative grid h-5 w-5 flex-none place-items-center rounded-full border-2 transition-colors ${mine ? 'border-accent bg-accent text-accent-text' : 'border-edge bg-surface-card'}`}
>
{mine && <Check size={11} strokeWidth={3} />}
</span>
{/* Plain text, but a long or multiline option wraps instead of being clipped (#2177). */}
<span
className="relative z-[1] text-content"
style={{
flex: 1,
minWidth: 0,
whiteSpace: 'pre-wrap',
overflowWrap: 'anywhere',
wordBreak: 'break-word',
lineHeight: 1.35,
...fs(13, 'body'),
fontWeight: mine || winner ? 600 : 400,
}}
>
{text}
</span>
{voters.length > 0 && showResults && (
<span className="relative z-[1] flex">
{voters.slice(0, 3).map((voter, index) => (
<VoterChip key={voter.key} name={voter.name} src={voter.src} offset={index > 0} />
))}
</span>
)}
{pending && <span className="sr-only">{pending.label}</span>}
{showResults && (
<span
className={`relative z-[1] min-w-[32px] text-end font-geist font-bold tabular-nums ${mine ? 'text-content' : 'text-content-muted'}`}
style={fs(12, 'body')}
>
{pct}%
</span>
)}
</>
);
const look = `relative flex w-full items-start gap-2.5 overflow-hidden rounded-[10px] border bg-surface-secondary px-3 py-2.5 text-start transition-colors disabled:cursor-default ${frame}`;
if (as === 'div') return <div className={look}>{body}</div>;
return (
<button type="button" onClick={onClick} disabled={disabled} className={look}>
{body}
</button>
);
}
+12 -75
View File
@@ -1,7 +1,8 @@
import React, { useState, useCallback, useId } from 'react'
import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
import { Plus, Trash2, X, BarChart3, Lock, Clock } from 'lucide-react'
import { RoundAction } from '../Planner/bookings/bookingParts'
import CollabPanelHead, { HEAD_ACTION } from './CollabPanelHead'
import { PollChip, PollOptionRow } from './CollabPollOption'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { AddRowButton, EditorField, TEXTAREA } from '../shared/dialogParts'
import { Tooltip } from '../shared/Tooltip'
@@ -19,12 +20,6 @@ import type { CollabPollCreateRequest } from '@trek/shared'
import { useCollabPolls } from './useCollabPolls'
import { hasUserVoted, isPollActive, pollMaxVoteCount, splitPolls, totalPollVotes, type CollabPollData } from './collabModel'
interface PollVoter {
user_id: number
username: string
avatar_url: string | null
}
const FONT = "var(--font-system)"
// Block styling for the markdown question (#2177). Tailwind's preflight resets
@@ -156,33 +151,6 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
)
}
// ── Voter Chip ───────────────────────────────────────────────────────────────
interface VoterChipProps {
voter: PollVoter
offset: boolean
}
function VoterChip({ voter, offset }: VoterChipProps) {
return (
<Tooltip label={voter.username || '?'}>
<span className="grid h-[18px] w-[18px] flex-none place-items-center overflow-hidden rounded-full border-[1.5px] border-surface-card bg-surface-tertiary font-bold text-content-muted"
style={{ ...fs(7), marginInlineStart: offset ? -5 : 0 }}>
{voter.avatar_url ? <img src={voter.avatar_url} alt={voter.username || ''} className="h-full w-full object-cover" /> : (voter.username || '?')[0].toUpperCase()}
</span>
</Tooltip>
)
}
/** A fact about the poll on its head band, set like the type chip on a booking card. */
function PollChip({ icon, tone = 'muted', children }: { icon?: React.ReactNode; tone?: 'muted' | 'warning'; children: React.ReactNode }) {
return (
<span className={`inline-flex flex-none items-center gap-1 rounded-full border border-edge-faint bg-surface-card px-2 py-[2px] font-geist font-bold uppercase tracking-[.06em] ${tone === 'warning' ? 'text-warning' : 'text-content-muted'}`} style={fs(9.5)}>
{icon}
{children}
</span>
)
}
// ── Poll Card ────────────────────────────────────────────────────────────────
interface PollCardProps {
poll: CollabPollData
@@ -238,48 +206,17 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
const isWinner = isClosed && count > 0 && count === topCount
return (
<button type="button" key={idx} onClick={() => onVote(poll.id, idx)}
<PollOptionRow
key={idx}
text={typeof opt === 'string' ? opt : opt.text}
pct={pct}
mine={myVote}
winner={isWinner}
showResults={hasVoted || isClosed}
voters={(opt.voters || []).map((v, vi) => ({ key: v.user_id || vi, name: v.username || '?', src: v.avatar_url }))}
disabled={isClosed}
className={`relative flex w-full items-start gap-2.5 overflow-hidden rounded-[10px] border bg-surface-secondary px-3 py-2.5 text-start transition-colors disabled:cursor-default ${myVote ? 'border-accent' : 'border-edge-faint enabled:hover:border-edge'}`}
>
{/* Progress bar background */}
<span aria-hidden="true" className="absolute inset-y-0 left-0 transition-[width] duration-500"
style={{
width: `${pct}%`,
background: myVote ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : isWinner ? 'var(--success-soft)' : 'var(--bg-tertiary)',
}} />
{/* Check circle */}
<span className={`relative grid h-5 w-5 flex-none place-items-center rounded-full border-2 transition-colors ${myVote ? 'border-accent bg-accent text-accent-text' : 'border-edge bg-surface-card'}`}>
{myVote && <Check size={11} strokeWidth={3} />}
</span>
{/* Label — options stay plain text, but long or multiline ones
must wrap instead of being clipped by the button's
overflow:hidden (#2177) */}
<span className="relative z-[1] text-content" style={{
flex: 1, minWidth: 0, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', wordBreak: 'break-word',
lineHeight: 1.35, ...fs(13, 'body'), fontWeight: myVote || isWinner ? 600 : 400,
}}>
{typeof opt === 'string' ? opt : opt.text}
</span>
{/* Voter avatars */}
{(opt.voters || []).length > 0 && (hasVoted || isClosed) && (
<span className="relative z-[1] flex">
{(opt.voters || []).slice(0, 3).map((v, vi) => (
<VoterChip key={v.user_id || vi} voter={v} offset={vi > 0} />
))}
</span>
)}
{/* Percentage */}
{(hasVoted || isClosed) && (
<span className={`relative z-[1] min-w-[32px] text-end font-geist font-bold tabular-nums ${myVote ? 'text-content' : 'text-content-muted'}`} style={fs(12, 'body')}>
{pct}%
</span>
)}
</button>
onClick={() => onVote(poll.id, idx)}
/>
)
})}
</div>
@@ -72,6 +72,20 @@ export const OTHER_BUBBLE_RADIUS = {
borderEndStartRadius: 16,
} as const;
/** The corners of a desktop chat bubble: round all over, with the tail (a tight
* corner on the sender's side) only on the last message of a group. Logical
* corners, so the tail follows the row in a right-to-left language. The Collab
* tab and the assistant's chat card draw with it. */
export function bubbleRadius(own: boolean, lastInGroup: boolean) {
const tail = lastInGroup ? 4 : 18;
return {
borderStartStartRadius: 18,
borderStartEndRadius: 18,
borderEndEndRadius: own ? tail : 18,
borderEndStartRadius: own ? 18 : tail,
};
}
// SQLite stores UTC without a 'Z' suffix — append one so JS parses it as UTC
// rather than local time.
export function parseUtcDate(iso: string): Date {