mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
feat(assistant): chat, note and poll cards drawn like the Collab tab
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
OTHER_BUBBLE_RADIUS,
|
||||
OWN_BUBBLE_RADIUS,
|
||||
QUICK_REACTIONS,
|
||||
bubbleRadius,
|
||||
buildCategoryColorMap,
|
||||
formatChatClockTime,
|
||||
formatChatDateSeparator,
|
||||
@@ -165,6 +166,17 @@ describe('collabModel — chat', () => {
|
||||
expect(shouldShowChatDateSeparator(c, b)).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-MOB-CLBM-024: bubbleRadius rounds a bubble and puts the tail on the sender side of the last one', () => {
|
||||
expect(bubbleRadius(true, false)).toEqual({
|
||||
borderStartStartRadius: 18,
|
||||
borderStartEndRadius: 18,
|
||||
borderEndEndRadius: 18,
|
||||
borderEndStartRadius: 18,
|
||||
});
|
||||
expect(bubbleRadius(true, true)).toMatchObject({ borderEndEndRadius: 4, borderEndStartRadius: 18 });
|
||||
expect(bubbleRadius(false, true)).toMatchObject({ borderEndEndRadius: 18, borderEndStartRadius: 4 });
|
||||
});
|
||||
|
||||
it('FE-MOB-CLBM-010: isSameSender compares user ids loosely and needs both messages', () => {
|
||||
const a = msg({ user_id: 7 });
|
||||
const b = msg({ id: 2, user_id: 7 });
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { AnswerInput, CardReader, PreviewContext, ToolArgs } from '../card-types';
|
||||
import { argId, clipped, recordOf, type Row } from '../card-values';
|
||||
import { rowsOf } from '../undo-snapshot';
|
||||
import { asRow, excerptOf, fitted, listed, personFrom, rowById, stampOf, viewerOf } from './collab-values';
|
||||
import {
|
||||
COLLAB_MESSAGE_ORIGIN_ASSISTANT,
|
||||
type AssistantChangePreview,
|
||||
type CollabBubbleCard,
|
||||
type CollabChatCard,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The trip chat in the assistant chat. A read shows the last messages it
|
||||
* returned as the chat draws them, the older ones counted; a held send shows
|
||||
* the bubble it will post (its text as the model wrote it, unmasked, since
|
||||
* that is what goes out), a held delete or reaction the message it touches
|
||||
* as it will look afterwards. A deleted message never shows, and nothing of a
|
||||
* file but its id travels.
|
||||
*/
|
||||
|
||||
/** Messages a read shows; the older ones are counted. */
|
||||
const SHOWN = 5;
|
||||
/** What a listed message shows of its text; the rest is in the chat. */
|
||||
const EXCERPT = 500;
|
||||
/** What a sent bubble holds; a longer message is cut and marked. */
|
||||
const SENT = 4000;
|
||||
|
||||
type Change = CollabBubbleCard['change'];
|
||||
|
||||
function isDeleted(row: Row): boolean {
|
||||
return !!row.deleted;
|
||||
}
|
||||
|
||||
function reactionsOf(row: Row, viewerId: number): CollabBubbleCard['reactions'] {
|
||||
return rowsOf(row.reactions)
|
||||
.flatMap((reaction) => {
|
||||
const emoji = typeof reaction.emoji === 'string' ? reaction.emoji : '';
|
||||
const users = rowsOf(reaction.users);
|
||||
if (emoji === '' || emoji.length > 16 || users.length === 0) return [];
|
||||
const names = users.map((user) => clipped(user.username, 80)).filter((name): name is string => name !== null);
|
||||
return [
|
||||
{
|
||||
emoji,
|
||||
count: Math.min(users.length, 999),
|
||||
mine: users.some((user) => user.user_id === viewerId),
|
||||
names: names.slice(0, 6),
|
||||
},
|
||||
];
|
||||
})
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
function filesOf(row: Row): CollabBubbleCard['files'] {
|
||||
return rowsOf(row.attachments).flatMap((file) => {
|
||||
const fileId = argId(file, 'id');
|
||||
if (fileId === null) return [];
|
||||
const mime = typeof file.mime_type === 'string' ? file.mime_type : '';
|
||||
return [{ fileId, image: mime.startsWith('image/'), name: clipped(file.original_name, 120) }];
|
||||
});
|
||||
}
|
||||
|
||||
/** The message a row answers, quoted; none when it was deleted or its author is unknown. */
|
||||
function replyOf(row: Row): CollabBubbleCard['reply'] {
|
||||
const name = clipped(row.reply_username, 80);
|
||||
const text = clipped(row.reply_text, 120);
|
||||
return name && text ? { name, text } : null;
|
||||
}
|
||||
|
||||
/** A listed message as a bubble: its text cut to `max` and masked, its reactions read for the viewer. */
|
||||
function bubbleOf(row: Row, viewerId: number, change: Change, max = EXCERPT): CollabBubbleCard | null {
|
||||
const id = argId(row, 'id');
|
||||
const author = personFrom(row);
|
||||
if (id === null || author === null || isDeleted(row)) return null;
|
||||
const excerpt = excerptOf(row.text, max, true) ?? { text: '', cut: false };
|
||||
const files = filesOf(row);
|
||||
if (excerpt.text === '' && files.length === 0) return null;
|
||||
return {
|
||||
id,
|
||||
author,
|
||||
text: excerpt.text,
|
||||
cut: excerpt.cut,
|
||||
at: stampOf(row.created_at),
|
||||
reply: replyOf(row),
|
||||
reactions: reactionsOf(row, viewerId),
|
||||
files: files.slice(0, 4),
|
||||
fromAssistant: row.origin === COLLAB_MESSAGE_ORIGIN_ASSISTANT,
|
||||
change,
|
||||
reactEmoji: null,
|
||||
};
|
||||
}
|
||||
|
||||
function chatOf(messages: CollabBubbleCard[], earlier = 0): CollabChatCard | null {
|
||||
return messages.length === 0 ? null : { kind: 'collabChat', messages, earlier: Math.min(earlier, 10_000) };
|
||||
}
|
||||
|
||||
/** `list_collab_messages`: the last few messages that are still there, oldest first, the older ones counted. */
|
||||
export function chatAnswer({ value, card }: AnswerInput): CollabChatCard | null {
|
||||
const kept = listed(value, 'messages')
|
||||
.map((row) => bubbleOf(row, card.viewerId, 'none'))
|
||||
.filter((bubble): bubble is CollabBubbleCard => bubble !== null);
|
||||
return fitted(kept.slice(-SHOWN), (shown) => chatOf(shown, kept.length - shown.length), 'first');
|
||||
}
|
||||
|
||||
/** The chat's last hundred messages, as a held write's lookup reads them. */
|
||||
export function loadMessages(_args: ToolArgs, read: CardReader): Promise<unknown> {
|
||||
return read('list_collab_messages');
|
||||
}
|
||||
|
||||
/** A send reads the chat only to quote the message it answers. */
|
||||
export function loadForReply(args: ToolArgs, read: CardReader): Promise<unknown> {
|
||||
return argId(args, 'replyTo') === null ? Promise.resolve(null) : read('list_collab_messages');
|
||||
}
|
||||
|
||||
/** The listed message a held call names, when it is still there. */
|
||||
function target(loaded: unknown, args: ToolArgs): Row | null {
|
||||
const row = rowById(listed(loaded, 'messages'), argId(args, 'messageId'));
|
||||
return row && !isDeleted(row) ? row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `send_collab_message`: the bubble as it will be posted, by the user, on
|
||||
* their side of the chat. The quoted message comes from the lookup; one that
|
||||
* is gone quotes nothing (the server refuses the reply then).
|
||||
*/
|
||||
export function sendChange(ctx: PreviewContext, args: ToolArgs, loaded: unknown): CollabChatCard | null {
|
||||
const author = viewerOf(ctx.card);
|
||||
const excerpt = excerptOf(args.text, SENT, false);
|
||||
if (!author || !excerpt) return null;
|
||||
const quoted = rowById(listed(loaded, 'messages'), argId(args, 'replyTo'));
|
||||
const reply =
|
||||
quoted && !isDeleted(quoted) ? replyOf({ reply_username: quoted.username, reply_text: quoted.text }) : null;
|
||||
return chatOf([
|
||||
{
|
||||
id: null,
|
||||
author,
|
||||
text: excerpt.text,
|
||||
cut: excerpt.cut,
|
||||
at: null,
|
||||
reply,
|
||||
reactions: [],
|
||||
files: [],
|
||||
// A message sent through the tool is posted as typed by the user, so it carries no mark.
|
||||
fromAssistant: false,
|
||||
change: 'send',
|
||||
reactEmoji: null,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/** `delete_collab_message`: the user's own message, struck; none for another's, which the server refuses. */
|
||||
export function removeChange(ctx: PreviewContext, args: ToolArgs, loaded: unknown): CollabChatCard | null {
|
||||
const row = target(loaded, args);
|
||||
if (!row || row.user_id !== ctx.card.viewerId) return null;
|
||||
const bubble = bubbleOf(row, ctx.card.viewerId, 'remove');
|
||||
return bubble ? chatOf([bubble]) : null;
|
||||
}
|
||||
|
||||
/** The reactions of a row after the viewer toggled `emoji` on it, as the chat groups them. */
|
||||
function toggled(row: Row, emoji: string, viewer: Row): Row {
|
||||
const reactions = rowsOf(row.reactions);
|
||||
const own = (user: Row) => user.user_id === viewer.user_id;
|
||||
const found = reactions.find((reaction) => reaction.emoji === emoji);
|
||||
if (!found) return { ...row, reactions: [...reactions, { emoji, users: [viewer] }] };
|
||||
const users = rowsOf(found.users);
|
||||
const next = users.some(own) ? users.filter((user) => !own(user)) : [...users, viewer];
|
||||
return {
|
||||
...row,
|
||||
reactions: reactions
|
||||
.map((reaction) => (reaction === found ? { ...reaction, users: next } : reaction))
|
||||
.filter((reaction) => rowsOf(reaction.users).length > 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** `react_collab_message`: the message with its reactions after the toggle, the toggled one marked. */
|
||||
export function reactChange(ctx: PreviewContext, args: ToolArgs, loaded: unknown): CollabChatCard | null {
|
||||
const row = target(loaded, args);
|
||||
const viewer = viewerOf(ctx.card);
|
||||
// Taken as sent, untrimmed: the server stores the string it gets, so " 👍" is a reaction of its own.
|
||||
const emoji = typeof args.emoji === 'string' ? args.emoji : '';
|
||||
if (!row || !viewer || emoji.trim() === '' || emoji.length > 16) return null;
|
||||
const bubble = bubbleOf(toggled(row, emoji, asRow(viewer)), ctx.card.viewerId, 'react');
|
||||
return bubble ? chatOf([{ ...bubble, reactEmoji: emoji }]) : null;
|
||||
}
|
||||
|
||||
/** The bubble a stored preview drew, for an applied send to keep what only the preview knew. */
|
||||
function previewBubble(preview: AssistantChangePreview | null): CollabBubbleCard | null {
|
||||
const card = preview?.card;
|
||||
return card?.kind === 'collabChat' ? (card.messages[0] ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `send_collab_message` once applied: the posted message with its id, time,
|
||||
* origin and files read from the answer. Its text stays the one the held card
|
||||
* showed, unmasked, since it is the user's own message: an address in it must
|
||||
* not turn into "[hidden]" the moment it is sent.
|
||||
*/
|
||||
export function sendApplied(input: { result: unknown; preview: AssistantChangePreview | null }): CollabChatCard | null {
|
||||
const row = recordOf(recordOf(input.result).message);
|
||||
const authorId = argId(row, 'user_id');
|
||||
if (authorId === null) return null;
|
||||
const held = previewBubble(input.preview);
|
||||
const bubble = bubbleOf(row, authorId, held?.change ?? 'send', SENT);
|
||||
if (!bubble) return null;
|
||||
return chatOf([held ? { ...bubble, text: held.text, cut: held.cut } : bubble]);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Described, PreviewContext, ToolArgs } from '../card-types';
|
||||
import { argId, clipped, type Row } from '../card-values';
|
||||
import { Rows, text } from '../preview-format';
|
||||
import { rowsOf } from '../undo-snapshot';
|
||||
import { lineOf, listed, rowById } from './collab-values';
|
||||
|
||||
/**
|
||||
* The words of the trip chat's change cards. Each keeps the generic verb of
|
||||
* what it does (add, change, remove) and names its tool, so the chat labels
|
||||
* it "Start a poll" or "Vote in the poll"; its subject is the message, the
|
||||
* question or the note's title, which the card itself shows and the receipt
|
||||
* and the share text still read. Rows only where the card is not enough on
|
||||
* its own: a note's title, category and pin before and after, and the option
|
||||
* a vote is for.
|
||||
*/
|
||||
|
||||
/** How long a subject taken from a message runs: its first line, cut. */
|
||||
const SUBJECT = 60;
|
||||
|
||||
type Describer = (ctx: PreviewContext, args: ToolArgs, loaded: unknown) => Described;
|
||||
|
||||
function described(tool: string, subject: string, rows = new Rows()): Described {
|
||||
return { subject, rows, dayId: null, placeIds: [], tool, domain: 'collab' };
|
||||
}
|
||||
|
||||
/** The first line of a text, cut to a subject; masked when it comes from an answer. */
|
||||
function firstLine(value: unknown, mask: boolean): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
const line = value.trim().split('\n')[0] ?? '';
|
||||
return (mask ? clipped(line, SUBJECT) : lineOf(line, SUBJECT)) ?? '';
|
||||
}
|
||||
|
||||
function heldRow(loaded: unknown, key: string, args: ToolArgs, idKey: string): Row | null {
|
||||
return rowById(listed(loaded, key), argId(args, idKey));
|
||||
}
|
||||
|
||||
function pinMark(value: unknown): string {
|
||||
return typeof value === 'boolean' ? text(value) : '';
|
||||
}
|
||||
|
||||
/** A note change's rows: what its title, category and pin go from and to. */
|
||||
function noteRows(args: ToolArgs, note: Row | null): Rows {
|
||||
const rows = new Rows();
|
||||
if (lineOf(args.title, 200) !== null) rows.change('name', clipped(note?.title, 200), text(args.title));
|
||||
if (lineOf(args.category, 100) !== null) rows.change('category', clipped(note?.category, 100), text(args.category));
|
||||
if (typeof args.pinned === 'boolean') {
|
||||
rows.rows.push({
|
||||
op: 'change',
|
||||
labelKey: 'field',
|
||||
field: 'pinned',
|
||||
before: note ? pinMark(!!note.pinned) : null,
|
||||
after: pinMark(args.pinned),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export const COLLAB_DESCRIBERS = {
|
||||
send_collab_message: (_ctx, args) => described('send_collab_message', firstLine(args.text, false)),
|
||||
delete_collab_message: (_ctx, args, loaded) => {
|
||||
const message = heldRow(loaded, 'messages', args, 'messageId');
|
||||
return described('delete_collab_message', firstLine(message?.text, true));
|
||||
},
|
||||
react_collab_message: (_ctx, args, loaded) => {
|
||||
const message = heldRow(loaded, 'messages', args, 'messageId');
|
||||
const emoji = lineOf(args.emoji, 16) ?? '';
|
||||
return described('react_collab_message', [emoji, firstLine(message?.text, true)].filter(Boolean).join(' '));
|
||||
},
|
||||
create_collab_poll: (_ctx, args) => described('create_collab_poll', firstLine(args.question, false)),
|
||||
vote_collab_poll: (_ctx, args, loaded) => {
|
||||
const poll = heldRow(loaded, 'polls', args, 'pollId');
|
||||
const index = typeof args.optionIndex === 'number' ? args.optionIndex : -1;
|
||||
const option = rowsOf(poll?.options)[index];
|
||||
const rows = new Rows().add('text', clipped(option?.text ?? option?.label, 160) ?? '');
|
||||
return described('vote_collab_poll', firstLine(poll?.question, true), rows);
|
||||
},
|
||||
close_collab_poll: (_ctx, args, loaded) =>
|
||||
described('close_collab_poll', firstLine(heldRow(loaded, 'polls', args, 'pollId')?.question, true)),
|
||||
delete_collab_poll: (_ctx, args, loaded) =>
|
||||
described('delete_collab_poll', firstLine(heldRow(loaded, 'polls', args, 'pollId')?.question, true)),
|
||||
create_collab_note: (_ctx, args) => {
|
||||
const rows = new Rows().add('category', lineOf(args.category, 100) ?? '');
|
||||
return described('create_collab_note', lineOf(args.title, 200) ?? '', rows);
|
||||
},
|
||||
update_collab_note: (_ctx, args, loaded) => {
|
||||
const note = heldRow(loaded, 'notes', args, 'noteId');
|
||||
const subject = clipped(note?.title, 200) ?? lineOf(args.title, 200) ?? '';
|
||||
return described('update_collab_note', subject, noteRows(args, note));
|
||||
},
|
||||
delete_collab_note: (_ctx, args, loaded) =>
|
||||
described('delete_collab_note', clipped(heldRow(loaded, 'notes', args, 'noteId')?.title, 200) ?? ''),
|
||||
} satisfies Readonly<Record<string, Describer>>;
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { AnswerInput, CardReader, PreviewContext, ToolArgs } from '../card-types';
|
||||
import { argId, blockText, clipped, hexOrNull, recordOf, type Row } from '../card-values';
|
||||
import { rowsOf } from '../undo-snapshot';
|
||||
import { hostOf, lineOf, listed, personFrom, rowById, viewerOf } from './collab-values';
|
||||
import type { AssistantChangePreview, CollabNoteCard, CollabNotesCard } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The shared notes of the trip in the assistant chat, as the Collab tab
|
||||
* draws them. A note takes its category's colour the way the tab gives it
|
||||
* (the last note of the category in list order wins), a pinned note comes
|
||||
* first. A held write shows the one note it adds, changes or deletes, after
|
||||
* the change, with what it changes listed.
|
||||
*/
|
||||
|
||||
/** Notes a read shows; the rest are counted. */
|
||||
const SHOWN = 4;
|
||||
/** What a card shows of a note's text: about three lines. */
|
||||
const BODY = 600;
|
||||
/** The category and colour the server gives a note created without them. */
|
||||
const DEFAULT_CATEGORY = 'General';
|
||||
const DEFAULT_COLOR = '#6366f1';
|
||||
|
||||
type Change = CollabNoteCard['change'];
|
||||
type Field = CollabNoteCard['changed'][number];
|
||||
|
||||
/** The colour each category takes in the tab: the last note of it in list order. */
|
||||
function categoryColors(rows: readonly Row[]): Map<string, string> {
|
||||
const colors = new Map<string, string>();
|
||||
for (const row of rows) {
|
||||
const color = hexOrNull(row.color);
|
||||
if (typeof row.category === 'string' && row.category !== '' && color) colors.set(row.category, color);
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
|
||||
/** A listed note as its card. Its text comes from an answer, so it is masked. */
|
||||
function noteOf(row: Row, colors: ReadonlyMap<string, string>, change: Change): CollabNoteCard | null {
|
||||
const noteId = argId(row, 'id');
|
||||
const title = clipped(row.title, 200);
|
||||
if (noteId === null || title === null) return null;
|
||||
const category = typeof row.category === 'string' ? row.category : null;
|
||||
return {
|
||||
noteId,
|
||||
title,
|
||||
body: blockText(row.content, BODY, { mask: true }),
|
||||
category: clipped(category, 100),
|
||||
color: (category ? colors.get(category) : undefined) ?? hexOrNull(row.color),
|
||||
pinned: !!row.pinned,
|
||||
websiteHost: hostOf(row.website),
|
||||
files: Math.min(rowsOf(row.attachments).length, 999),
|
||||
author: personFrom(row),
|
||||
change,
|
||||
changed: [],
|
||||
};
|
||||
}
|
||||
|
||||
function notesOf(notes: CollabNoteCard[], more = 0): CollabNotesCard | null {
|
||||
return notes.length === 0 ? null : { kind: 'collabNotes', notes, more: Math.min(more, 1_000_000) };
|
||||
}
|
||||
|
||||
/** When a note was last touched, for the tab's order. */
|
||||
function touchedAt(row: Row): number {
|
||||
const stamp = typeof row.updated_at === 'string' && row.updated_at ? row.updated_at : row.created_at;
|
||||
const ms = typeof stamp === 'string' ? Date.parse(stamp.includes('T') ? stamp : `${stamp.replace(' ', 'T')}Z`) : NaN;
|
||||
return Number.isNaN(ms) ? 0 : ms;
|
||||
}
|
||||
|
||||
/** `list_collab_notes`: up to four notes, pinned first, then the latest touched, as the tab sorts them. */
|
||||
export function notesAnswer({ value }: AnswerInput): CollabNotesCard | null {
|
||||
const rows = listed(value, 'notes');
|
||||
const colors = categoryColors(rows);
|
||||
const ordered = [...rows].sort((a, b) => Number(!!b.pinned) - Number(!!a.pinned) || touchedAt(b) - touchedAt(a));
|
||||
const notes = ordered
|
||||
.map((row) => noteOf(row, colors, 'none'))
|
||||
.filter((note): note is CollabNoteCard => note !== null);
|
||||
const shown = notes.slice(0, SHOWN);
|
||||
return notesOf(shown, notes.length - shown.length);
|
||||
}
|
||||
|
||||
/** The trip's notes, as a held note write reads them. */
|
||||
export function loadNotes(_args: ToolArgs, read: CardReader): Promise<unknown> {
|
||||
return read('list_collab_notes');
|
||||
}
|
||||
|
||||
/**
|
||||
* `create_collab_note`: the new note as the server will store it, its text
|
||||
* as the model wrote it. Without a category it lands in "General"; without a
|
||||
* colour it takes its category's, as the tab draws it.
|
||||
*/
|
||||
export function createNoteChange(ctx: PreviewContext, args: ToolArgs, loaded: unknown): CollabNotesCard | null {
|
||||
const author = viewerOf(ctx.card);
|
||||
const title = lineOf(args.title, 200);
|
||||
if (!author || title === null) return null;
|
||||
const category = lineOf(args.category, 100) ?? DEFAULT_CATEGORY;
|
||||
const color = hexOrNull(args.color) ?? categoryColors(listed(loaded, 'notes')).get(category) ?? DEFAULT_COLOR;
|
||||
return notesOf([
|
||||
{
|
||||
noteId: null,
|
||||
title,
|
||||
body: blockText(args.content, BODY, { mask: false }),
|
||||
category,
|
||||
color,
|
||||
pinned: args.pinned === true,
|
||||
websiteHost: hostOf(args.website),
|
||||
files: 0,
|
||||
author,
|
||||
change: 'create',
|
||||
changed: [],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/** The listed note a held call names, as its card. */
|
||||
function heldNote(loaded: unknown, args: ToolArgs, change: Change): CollabNoteCard | null {
|
||||
const rows = listed(loaded, 'notes');
|
||||
const row = rowById(rows, argId(args, 'noteId'));
|
||||
return row ? noteOf(row, categoryColors(rows), change) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `update_collab_note`: the note with the arguments laid over it as the
|
||||
* server applies them (an empty title, category or colour keeps the old one,
|
||||
* a link of null removes it), and which of its parts change.
|
||||
*/
|
||||
export function updateNoteChange(_ctx: PreviewContext, args: ToolArgs, loaded: unknown): CollabNotesCard | null {
|
||||
const before = heldNote(loaded, args, 'update');
|
||||
if (!before) return null;
|
||||
const row = rowById(listed(loaded, 'notes'), argId(args, 'noteId'));
|
||||
const after: CollabNoteCard = {
|
||||
...before,
|
||||
title: lineOf(args.title, 200) ?? before.title,
|
||||
body: args.content === undefined ? before.body : blockText(args.content, BODY, { mask: false }),
|
||||
category: lineOf(args.category, 100) ?? before.category,
|
||||
color: hexOrNull(args.color) ?? before.color,
|
||||
pinned: typeof args.pinned === 'boolean' ? args.pinned : before.pinned,
|
||||
websiteHost: args.website === undefined ? before.websiteHost : hostOf(args.website),
|
||||
};
|
||||
// Compared with the stored note, not the masked card, so an address in it reads as unchanged.
|
||||
const stored: Row = row ?? {};
|
||||
const set = (value: string | null, was: unknown) => value !== null && value !== lineOf(was, 200);
|
||||
const differs: Record<Field, boolean> = {
|
||||
title: set(lineOf(args.title, 200), stored.title),
|
||||
body: args.content !== undefined && (args.content ?? '') !== (stored.content ?? ''),
|
||||
category: set(lineOf(args.category, 100), stored.category),
|
||||
color: set(hexOrNull(args.color), stored.color),
|
||||
website: args.website !== undefined && (args.website ?? null) !== (stored.website ?? null),
|
||||
pinned: typeof args.pinned === 'boolean' && args.pinned !== !!stored.pinned,
|
||||
};
|
||||
const changed = (Object.keys(differs) as Field[]).filter((field) => differs[field]);
|
||||
return notesOf([{ ...after, changed }]);
|
||||
}
|
||||
|
||||
/** `delete_collab_note`: the note as it is, struck. */
|
||||
export function removeNoteChange(_ctx: PreviewContext, args: ToolArgs, loaded: unknown): CollabNotesCard | null {
|
||||
const note = heldNote(loaded, args, 'remove');
|
||||
return note ? notesOf([note]) : null;
|
||||
}
|
||||
|
||||
/** The note a stored preview drew. */
|
||||
function previewNote(preview: AssistantChangePreview | null): CollabNoteCard | null {
|
||||
const card = preview?.card;
|
||||
return card?.kind === 'collabNotes' ? (card.notes[0] ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A note write once applied: the note as the server answered it, with its
|
||||
* id. Its colour is the one the preview showed, which knew the category's.
|
||||
*/
|
||||
export function noteApplied(
|
||||
fallback: Change,
|
||||
input: { result: unknown; preview: AssistantChangePreview | null },
|
||||
): CollabNotesCard | null {
|
||||
const shown = previewNote(input.preview);
|
||||
const row = recordOf(recordOf(input.result).note);
|
||||
const colors = new Map<string, string>();
|
||||
if (shown?.category && shown.color) colors.set(shown.category, shown.color);
|
||||
const note = noteOf(row, colors, shown?.change ?? fallback);
|
||||
return note ? notesOf([{ ...note, changed: shown?.changed ?? [] }]) : null;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { AnswerInput, CardReader, PreviewContext, ToolArgs } from '../card-types';
|
||||
import { argId, blockText, clipped, recordOf, type Row } from '../card-values';
|
||||
import { rowsOf } from '../undo-snapshot';
|
||||
import { asRow, deadlineOf, fitted, hasPassed, lineOf, listed, personFrom, rowById, viewerOf } from './collab-values';
|
||||
import type { AssistantChangePreview, CollabPollCard, CollabPollsCard } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The polls of the trip in the assistant chat, as the Collab tab draws them.
|
||||
* A read shows up to four polls, the open ones first; a held write shows the
|
||||
* one poll it starts, votes in, closes or deletes, after the change. A vote
|
||||
* is worked out the way the server casts it: the same option again takes it
|
||||
* back, and a single choice poll moves the vote. Who voted for what is read
|
||||
* for the signed-in user (`mine`), which the client cannot tell from the
|
||||
* three faces a card carries.
|
||||
*/
|
||||
|
||||
/** Polls a read shows; the rest are counted. */
|
||||
const SHOWN = 4;
|
||||
/** Options a poll shows; the rest are counted. */
|
||||
const OPTIONS = 12;
|
||||
|
||||
type Change = CollabPollCard['change'];
|
||||
|
||||
/** Whether `viewerId` voted for an option, given its voters and where it stands. */
|
||||
type MineOf = (voters: readonly Row[], index: number) => boolean;
|
||||
|
||||
const byViewer =
|
||||
(viewerId: number): MineOf =>
|
||||
(voters) =>
|
||||
voters.some((voter) => voter.user_id === viewerId);
|
||||
|
||||
function isClosed(row: Row): boolean {
|
||||
return row.is_closed === true || (row.is_closed === undefined && !!row.closed);
|
||||
}
|
||||
|
||||
function isMultiple(row: Row): boolean {
|
||||
return row.multiple_choice === true || (row.multiple_choice === undefined && !!row.multiple);
|
||||
}
|
||||
|
||||
/** A listed poll as its card, the options' votes read for the viewer. */
|
||||
function pollOf(row: Row, mineOf: MineOf, change: Change): CollabPollCard | null {
|
||||
const pollId = argId(row, 'id');
|
||||
const question = blockText(row.question, 300, { mask: true });
|
||||
const all = rowsOf(row.options);
|
||||
if (pollId === null || question === null || all.length === 0) return null;
|
||||
return {
|
||||
pollId,
|
||||
question,
|
||||
options: all.slice(0, OPTIONS).map((option, index) => {
|
||||
const voters = rowsOf(option.voters);
|
||||
return {
|
||||
text: clipped(option.text ?? option.label, 160) ?? `#${index + 1}`,
|
||||
votes: Math.min(voters.length, 9999),
|
||||
mine: mineOf(voters, index),
|
||||
voters: voters.slice(0, 3).flatMap((voter) => {
|
||||
const person = personFrom(voter);
|
||||
return person ? [person] : [];
|
||||
}),
|
||||
};
|
||||
}),
|
||||
moreOptions: Math.min(Math.max(all.length - OPTIONS, 0), 500),
|
||||
multiple: isMultiple(row),
|
||||
closed: isClosed(row),
|
||||
deadline: deadlineOf(row.deadline),
|
||||
author: personFrom(row),
|
||||
change,
|
||||
votedIndex: null,
|
||||
votedAdds: false,
|
||||
warning: null,
|
||||
};
|
||||
}
|
||||
|
||||
function pollsOf(polls: CollabPollCard[], more = 0): CollabPollsCard | null {
|
||||
return polls.length === 0 ? null : { kind: 'collabPolls', polls, more: Math.min(more, 1_000_000) };
|
||||
}
|
||||
|
||||
/** `list_collab_polls`: up to four polls, the open ones first as the Collab tab lists them. */
|
||||
export function pollsAnswer({ value, card }: AnswerInput, now = Date.now()): CollabPollsCard | null {
|
||||
const open = (row: Row) => !isClosed(row) && !hasPassed(deadlineOf(row.deadline), now);
|
||||
const rows = listed(value, 'polls');
|
||||
const ordered = [...rows.filter(open), ...rows.filter((row) => !open(row))];
|
||||
const polls = ordered
|
||||
.map((row) => pollOf(row, byViewer(card.viewerId), 'none'))
|
||||
.filter((poll): poll is CollabPollCard => poll !== null);
|
||||
return fitted(polls.slice(0, SHOWN), (shown) => pollsOf(shown, polls.length - shown.length), 'last');
|
||||
}
|
||||
|
||||
/** The trip's polls, as a held vote, close or delete reads them. */
|
||||
export function loadPolls(_args: ToolArgs, read: CardReader): Promise<unknown> {
|
||||
return read('list_collab_polls');
|
||||
}
|
||||
|
||||
function heldPoll(loaded: unknown, args: ToolArgs): Row | null {
|
||||
return rowById(listed(loaded, 'polls'), argId(args, 'pollId'));
|
||||
}
|
||||
|
||||
/** `create_collab_poll`: the new poll, by the user, with its options as the model wrote them and no votes. */
|
||||
export function createPollChange(ctx: PreviewContext, args: ToolArgs): CollabPollsCard | null {
|
||||
const author = viewerOf(ctx.card);
|
||||
const question = blockText(args.question, 300, { mask: false });
|
||||
const texts = Array.isArray(args.options) ? args.options : [];
|
||||
// A blank option keeps its place as "#n", as a listed poll shows it: the server stores
|
||||
// every option and votes go by index, so dropping one would shift the rest.
|
||||
const options = texts.map((text, index) => lineOf(text, 160) ?? `#${index + 1}`);
|
||||
if (!author || question === null || options.length === 0) return null;
|
||||
return pollsOf([
|
||||
{
|
||||
pollId: null,
|
||||
question,
|
||||
options: options.slice(0, OPTIONS).map((text) => ({ text, votes: 0, mine: false, voters: [] })),
|
||||
moreOptions: Math.min(Math.max(options.length - OPTIONS, 0), 500),
|
||||
multiple: args.multiple === true,
|
||||
closed: false,
|
||||
deadline: deadlineOf(args.deadline),
|
||||
author,
|
||||
change: 'create',
|
||||
votedIndex: null,
|
||||
votedAdds: false,
|
||||
warning: null,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The options of a poll after the viewer's vote on `index`, as the server
|
||||
* casts it: a vote already there is taken back; otherwise a single choice
|
||||
* poll first takes the viewer off every option.
|
||||
*/
|
||||
function voted(options: readonly Row[], index: number, viewer: Row, multiple: boolean) {
|
||||
const own = (voter: Row) => voter.user_id === viewer.user_id;
|
||||
const adds = !rowsOf(options[index]?.voters).some(own);
|
||||
const after = options.map((option, at) => {
|
||||
const voters = rowsOf(option.voters);
|
||||
if (at === index) return { ...option, voters: adds ? [...voters, viewer] : voters.filter((v) => !own(v)) };
|
||||
return adds && !multiple ? { ...option, voters: voters.filter((v) => !own(v)) } : option;
|
||||
});
|
||||
return { after, adds };
|
||||
}
|
||||
|
||||
/**
|
||||
* `vote_collab_poll`: the poll after the vote, the toggled option marked. A
|
||||
* vote the server refuses (a closed poll, an option it does not have) shows
|
||||
* the poll as it is, with the reason.
|
||||
*/
|
||||
export function voteChange(ctx: PreviewContext, args: ToolArgs, loaded: unknown): CollabPollsCard | null {
|
||||
const row = heldPoll(loaded, args);
|
||||
const viewer = viewerOf(ctx.card);
|
||||
if (!row || !viewer) return null;
|
||||
const options = rowsOf(row.options);
|
||||
const index = typeof args.optionIndex === 'number' && Number.isInteger(args.optionIndex) ? args.optionIndex : -1;
|
||||
const mine = byViewer(viewer.id);
|
||||
const known = index >= 0 && index < options.length;
|
||||
if (!known || isClosed(row)) {
|
||||
const poll = pollOf(row, mine, 'vote');
|
||||
const marked = known && index < OPTIONS ? index : null;
|
||||
return poll && pollsOf([{ ...poll, votedIndex: marked, warning: known ? 'closed' : 'unknownOption' }]);
|
||||
}
|
||||
const { after, adds } = voted(options, index, asRow(viewer), isMultiple(row));
|
||||
const poll = pollOf({ ...row, options: after }, mine, 'vote');
|
||||
return poll && pollsOf([{ ...poll, votedIndex: index < OPTIONS ? index : null, votedAdds: adds }]);
|
||||
}
|
||||
|
||||
/** `close_collab_poll`: the poll, closed; the client marks its leader. */
|
||||
export function closeChange(ctx: PreviewContext, args: ToolArgs, loaded: unknown): CollabPollsCard | null {
|
||||
const row = heldPoll(loaded, args);
|
||||
const poll = row && pollOf({ ...row, is_closed: true }, byViewer(ctx.card.viewerId), 'close');
|
||||
return poll ? pollsOf([poll]) : null;
|
||||
}
|
||||
|
||||
/** `delete_collab_poll`: the poll as it is, struck. */
|
||||
export function removePollChange(ctx: PreviewContext, args: ToolArgs, loaded: unknown): CollabPollsCard | null {
|
||||
const row = heldPoll(loaded, args);
|
||||
const poll = row && pollOf(row, byViewer(ctx.card.viewerId), 'remove');
|
||||
return poll ? pollsOf([poll]) : null;
|
||||
}
|
||||
|
||||
/** The poll a stored preview drew. */
|
||||
function previewPoll(preview: AssistantChangePreview | null): CollabPollCard | null {
|
||||
const card = preview?.card;
|
||||
return card?.kind === 'collabPolls' ? (card.polls[0] ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A poll write once applied: the poll as the server answered it, with its id
|
||||
* and the votes everyone cast by then. The applied card has no trip card, so
|
||||
* which options are the user's comes from the preview's after-state.
|
||||
*/
|
||||
export function pollApplied(
|
||||
fallback: Change,
|
||||
input: { result: unknown; preview: AssistantChangePreview | null },
|
||||
): CollabPollsCard | null {
|
||||
const shown = previewPoll(input.preview);
|
||||
const mine: MineOf = (_voters, index) => shown?.options[index]?.mine ?? false;
|
||||
const poll = pollOf(recordOf(recordOf(input.result).poll), mine, shown?.change ?? fallback);
|
||||
if (!poll) return null;
|
||||
return pollsOf([{ ...poll, votedIndex: shown?.votedIndex ?? null, votedAdds: shown?.votedAdds ?? false }]);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ToolCards, UndoSnapshot } from '../card-types';
|
||||
import { idIn, readKey, recordOf, type Row } from '../card-values';
|
||||
import { rowsOf } from '../undo-snapshot';
|
||||
import { listed, rowById } from './collab-values';
|
||||
|
||||
/**
|
||||
* The way back from a note or a poll the assistant added: deleting it, as
|
||||
* long as nobody built on it since. A note must still read as it was created
|
||||
* (title, text, last change) and hold no file, since a new note starts with
|
||||
* none and any file is a teammate's; a poll must have no votes and still be
|
||||
* open. A sent message has no way back: deleting it leaves "deleted a
|
||||
* message" in everyone's chat.
|
||||
*/
|
||||
|
||||
type Undo = NonNullable<ToolCards['undo']>;
|
||||
|
||||
const NOTES_READ = { tool: 'list_collab_notes', args: {} };
|
||||
const POLLS_READ = { tool: 'list_collab_polls', args: {} };
|
||||
|
||||
/** The row a list read of the undo holds under `key` with the created row's id. */
|
||||
function current(snapshot: UndoSnapshot, read: { tool: string; args: Row }, key: string, created: Row): Row | null {
|
||||
const id = typeof created.id === 'number' ? created.id : null;
|
||||
return rowById(listed(snapshot.reads.get(readKey(read.tool, read.args)), key), id);
|
||||
}
|
||||
|
||||
/** A created note, still as it was created and without a file someone added. */
|
||||
function noteUnchanged(result: unknown, snapshot: UndoSnapshot): boolean {
|
||||
const created = recordOf(recordOf(result).note);
|
||||
const now = current(snapshot, NOTES_READ, 'notes', created);
|
||||
return (
|
||||
now !== null &&
|
||||
now.title === created.title &&
|
||||
(now.content ?? null) === (created.content ?? null) &&
|
||||
now.updated_at === created.updated_at &&
|
||||
rowsOf(now.attachments).length === 0
|
||||
);
|
||||
}
|
||||
|
||||
/** A created poll nobody voted in and nobody closed. */
|
||||
function pollUnchanged(result: unknown, snapshot: UndoSnapshot): boolean {
|
||||
const created = recordOf(recordOf(result).poll);
|
||||
const now = current(snapshot, POLLS_READ, 'polls', created);
|
||||
if (now === null || now.question !== created.question || now.is_closed === true) return false;
|
||||
return rowsOf(now.options).every((option) => rowsOf(option.voters).length === 0);
|
||||
}
|
||||
|
||||
export const DELETE_CREATED_NOTE: Undo = {
|
||||
tool: 'delete_collab_note',
|
||||
args: (_args, result) => {
|
||||
const noteId = idIn(result, 'note');
|
||||
return noteId ? { noteId } : null;
|
||||
},
|
||||
reads: () => [NOTES_READ],
|
||||
unchanged: noteUnchanged,
|
||||
};
|
||||
|
||||
export const DELETE_CREATED_POLL: Undo = {
|
||||
tool: 'delete_collab_poll',
|
||||
args: (_args, result) => {
|
||||
const pollId = idIn(result, 'poll');
|
||||
return pollId ? { pollId } : null;
|
||||
},
|
||||
reads: () => [POLLS_READ],
|
||||
unchanged: pollUnchanged,
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { TripCard } from '../../assistant-context';
|
||||
import type { ToolArgs } from '../card-types';
|
||||
import { argId, blockText, clipped, fileKeyOf, idIn, instantOf, personOf, recordOf, type Row } from '../card-values';
|
||||
import { rowsOf } from '../undo-snapshot';
|
||||
import { MAX_CARD_CHARS, type CardPerson } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The readers the trip chat's cards share: people as a collab row names them
|
||||
* (its author, a voter), the moments the chat stores, a text with its line
|
||||
* breaks and whether it was cut, and a link read as its host. Text from a
|
||||
* tool's answer is masked, text from a held call's arguments only cut: the
|
||||
* card shows exactly what will be posted.
|
||||
*/
|
||||
|
||||
/** Upload paths of TREK's own avatars; a picture on another host has no key. */
|
||||
const AVATAR_PATH = '/uploads/avatars/';
|
||||
|
||||
/** The person a collab row names: `idKey` and `nameKey` of the row, its `avatar_url` as a stored file name. */
|
||||
export function personFrom(row: Row, idKey = 'user_id', nameKey = 'username'): CardPerson | null {
|
||||
const id = argId(row, idKey);
|
||||
if (id === null) return null;
|
||||
return { id, name: clipped(row[nameKey], 80) ?? `#${id}`, avatarKey: fileKeyOf(row.avatar_url) };
|
||||
}
|
||||
|
||||
/** The signed-in user as the trip card names them; null when the card does not list them. */
|
||||
export function viewerOf(card: TripCard): CardPerson | null {
|
||||
return personOf(card, card.viewerId);
|
||||
}
|
||||
|
||||
/** A person as a collab row would name them, so a vote or a reaction the card adds reads like the others. */
|
||||
export function asRow(person: CardPerson): Row {
|
||||
return {
|
||||
user_id: person.id,
|
||||
username: person.name,
|
||||
avatar_url: person.avatarKey ? `${AVATAR_PATH}${person.avatarKey}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
const SQLITE_UTC = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})(?::(\d{2}))?(?:\.\d+)?$/;
|
||||
|
||||
/**
|
||||
* A moment as the cards carry it (`YYYY-MM-DDTHH:MM:SSZ`): one that names its
|
||||
* zone as it is, and one without, as SQLite stores its stamps (a message's
|
||||
* `created_at`), read as UTC the way the chat reads those. A date alone is
|
||||
* its midnight in UTC, as a browser parses it.
|
||||
*/
|
||||
export function stampOf(value: unknown): string | null {
|
||||
const zoned = instantOf(value);
|
||||
if (zoned || typeof value !== 'string') return zoned;
|
||||
const text = value.trim();
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return `${text}T00:00:00Z`;
|
||||
const match = SQLITE_UTC.exec(text);
|
||||
return match ? instantOf(`${match[1]}T${match[2]}:${match[3] ?? '00'}Z`) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A poll's deadline as the cards carry it. The deadline is stored as it was
|
||||
* sent and the Collab tab reads it with `new Date()`, so a date and time
|
||||
* without a zone is the viewer's own local time there: a different moment for
|
||||
* each viewer, which no instant on a card can match. Such a deadline gives
|
||||
* null (the card shows none rather than a countdown off by the viewer's
|
||||
* offset); one with a zone, and a date alone (midnight UTC in a browser too),
|
||||
* read as `stampOf` reads them. The app writes `toISOString()`, so only a
|
||||
* hand-made deadline lacks a zone.
|
||||
*/
|
||||
export function deadlineOf(value: unknown): string | null {
|
||||
if (typeof value === 'string' && SQLITE_UTC.test(value.trim())) return null;
|
||||
return stampOf(value);
|
||||
}
|
||||
|
||||
/** Whether a deadline lies behind `now`; a poll without one never ends by itself. */
|
||||
export function hasPassed(deadline: string | null, now: number): boolean {
|
||||
return deadline !== null && Date.parse(deadline) <= now;
|
||||
}
|
||||
|
||||
/** A text with its line breaks kept, cut to `max`, and whether anything was cut away. */
|
||||
export function excerptOf(value: unknown, max: number, mask: boolean): { text: string; cut: boolean } | null {
|
||||
const whole = blockText(value, Number.MAX_SAFE_INTEGER, { mask });
|
||||
if (whole === null) return null;
|
||||
return { text: blockText(whole, max, { mask: false }) ?? '', cut: whole.length > max };
|
||||
}
|
||||
|
||||
/** One line of a held call's arguments, cut but not masked. */
|
||||
export function lineOf(value: unknown, max: number): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const line = value.replace(/\s+/g, ' ').trim();
|
||||
if (line === '') return null;
|
||||
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
|
||||
}
|
||||
|
||||
/**
|
||||
* The host of a note's link, without "www.", for a web address only; any
|
||||
* other scheme, a bare word or a broken address gives null.
|
||||
*/
|
||||
export function hostOf(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || value.trim() === '') return null;
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||
const host = url.hostname.replace(/^www\./, '');
|
||||
return host !== '' && host.length <= 253 ? host : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The rows a list read answered under `key` (`{ messages: [...] }`). */
|
||||
export function listed(value: unknown, key: string): Row[] {
|
||||
return rowsOf(recordOf(value)[key]);
|
||||
}
|
||||
|
||||
/** The row of `rows` with the id an argument names. */
|
||||
export function rowById(rows: readonly Row[], id: number | null): Row | null {
|
||||
return id === null ? null : (rows.find((row) => row.id === id) ?? null);
|
||||
}
|
||||
|
||||
/** The receipt's ids from the row an answer names under `key` (`{ poll: { id } }`). */
|
||||
export function answeredId(key: string): (args: ToolArgs, result: unknown) => number[] {
|
||||
return (_args, result) => {
|
||||
const id = idIn(result, key);
|
||||
return id === null ? [] : [id];
|
||||
};
|
||||
}
|
||||
|
||||
/** The receipt's ids from the argument `key`, for a write whose answer names nothing. */
|
||||
export function namedId(key: string): (args: ToolArgs) => number[] {
|
||||
return (args) => {
|
||||
const id = argId(args, key);
|
||||
return id === null ? [] : [id];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The most of `items` a card holds within MAX_CARD_CHARS: one item less at a
|
||||
* time, from the end `drop` names, while the card is too heavy and more than
|
||||
* one is left. A read of long messages with many reactions shows fewer of
|
||||
* them rather than no card.
|
||||
*/
|
||||
export function fitted<T, C>(items: readonly T[], build: (shown: T[]) => C, drop: 'first' | 'last'): C {
|
||||
let shown = [...items];
|
||||
let card = build(shown);
|
||||
while (shown.length > 1 && JSON.stringify(card).length > MAX_CARD_CHARS) {
|
||||
shown = drop === 'first' ? shown.slice(1) : shown.slice(0, -1);
|
||||
card = build(shown);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
@@ -1,4 +1,123 @@
|
||||
import type { DomainCards } from '../card-types';
|
||||
import {
|
||||
chatAnswer,
|
||||
loadForReply,
|
||||
loadMessages,
|
||||
reactChange,
|
||||
removeChange,
|
||||
sendApplied,
|
||||
sendChange,
|
||||
} from './collab-chat';
|
||||
import { COLLAB_DESCRIBERS } from './collab-describe';
|
||||
import {
|
||||
createNoteChange,
|
||||
loadNotes,
|
||||
noteApplied,
|
||||
notesAnswer,
|
||||
removeNoteChange,
|
||||
updateNoteChange,
|
||||
} from './collab-notes';
|
||||
import {
|
||||
closeChange,
|
||||
createPollChange,
|
||||
loadPolls,
|
||||
pollApplied,
|
||||
pollsAnswer,
|
||||
removePollChange,
|
||||
voteChange,
|
||||
} from './collab-polls';
|
||||
import { DELETE_CREATED_NOTE, DELETE_CREATED_POLL } from './collab-undo';
|
||||
import { answeredId, namedId } from './collab-values';
|
||||
|
||||
/** The cards of the trip chat: messages, polls and shared notes. None yet: this package fills them in. */
|
||||
export const COLLAB_CARDS: DomainCards = { id: 'collab', tools: {} };
|
||||
/**
|
||||
* The cards of the trip chat: messages, polls and shared notes, drawn as the
|
||||
* Collab tab draws them. The three reads show what they found; every write
|
||||
* shows what it will post, change or remove, and the ones that create keep
|
||||
* the answer's id once applied. A new note or poll can be taken back while
|
||||
* nobody built on it; a sent message cannot.
|
||||
*/
|
||||
export const COLLAB_CARDS: DomainCards = {
|
||||
id: 'collab',
|
||||
tools: {
|
||||
list_collab_messages: { answer: chatAnswer },
|
||||
list_collab_polls: { answer: pollsAnswer },
|
||||
list_collab_notes: { answer: notesAnswer },
|
||||
send_collab_message: {
|
||||
write: { verbKey: 'genericCreate', entity: 'collab_message' },
|
||||
describe: COLLAB_DESCRIBERS.send_collab_message,
|
||||
load: loadForReply,
|
||||
change: sendChange,
|
||||
applied: sendApplied,
|
||||
appliedIds: answeredId('message'),
|
||||
},
|
||||
delete_collab_message: {
|
||||
write: { verbKey: 'genericRemove', entity: 'collab_message' },
|
||||
describe: COLLAB_DESCRIBERS.delete_collab_message,
|
||||
load: loadMessages,
|
||||
change: removeChange,
|
||||
appliedIds: namedId('messageId'),
|
||||
},
|
||||
react_collab_message: {
|
||||
write: { verbKey: 'genericUpdate', entity: 'collab_message' },
|
||||
describe: COLLAB_DESCRIBERS.react_collab_message,
|
||||
load: loadMessages,
|
||||
change: reactChange,
|
||||
appliedIds: namedId('messageId'),
|
||||
},
|
||||
create_collab_poll: {
|
||||
write: { verbKey: 'genericCreate', entity: 'collab_poll' },
|
||||
describe: COLLAB_DESCRIBERS.create_collab_poll,
|
||||
change: createPollChange,
|
||||
applied: (input) => pollApplied('create', input),
|
||||
undo: DELETE_CREATED_POLL,
|
||||
appliedIds: answeredId('poll'),
|
||||
},
|
||||
vote_collab_poll: {
|
||||
write: { verbKey: 'genericUpdate', entity: 'collab_poll' },
|
||||
describe: COLLAB_DESCRIBERS.vote_collab_poll,
|
||||
load: loadPolls,
|
||||
change: voteChange,
|
||||
applied: (input) => pollApplied('vote', input),
|
||||
appliedIds: answeredId('poll'),
|
||||
},
|
||||
close_collab_poll: {
|
||||
write: { verbKey: 'genericUpdate', entity: 'collab_poll' },
|
||||
describe: COLLAB_DESCRIBERS.close_collab_poll,
|
||||
load: loadPolls,
|
||||
change: closeChange,
|
||||
applied: (input) => pollApplied('close', input),
|
||||
appliedIds: answeredId('poll'),
|
||||
},
|
||||
delete_collab_poll: {
|
||||
write: { verbKey: 'genericRemove', entity: 'collab_poll' },
|
||||
describe: COLLAB_DESCRIBERS.delete_collab_poll,
|
||||
load: loadPolls,
|
||||
change: removePollChange,
|
||||
appliedIds: namedId('pollId'),
|
||||
},
|
||||
create_collab_note: {
|
||||
write: { verbKey: 'genericCreate', entity: 'collab_note' },
|
||||
describe: COLLAB_DESCRIBERS.create_collab_note,
|
||||
load: loadNotes,
|
||||
change: createNoteChange,
|
||||
applied: (input) => noteApplied('create', input),
|
||||
undo: DELETE_CREATED_NOTE,
|
||||
appliedIds: answeredId('note'),
|
||||
},
|
||||
update_collab_note: {
|
||||
write: { verbKey: 'genericUpdate', entity: 'collab_note' },
|
||||
describe: COLLAB_DESCRIBERS.update_collab_note,
|
||||
load: loadNotes,
|
||||
change: updateNoteChange,
|
||||
applied: (input) => noteApplied('update', input),
|
||||
appliedIds: answeredId('note'),
|
||||
},
|
||||
delete_collab_note: {
|
||||
write: { verbKey: 'genericRemove', entity: 'collab_note' },
|
||||
describe: COLLAB_DESCRIBERS.delete_collab_note,
|
||||
load: loadNotes,
|
||||
change: removeNoteChange,
|
||||
appliedIds: namedId('noteId'),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -70,7 +70,8 @@ function jsonContent(uri: string, data: unknown) {
|
||||
* vote_collab_poll gained the demo-user gate the legacy registrar was missing,
|
||||
* matching every other collab write tool. The two note write tools have since
|
||||
* gained `website`, which the REST route has always accepted and the note card
|
||||
* renders as a link preview.
|
||||
* renders as a link preview. list_collab_notes came later, for the assistant's
|
||||
* note cards: the notes resource as a tool, under the same gates.
|
||||
*/
|
||||
@McpController()
|
||||
export class CollabMcp {
|
||||
@@ -214,6 +215,22 @@ export class CollabMcp {
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'list_collab_notes',
|
||||
description: 'List the shared notes of a trip, pinned first, with their category, colour, link and files.',
|
||||
inputSchema: {
|
||||
tripId: idSchema,
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: collabNotesOn,
|
||||
access: { group: 'collab', mode: 'read' },
|
||||
})
|
||||
async listCollabNotes({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
if (!(await this.collab.verifyTripAccess(tripId, ctx.userId))) return noAccess();
|
||||
const notes = await this.collab.listNotes(tripId);
|
||||
return ok({ notes });
|
||||
}
|
||||
|
||||
// --- COLLAB POLLS & CHAT ---
|
||||
|
||||
@Tool({
|
||||
@@ -240,7 +257,10 @@ export class CollabMcp {
|
||||
question: z.string().min(1),
|
||||
options: z.array(z.string()).min(2).describe('Poll answer options (at least 2)'),
|
||||
multiple: z.boolean().optional().describe('Allow multiple choice'),
|
||||
deadline: z.string().optional().describe('ISO date string for poll deadline'),
|
||||
deadline: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Poll deadline as an ISO date-time with its zone, e.g. 2026-10-24T18:00:00+02:00 or ...Z'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: collabPollsOn,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Collab e2e for the notes as an MCP client reads them: list_collab_notes
|
||||
* called through a real MCP session on the whole app (buildApp, the real
|
||||
* registry with its addon and sub-feature gates, scopes and error mapper,
|
||||
* the real CollabService and a migrated temp SQLite), the way the
|
||||
* assistant's note cards read them.
|
||||
*/
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createLocalMcpServer, McpRegistryService } from '../../src/nest-mcp';
|
||||
import { withRequestContext } from '../../src/nest/database/request-context';
|
||||
import { makeCollabNote } from '../helpers/factories/collab';
|
||||
import { setAddonEnabled, setCollabFeature } from '../helpers/factories/settings';
|
||||
import { makeTrip } from '../helpers/factories/trips';
|
||||
import { makeUser } from '../helpers/factories/users';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
let app: INestApplication;
|
||||
let orm: MikroORM;
|
||||
|
||||
/** The tools `userId` is offered, and one call of `name` as that user with every scope: its JSON, or its error. */
|
||||
async function session(userId: number, name: string, args: Record<string, unknown>) {
|
||||
const server = createLocalMcpServer('TREK collab e2e');
|
||||
const around = (_info: unknown, call: () => unknown): unknown => withRequestContext(orm, call);
|
||||
await withRequestContext(orm, () =>
|
||||
app
|
||||
.get(McpRegistryService)
|
||||
.attach(server, { userId, scopes: null, isStaticToken: false }, { include: (kind) => kind === 'tool', around }),
|
||||
);
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const client = new Client({ name: 'collab-e2e', version: '1.0.0' });
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
try {
|
||||
const offered = (await client.listTools()).tools.map((tool) => tool.name);
|
||||
if (!offered.includes(name)) return { offered, answer: null };
|
||||
const result = await client.callTool({ name, arguments: args });
|
||||
const content = Array.isArray(result.content) ? (result.content as Array<{ text?: string }>) : [];
|
||||
const text = content[0]?.text ?? '';
|
||||
return { offered, answer: result.isError === true ? { error: text } : (JSON.parse(text) as unknown) };
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
orm = app.get(MikroORM);
|
||||
await setAddonEnabled(orm, 'collab', true);
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
describe('the shared notes over MCP', () => {
|
||||
it('COLLAB-MCP-E2E-001: lists the notes of the trip, pinned first, with their category, colour and link', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, user.id, { title: 'Rome' });
|
||||
await makeCollabNote(orm, trip.id, user.id, { title: 'Packing ideas', category: 'Ideas' });
|
||||
const pinned = await makeCollabNote(orm, trip.id, user.id, {
|
||||
title: 'Restaurants',
|
||||
category: 'Food',
|
||||
color: '#10b981',
|
||||
website: 'https://www.luigis.it/menu',
|
||||
pinned: 1,
|
||||
});
|
||||
const { answer } = await session(user.id, 'list_collab_notes', { tripId: trip.id });
|
||||
const notes = (answer as { notes: Array<Record<string, unknown>> }).notes;
|
||||
expect(notes.map((note) => note.title)).toEqual(['Restaurants', 'Packing ideas']);
|
||||
expect(notes[0]).toMatchObject({
|
||||
id: pinned.id,
|
||||
category: 'Food',
|
||||
color: '#10b981',
|
||||
website: 'https://www.luigis.it/menu',
|
||||
pinned: 1,
|
||||
username: user.username,
|
||||
attachments: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('COLLAB-MCP-E2E-002: refuses a trip the user is not part of', async () => {
|
||||
const { user: owner } = await makeUser(orm);
|
||||
const { user: stranger } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, owner.id);
|
||||
await makeCollabNote(orm, trip.id, owner.id, { title: 'Private' });
|
||||
const { answer } = await session(stranger.id, 'list_collab_notes', { tripId: trip.id });
|
||||
expect(answer).toEqual({ error: expect.any(String) });
|
||||
});
|
||||
|
||||
it('COLLAB-MCP-E2E-003: is gone while the notes are switched off, and back with them', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, user.id);
|
||||
await setCollabFeature(orm, 'notes', false);
|
||||
try {
|
||||
const { offered } = await session(user.id, 'list_collab_notes', { tripId: trip.id });
|
||||
expect(offered).not.toContain('list_collab_notes');
|
||||
expect(offered).toContain('list_collab_polls');
|
||||
} finally {
|
||||
await setCollabFeature(orm, 'notes', true);
|
||||
}
|
||||
expect((await session(user.id, 'list_collab_notes', { tripId: trip.id })).answer).toEqual({ notes: [] });
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ import { countRows, findRow, updateRows } from '../../helpers/factories/rows';
|
||||
import { FakeRealtimeService } from '../../helpers/fake-realtime';
|
||||
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
import { bootTestApp } from '../../helpers/test-app';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { resetTestDb, setCollabFeature } from '../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
@@ -825,3 +825,64 @@ describe('create_collab_note, as the Collab tab posts it', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool: list_collab_notes', () => {
|
||||
interface ListedNote {
|
||||
id: number;
|
||||
title: string;
|
||||
username: string;
|
||||
pinned: number | null;
|
||||
attachments: unknown[];
|
||||
}
|
||||
|
||||
async function listNotes(userId: number, tripId: number) {
|
||||
let listed: { isError?: unknown; data: unknown } = { data: null };
|
||||
await withHarness(userId, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_collab_notes', arguments: { tripId } });
|
||||
listed = { isError: result.isError, data: result.isError ? null : parseToolResult(result) };
|
||||
});
|
||||
return listed;
|
||||
}
|
||||
|
||||
it('MCP-COLLAB-010: lists the notes of the trip, pinned first, with their author and files', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createCollabNote(testDb, trip.id, user.id, { title: 'Ideas' });
|
||||
const pinned = createCollabNote(testDb, trip.id, user.id, { title: 'Restaurants' });
|
||||
await updateRows(orm, CollabNotes, { id: pinned.id }, { pinned: 1 });
|
||||
const { isError, data } = await listNotes(user.id, trip.id);
|
||||
expect(isError).not.toBe(true);
|
||||
const notes = (data as { notes: ListedNote[] }).notes;
|
||||
expect(notes.map((note) => note.title)).toEqual(['Restaurants', 'Ideas']);
|
||||
expect(notes[0]).toMatchObject({ id: pinned.id, pinned: 1, username: user.username, attachments: [] });
|
||||
});
|
||||
|
||||
it('MCP-COLLAB-011: refuses a trip the user is not part of, and reads nothing', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
createCollabNote(testDb, trip.id, owner.id, { title: 'Private' });
|
||||
const { isError, data } = await listNotes(user.id, trip.id);
|
||||
expect(isError).toBe(true);
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
|
||||
it('MCP-COLLAB-012: is a read, offered only while the notes are switched on', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const names = async () => {
|
||||
const h = await createMcpHarness({ realtime, userId: user.id, withResources: false });
|
||||
try {
|
||||
return (await h.client.listTools()).tools.map((tool) => [tool.name, tool.annotations?.readOnlyHint]);
|
||||
} finally {
|
||||
await h.cleanup();
|
||||
}
|
||||
};
|
||||
expect(await names()).toContainEqual(['list_collab_notes', true]);
|
||||
setCollabFeature(testDb, 'notes', false);
|
||||
try {
|
||||
expect((await names()).map(([name]) => name)).not.toContain('list_collab_notes');
|
||||
} finally {
|
||||
setCollabFeature(testDb, 'notes', true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
/**
|
||||
* The trip chat's cards in the assistant chat: messages, polls and shared
|
||||
* notes as a read answered them and as a held write will leave them, built
|
||||
* from the tools' raw answers and checked against the card union through the
|
||||
* registry's guard (a card that does not fit its kind comes back as null).
|
||||
* Text from an answer is masked, text the model wrote is not; no address of
|
||||
* a file or a picture travels; a vote is worked out as the server casts it.
|
||||
*/
|
||||
import { buildTripCard } from '../../../../../../src/nest/assistant/assistant-context';
|
||||
import { unchangedSinceApplied } from '../../../../../../src/nest/assistant/assistant-undo.helpers';
|
||||
import { loadFor } from '../../../../../../src/nest/assistant/cards/card-context';
|
||||
import type { AnswerInput, PreviewContext } from '../../../../../../src/nest/assistant/cards/card-types';
|
||||
import { readKey } from '../../../../../../src/nest/assistant/cards/card-values';
|
||||
import { deadlineOf, stampOf } from '../../../../../../src/nest/assistant/cards/collab/collab-values';
|
||||
import {
|
||||
answerCardOf,
|
||||
appliedCardOf,
|
||||
ASSISTANT_UNDO_MAP,
|
||||
ASSISTANT_WRITE_TOOLS,
|
||||
changeCardOf,
|
||||
undoReadsOf,
|
||||
} from '../../../../../../src/nest/assistant/cards/registry';
|
||||
import { appliedChange, describeToolCall } from '../../../../../../src/nest/assistant/tool-call-preview.helpers';
|
||||
import {
|
||||
assistantChangePreviewSchema,
|
||||
MAX_CARD_CHARS,
|
||||
type CollabChatCard,
|
||||
type CollabNotesCard,
|
||||
type CollabPollsCard,
|
||||
type TrekCard,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const logMock = vi.hoisted(() => ({ logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() }));
|
||||
vi.mock('../../../../../../src/nest/audit/audit-log.logger', () => logMock);
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
/** Anna (7) asks; Jon (8) signed in with a provider's picture. */
|
||||
const summary = {
|
||||
trip: { title: 'Rome' },
|
||||
members: {
|
||||
owner: { id: 7, username: 'anna', avatar_url: '/uploads/avatars/anna.png' },
|
||||
collaborators: [{ id: 8, username: 'jon', avatar_url: 'https://idp.test/jon.png' }],
|
||||
},
|
||||
};
|
||||
const card = buildTripCard(1, 7, summary, null, null);
|
||||
const ctx: PreviewContext = { card, categories: new Map(), locale: 'en', today: '2026-10-21', airport: () => null };
|
||||
const ANNA = { id: 7, name: 'anna', avatarKey: 'anna.png' };
|
||||
const JON = { id: 8, name: 'jon', avatarKey: null };
|
||||
/** Anna as a voter row names her: without a picture. */
|
||||
const ANNA_VOTER = { ...ANNA, avatarKey: null };
|
||||
const ANNA_ROW = { user_id: 7, username: 'anna', avatar_url: '/uploads/avatars/anna.png' };
|
||||
|
||||
const answer = (tool: string, value: unknown): TrekCard | null =>
|
||||
answerCardOf(tool, { toolCallId: 'call_1', args: {}, value, card, today: ctx.today } satisfies AnswerInput);
|
||||
|
||||
const message = (id: number, over: Row = {}): Row => ({
|
||||
id,
|
||||
trip_id: 1,
|
||||
user_id: 8,
|
||||
username: 'jon',
|
||||
avatar_url: 'https://idp.test/jon.png',
|
||||
text: `Message ${id}`,
|
||||
created_at: '2026-10-21 09:12:00',
|
||||
deleted: 0,
|
||||
origin: null,
|
||||
reply_to: null,
|
||||
reply_text: null,
|
||||
reply_username: null,
|
||||
reactions: [],
|
||||
attachments: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
const voter = (id: number, name: string) => ({ id, user_id: id, username: name, avatar: null, avatar_url: null });
|
||||
|
||||
const poll = (id: number, over: Row = {}): Row => ({
|
||||
id,
|
||||
trip_id: 1,
|
||||
user_id: 8,
|
||||
username: 'jon',
|
||||
avatar_url: null,
|
||||
question: `Question ${id}?`,
|
||||
options: [
|
||||
{ text: 'Trattoria', label: 'Trattoria', voters: [voter(8, 'jon')] },
|
||||
{ text: 'Market', label: 'Market', voters: [voter(7, 'anna')] },
|
||||
{ text: 'Noodles', label: 'Noodles', voters: [] },
|
||||
],
|
||||
is_closed: false,
|
||||
multiple_choice: false,
|
||||
deadline: null,
|
||||
created_at: '2026-10-20 10:00:00',
|
||||
...over,
|
||||
});
|
||||
|
||||
const note = (id: number, over: Row = {}): Row => ({
|
||||
id,
|
||||
trip_id: 1,
|
||||
user_id: 7,
|
||||
username: 'anna',
|
||||
avatar_url: '/uploads/avatars/anna.png',
|
||||
title: `Note ${id}`,
|
||||
content: null,
|
||||
category: 'General',
|
||||
color: '#6366f1',
|
||||
pinned: 0,
|
||||
website: null,
|
||||
created_at: '2026-10-20 10:00:00',
|
||||
updated_at: '2026-10-20 10:00:00',
|
||||
attachments: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
const chatOf = (built: TrekCard | null): CollabChatCard => {
|
||||
if (built?.kind !== 'collabChat') throw new Error(`no chat card: ${JSON.stringify(built)}`);
|
||||
return built;
|
||||
};
|
||||
const pollsOf = (built: TrekCard | null): CollabPollsCard => {
|
||||
if (built?.kind !== 'collabPolls') throw new Error(`no poll card: ${JSON.stringify(built)}`);
|
||||
return built;
|
||||
};
|
||||
const notesOf = (built: TrekCard | null): CollabNotesCard => {
|
||||
if (built?.kind !== 'collabNotes') throw new Error(`no note card: ${JSON.stringify(built)}`);
|
||||
return built;
|
||||
};
|
||||
|
||||
describe('the chat card of a read', () => {
|
||||
it('ASTCOLLAB-001: shows the last five messages still there, oldest first, and counts the older ones', () => {
|
||||
const rows = [1, 2, 3, 4, 5, 6, 7, 8].map((id) => message(id, id === 7 ? { deleted: 1, text: '' } : {}));
|
||||
const chat = chatOf(answer('list_collab_messages', { messages: rows }));
|
||||
expect(chat.messages.map((bubble) => bubble.id)).toEqual([3, 4, 5, 6, 8]);
|
||||
expect(chat.earlier).toBe(2);
|
||||
expect(chat.messages[0]).toMatchObject({ author: JON, at: '2026-10-21T09:12:00Z', change: 'none', cut: false });
|
||||
expect(answer('list_collab_messages', { messages: [message(1, { deleted: 1 })] })).toBeNull();
|
||||
expect(answer('list_collab_messages', { messages: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-002: reads reactions for the user, files by id, the assistant mark, and never an address', () => {
|
||||
const row = message(4, {
|
||||
origin: 'assistant',
|
||||
reactions: [
|
||||
{ emoji: '❤️', count: 2, users: [{ user_id: 7, username: 'anna' }, { user_id: 8, username: 'jon' }] },
|
||||
{ emoji: '👍', count: 1, users: [{ user_id: 8, username: 'jon' }] },
|
||||
],
|
||||
attachments: [
|
||||
{ id: 31, original_name: 'ticket.jpg', mime_type: 'image/jpeg', url: '/api/trips/1/files/31/download' },
|
||||
{ id: 32, original_name: 'plan.pdf', mime_type: 'application/pdf', url: '/api/trips/1/files/32/download' },
|
||||
],
|
||||
});
|
||||
const [bubble] = chatOf(answer('list_collab_messages', { messages: [row] })).messages;
|
||||
expect(bubble?.reactions).toEqual([
|
||||
{ emoji: '❤️', count: 2, mine: true, names: ['anna', 'jon'] },
|
||||
{ emoji: '👍', count: 1, mine: false, names: ['jon'] },
|
||||
]);
|
||||
expect(bubble?.files).toEqual([
|
||||
{ fileId: 31, image: true, name: 'ticket.jpg' },
|
||||
{ fileId: 32, image: false, name: 'plan.pdf' },
|
||||
]);
|
||||
expect(bubble?.fromAssistant).toBe(true);
|
||||
const json = JSON.stringify(bubble);
|
||||
expect(json).not.toContain('/api/');
|
||||
expect(json).not.toContain('http');
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-003: keeps line breaks, folds blank runs, masks an address and cuts a long message', () => {
|
||||
const text = 'Train at 7:40\n\n\n\nwrite to jon@example.test\nok';
|
||||
const [bubble] = chatOf(answer('list_collab_messages', { messages: [message(1, { text })] })).messages;
|
||||
expect(bubble?.text).toBe('Train at 7:40\n\nwrite to [hidden]\nok');
|
||||
const longText = { messages: [message(2, { text: 'x'.repeat(900) })] };
|
||||
const [long] = chatOf(answer('list_collab_messages', longText)).messages;
|
||||
expect(long?.cut).toBe(true);
|
||||
expect(long?.text).toHaveLength(500);
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-004: quotes the message a bubble answers, and nothing for a deleted one', () => {
|
||||
const reply = message(3, { reply_to: 2, reply_username: 'anna', reply_text: 'Shall we?' });
|
||||
const gone = message(4, { reply_to: 1, reply_username: 'anna', reply_text: '' });
|
||||
const chat = chatOf(answer('list_collab_messages', { messages: [reply, gone] }));
|
||||
expect(chat.messages.map((bubble) => bubble.reply)).toEqual([{ name: 'anna', text: 'Shall we?' }, null]);
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-005: a face is a stored file name: a provider picture or a way up gives none', () => {
|
||||
const rows = [
|
||||
message(1, { user_id: 7, username: 'anna', avatar_url: '/uploads/avatars/anna.png' }),
|
||||
message(2, { avatar_url: 'https://idp.test/jon.png' }),
|
||||
message(3, { avatar_url: '/uploads/avatars/../secret.png' }),
|
||||
];
|
||||
const chat = chatOf(answer('list_collab_messages', { messages: rows }));
|
||||
expect(chat.messages.map((bubble) => bubble.author.avatarKey)).toEqual(['anna.png', null, null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the chat card of a held write', () => {
|
||||
const listing = { messages: [message(1), message(2, ANNA_ROW), message(3, { deleted: 1 })] };
|
||||
|
||||
it('ASTCOLLAB-010: a send shows the bubble as it will be posted, unmasked, by the user', () => {
|
||||
const text = 'Mail me at anna@example.test\nthanks';
|
||||
const chat = chatOf(changeCardOf('send_collab_message', ctx, { text }, null));
|
||||
expect(chat.messages).toEqual([
|
||||
{
|
||||
id: null,
|
||||
author: ANNA,
|
||||
text,
|
||||
cut: false,
|
||||
at: null,
|
||||
reply: null,
|
||||
reactions: [],
|
||||
files: [],
|
||||
fromAssistant: false,
|
||||
change: 'send',
|
||||
reactEmoji: null,
|
||||
},
|
||||
]);
|
||||
const long = chatOf(changeCardOf('send_collab_message', ctx, { text: 'y'.repeat(4200) }, null));
|
||||
expect(long.messages[0]).toMatchObject({ cut: true });
|
||||
expect(long.messages[0]?.text).toHaveLength(4000);
|
||||
expect(changeCardOf('send_collab_message', ctx, {}, null)).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-011: a reply quotes the message it answers from the lookup, nothing when it is gone', async () => {
|
||||
const read = vi.fn().mockResolvedValue(listing);
|
||||
expect(await loadFor('send_collab_message', { text: 'Hi' }, read)).toBeNull();
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
expect(await loadFor('send_collab_message', { text: 'Yes', replyTo: 1 }, read)).toBe(listing);
|
||||
expect(read).toHaveBeenCalledWith('list_collab_messages', undefined);
|
||||
const quoted = chatOf(changeCardOf('send_collab_message', ctx, { text: 'Yes', replyTo: 1 }, listing));
|
||||
expect(quoted.messages[0]?.reply).toEqual({ name: 'jon', text: 'Message 1' });
|
||||
for (const replyTo of [3, 99]) {
|
||||
const none = chatOf(changeCardOf('send_collab_message', ctx, { text: 'Yes', replyTo }, listing));
|
||||
expect(none.messages[0]?.reply).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("ASTCOLLAB-012: a delete shows the user's own message struck, and no card for another's or a missing one", () => {
|
||||
const own = chatOf(changeCardOf('delete_collab_message', ctx, { messageId: 2 }, listing));
|
||||
expect(own.messages[0]).toMatchObject({ id: 2, change: 'remove', author: ANNA });
|
||||
for (const messageId of [1, 3, 99]) {
|
||||
expect(changeCardOf('delete_collab_message', ctx, { messageId }, listing)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-013: a reaction shows the message after the toggle, the toggled emoji marked', () => {
|
||||
const reacted = {
|
||||
messages: [
|
||||
message(1, {
|
||||
reactions: [
|
||||
{ emoji: '❤️', count: 1, users: [{ user_id: 7, username: 'anna' }] },
|
||||
{ emoji: '👍', count: 1, users: [{ user_id: 8, username: 'jon' }] },
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
const off = chatOf(changeCardOf('react_collab_message', ctx, { messageId: 1, emoji: '❤️' }, reacted));
|
||||
expect(off.messages[0]?.reactions).toEqual([{ emoji: '👍', count: 1, mine: false, names: ['jon'] }]);
|
||||
expect(off.messages[0]).toMatchObject({ change: 'react', reactEmoji: '❤️' });
|
||||
const more = chatOf(changeCardOf('react_collab_message', ctx, { messageId: 1, emoji: '👍' }, reacted));
|
||||
expect(more.messages[0]?.reactions[1]).toEqual({ emoji: '👍', count: 2, mine: true, names: ['jon', 'anna'] });
|
||||
const fresh = chatOf(changeCardOf('react_collab_message', ctx, { messageId: 1, emoji: '🎉' }, reacted));
|
||||
expect(fresh.messages[0]?.reactions[2]).toEqual({ emoji: '🎉', count: 1, mine: true, names: ['anna'] });
|
||||
expect(changeCardOf('react_collab_message', ctx, { messageId: 99, emoji: '🎉' }, reacted)).toBeNull();
|
||||
// Taken as sent: the server stores " 👍" as it gets it, a reaction of its own beside 👍.
|
||||
const spaced = chatOf(changeCardOf('react_collab_message', ctx, { messageId: 1, emoji: ' 👍' }, reacted));
|
||||
expect(spaced.messages[0]?.reactions.map((reaction) => reaction.emoji)).toEqual(['❤️', '👍', ' 👍']);
|
||||
expect(spaced.messages[0]?.reactEmoji).toBe(' 👍');
|
||||
expect(changeCardOf('react_collab_message', ctx, { messageId: 1, emoji: ' ' }, reacted)).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-014: an applied send carries the posted message with its id and time', () => {
|
||||
const preview = describeToolCall('send_collab_message', 'call_1', { text: 'Hi' }, ctx, null);
|
||||
const result = { message: message(40, { ...ANNA_ROW, text: 'Hi', created_at: '2026-10-21 10:00:05' }) };
|
||||
const chat = chatOf(appliedCardOf('send_collab_message', { args: { text: 'Hi' }, result, preview }));
|
||||
expect(chat.messages[0]).toMatchObject({ id: 40, at: '2026-10-21T10:00:05Z', change: 'send', text: 'Hi' });
|
||||
const refused = { args: {}, result: 'Reply target message not found', preview };
|
||||
expect(appliedCardOf('send_collab_message', refused)).toBeNull();
|
||||
// The user's own words stay as the held card showed them, an address included.
|
||||
const text = 'Mail me at anna@example.test';
|
||||
const mailPreview = describeToolCall('send_collab_message', 'call_2', { text }, ctx, null);
|
||||
const mailed = { message: message(41, { ...ANNA_ROW, text }) };
|
||||
const sent = chatOf(appliedCardOf('send_collab_message', { args: { text }, result: mailed, preview: mailPreview }));
|
||||
expect(sent.messages[0]).toMatchObject({ id: 41, text, cut: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the poll cards', () => {
|
||||
const now = Date.parse('2026-10-21T12:00:00Z');
|
||||
|
||||
it('ASTCOLLAB-020: a read shows up to four polls, the open ones first, the votes read for the user', () => {
|
||||
vi.useFakeTimers({ now });
|
||||
try {
|
||||
const rows = [
|
||||
poll(1, { is_closed: true }),
|
||||
poll(2, { deadline: '2026-10-20T00:00:00.000Z' }),
|
||||
poll(3),
|
||||
poll(4),
|
||||
poll(5),
|
||||
poll(6),
|
||||
];
|
||||
const polls = pollsOf(answer('list_collab_polls', { polls: rows }));
|
||||
expect(polls.polls.map((p) => p.pollId)).toEqual([3, 4, 5, 6]);
|
||||
expect(polls.more).toBe(2);
|
||||
expect(polls.polls[0]?.options).toEqual([
|
||||
{ text: 'Trattoria', votes: 1, mine: false, voters: [JON] },
|
||||
{ text: 'Market', votes: 1, mine: true, voters: [ANNA_VOTER] },
|
||||
{ text: 'Noodles', votes: 0, mine: false, voters: [] },
|
||||
]);
|
||||
expect(polls.polls[0]).toMatchObject({ multiple: false, closed: false, author: JON, change: 'none' });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-021: a new poll lists twelve options, counts the rest, and has no votes', () => {
|
||||
const options = Array.from({ length: 14 }, (_, index) => `Option ${index + 1}`);
|
||||
const args = { question: 'Where on Friday?', options, multiple: true, deadline: '2026-10-24T18:00:00+02:00' };
|
||||
const [created] = pollsOf(changeCardOf('create_collab_poll', ctx, args, null)).polls;
|
||||
expect(created?.options).toHaveLength(12);
|
||||
expect(created).toMatchObject({
|
||||
pollId: null,
|
||||
moreOptions: 2,
|
||||
multiple: true,
|
||||
deadline: '2026-10-24T16:00:00Z',
|
||||
author: ANNA,
|
||||
change: 'create',
|
||||
});
|
||||
expect(created?.options.every((option) => option.votes === 0 && !option.mine)).toBe(true);
|
||||
// A blank option keeps its place, since votes go by index; a deadline without a zone is no moment.
|
||||
const blank = { question: 'Which?', options: ['A', ' ', 'C'], deadline: '2026-10-24T18:00' };
|
||||
const [kept] = pollsOf(changeCardOf('create_collab_poll', ctx, blank, null)).polls;
|
||||
expect(kept?.options.map((option) => option.text)).toEqual(['A', '#2', 'C']);
|
||||
expect(kept?.deadline).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-022: a vote moves a single choice, adds to a multiple choice and takes a repeat back', () => {
|
||||
const listing = { polls: [poll(1), poll(2, { multiple_choice: true })] };
|
||||
const vote = (pollId: number, optionIndex: number) =>
|
||||
pollsOf(changeCardOf('vote_collab_poll', ctx, { pollId, optionIndex }, listing)).polls[0];
|
||||
const moved = vote(1, 2);
|
||||
expect(moved?.options.map((option) => [option.votes, option.mine])).toEqual([
|
||||
[1, false],
|
||||
[0, false],
|
||||
[1, true],
|
||||
]);
|
||||
expect(moved).toMatchObject({ votedIndex: 2, votedAdds: true, warning: null, change: 'vote' });
|
||||
const added = vote(2, 0);
|
||||
expect(added?.options.map((option) => option.mine)).toEqual([true, true, false]);
|
||||
const back = vote(1, 1);
|
||||
expect(back?.options[1]).toMatchObject({ votes: 0, mine: false });
|
||||
expect(back).toMatchObject({ votedIndex: 1, votedAdds: false });
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-023: a vote the server refuses says why, and no poll gives no card', () => {
|
||||
const listing = { polls: [poll(1, { is_closed: true }), poll(2)] };
|
||||
const closed = pollsOf(changeCardOf('vote_collab_poll', ctx, { pollId: 1, optionIndex: 0 }, listing)).polls[0];
|
||||
expect(closed).toMatchObject({ warning: 'closed', votedIndex: 0, votedAdds: false });
|
||||
expect(closed?.options[0]?.votes).toBe(1);
|
||||
const unknown = pollsOf(changeCardOf('vote_collab_poll', ctx, { pollId: 2, optionIndex: 9 }, listing)).polls[0];
|
||||
expect(unknown).toMatchObject({ warning: 'unknownOption', votedIndex: null });
|
||||
expect(changeCardOf('vote_collab_poll', ctx, { pollId: 3, optionIndex: 0 }, listing)).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-024: closing marks the poll closed, deleting shows it as it is', () => {
|
||||
const listing = { polls: [poll(1)] };
|
||||
expect(pollsOf(changeCardOf('close_collab_poll', ctx, { pollId: 1 }, listing)).polls[0]).toMatchObject({
|
||||
closed: true,
|
||||
change: 'close',
|
||||
});
|
||||
expect(pollsOf(changeCardOf('delete_collab_poll', ctx, { pollId: 1 }, listing)).polls[0]).toMatchObject({
|
||||
closed: false,
|
||||
change: 'remove',
|
||||
});
|
||||
expect(changeCardOf('close_collab_poll', ctx, { pollId: 2 }, listing)).toBeNull();
|
||||
});
|
||||
|
||||
it("ASTCOLLAB-025: an applied vote has the server's counts and keeps the user's options from the preview", () => {
|
||||
const listing = { polls: [poll(1)] };
|
||||
const preview = describeToolCall('vote_collab_poll', 'call_1', { pollId: 1, optionIndex: 2 }, ctx, listing);
|
||||
const result = {
|
||||
poll: poll(1, {
|
||||
options: [
|
||||
{ text: 'Trattoria', voters: [voter(8, 'jon'), voter(9, 'mia')] },
|
||||
{ text: 'Market', voters: [] },
|
||||
{ text: 'Noodles', voters: [voter(7, 'anna')] },
|
||||
],
|
||||
}),
|
||||
};
|
||||
const [applied] = pollsOf(appliedCardOf('vote_collab_poll', { args: {}, result, preview })).polls;
|
||||
expect(applied?.options.map((option) => [option.votes, option.mine])).toEqual([
|
||||
[2, false],
|
||||
[0, false],
|
||||
[1, true],
|
||||
]);
|
||||
expect(applied).toMatchObject({ pollId: 1, change: 'vote', votedIndex: 2, votedAdds: true });
|
||||
const created = appliedCardOf('create_collab_poll', { args: {}, result: { poll: poll(5) }, preview: null });
|
||||
expect(pollsOf(created).polls[0]).toMatchObject({ pollId: 5, change: 'create' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the note cards', () => {
|
||||
it('ASTCOLLAB-030: a read shows pinned notes first, then the latest changed, in their category colour', () => {
|
||||
const rows = [
|
||||
note(1, { category: 'Ideas', color: '#ef4444', updated_at: '2026-10-20 08:00:00' }),
|
||||
note(2, { category: 'Ideas', color: '#10b981', updated_at: '2026-10-21 08:00:00' }),
|
||||
note(3, { pinned: 1, website: 'https://www.luigis.it/menu', attachments: [{ id: 1 }, { id: 2 }] }),
|
||||
note(4, { website: 'javascript:alert(1)', content: 'Mail jon@example.test' }),
|
||||
note(5, { updated_at: '2026-10-01 08:00:00' }),
|
||||
];
|
||||
const notes = notesOf(answer('list_collab_notes', { notes: rows }));
|
||||
expect(notes.notes.map((n) => n.noteId)).toEqual([3, 2, 4, 1]);
|
||||
expect(notes.more).toBe(1);
|
||||
expect(notes.notes[0]).toMatchObject({ pinned: true, websiteHost: 'luigis.it', files: 2, author: ANNA });
|
||||
expect(notes.notes[1]?.color).toBe('#10b981');
|
||||
expect(notes.notes[2]).toMatchObject({ websiteHost: null, body: 'Mail [hidden]', color: '#6366f1' });
|
||||
expect(notes.notes[3]?.color).toBe('#10b981');
|
||||
const few = notesOf(answer('list_collab_notes', { notes: [note(1), note(2)] }));
|
||||
expect(few.notes).toHaveLength(2);
|
||||
expect(few.more).toBe(0);
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-031: a new note lands in General without a category, in its category colour without a colour', () => {
|
||||
const listing = { notes: [note(1, { category: 'Food', color: '#f59e0b' })] };
|
||||
const plain = notesOf(changeCardOf('create_collab_note', ctx, { title: 'Ideas' }, listing)).notes[0];
|
||||
expect(plain).toMatchObject({ category: 'General', color: '#6366f1', change: 'create', noteId: null });
|
||||
expect(plain?.author).toEqual(ANNA);
|
||||
const pizza = { title: 'Pizza', category: 'Food' };
|
||||
const food = notesOf(changeCardOf('create_collab_note', ctx, pizza, listing)).notes[0];
|
||||
expect(food?.color).toBe('#f59e0b');
|
||||
const args = { title: 'Rome', content: 'Ask at anna@example.test', color: '#3b82f6', website: 'http://r.test/x' };
|
||||
const own = notesOf(changeCardOf('create_collab_note', ctx, args, listing)).notes[0];
|
||||
expect(own).toMatchObject({ color: '#3b82f6', body: 'Ask at anna@example.test', websiteHost: 'r.test' });
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-032: a change lists only the parts that differ, a link of null removes it', () => {
|
||||
const listing = { notes: [note(1, { title: 'Food', website: 'https://a.test', pinned: 0 })] };
|
||||
const args = { noteId: 1, title: 'Food', category: 'Eat', website: null, pinned: true };
|
||||
const changed = notesOf(changeCardOf('update_collab_note', ctx, args, listing)).notes[0];
|
||||
expect(changed?.changed).toEqual(['category', 'website', 'pinned']);
|
||||
expect(changed).toMatchObject({ category: 'Eat', websiteHost: null, pinned: true, change: 'update', noteId: 1 });
|
||||
expect(changeCardOf('update_collab_note', ctx, { noteId: 2, title: 'x' }, listing)).toBeNull();
|
||||
const removed = notesOf(changeCardOf('delete_collab_note', ctx, { noteId: 1 }, listing)).notes[0];
|
||||
expect(removed).toMatchObject({ change: 'remove', title: 'Food' });
|
||||
});
|
||||
|
||||
it("ASTCOLLAB-033: an applied note carries the server's id and keeps the preview's colour and changes", () => {
|
||||
const listing = { notes: [note(1, { category: 'Food', color: '#f59e0b' })] };
|
||||
const args = { title: 'Pizza', category: 'Food' };
|
||||
const preview = describeToolCall('create_collab_note', 'call_1', args, ctx, listing);
|
||||
const result = { note: note(9, { title: 'Pizza', category: 'Food', color: '#6366f1' }) };
|
||||
const applied = notesOf(appliedCardOf('create_collab_note', { args: {}, result, preview })).notes[0];
|
||||
expect(applied).toMatchObject({ noteId: 9, color: '#f59e0b', change: 'create' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the words, the receipts and the way back', () => {
|
||||
const messages = { messages: [message(2, { user_id: 7, username: 'anna', text: 'See you\nat nine' })] };
|
||||
const polls = { polls: [poll(1)] };
|
||||
const notes = { notes: [note(1, { title: 'Food', category: 'Ideas', pinned: 0 })] };
|
||||
|
||||
it('ASTCOLLAB-040: every write names its tool in the trip chat, with its message, question or title', () => {
|
||||
const cases: Array<[string, Row, unknown, string]> = [
|
||||
['send_collab_message', { text: 'Hello all\nsecond line' }, null, 'Hello all'],
|
||||
['delete_collab_message', { messageId: 2 }, messages, 'See you'],
|
||||
['react_collab_message', { messageId: 2, emoji: '👍' }, messages, '👍 See you'],
|
||||
['create_collab_poll', { question: 'Where?', options: ['A', 'B'] }, null, 'Where?'],
|
||||
['vote_collab_poll', { pollId: 1, optionIndex: 1 }, polls, 'Question 1?'],
|
||||
['close_collab_poll', { pollId: 1 }, polls, 'Question 1?'],
|
||||
['delete_collab_poll', { pollId: 1 }, polls, 'Question 1?'],
|
||||
['create_collab_note', { title: 'Pizza' }, null, 'Pizza'],
|
||||
['update_collab_note', { noteId: 1, pinned: true }, notes, 'Food'],
|
||||
['delete_collab_note', { noteId: 1 }, notes, 'Food'],
|
||||
];
|
||||
for (const [tool, args, loaded, subject] of cases) {
|
||||
const preview = describeToolCall(tool, 'call_1', args, ctx, loaded);
|
||||
expect(assistantChangePreviewSchema.safeParse(preview).success, tool).toBe(true);
|
||||
const verbKey = ASSISTANT_WRITE_TOOLS[tool]?.verbKey;
|
||||
expect(preview, tool).toMatchObject({ subject, tool, domain: 'collab', verbKey });
|
||||
expect(preview.card, tool).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-041: a vote names its option, a note change its title, category and pin before and after', () => {
|
||||
expect(describeToolCall('vote_collab_poll', 'c', { pollId: 1, optionIndex: 1 }, ctx, polls).rows).toEqual([
|
||||
{ op: 'add', labelKey: 'text', before: null, after: 'Market' },
|
||||
]);
|
||||
const args = { noteId: 1, title: 'Eat', category: 'Food', pinned: true };
|
||||
expect(describeToolCall('update_collab_note', 'c', args, ctx, notes).rows).toEqual([
|
||||
{ op: 'change', labelKey: 'name', before: 'Food', after: 'Eat' },
|
||||
{ op: 'change', labelKey: 'category', before: 'Ideas', after: 'Food' },
|
||||
{ op: 'change', labelKey: 'field', field: 'pinned', before: '✗', after: '✓' },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ASTCOLLAB-042: a note id is the shared note's, never a day note of the same number", () => {
|
||||
const withDayNote = buildTripCard(
|
||||
1,
|
||||
7,
|
||||
{ ...summary, days: [{ id: 31, day_number: 1, notes: [{ id: 1, text: 'Day note' }] }] },
|
||||
null,
|
||||
null,
|
||||
);
|
||||
expect(withDayNote.notes.get(1)?.text).toBe('Day note');
|
||||
const preview = describeToolCall('delete_collab_note', 'c', { noteId: 1 }, { ...ctx, card: withDayNote }, notes);
|
||||
expect(preview.subject).toBe('Food');
|
||||
expect(describeToolCall('delete_collab_note', 'c', { noteId: 1 }, ctx, null).subject).toBe('');
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-043: a receipt names the message, poll or note it touched', () => {
|
||||
const cases: Array<[string, Row, unknown, string, number[]]> = [
|
||||
['send_collab_message', { text: 'x' }, { message: { id: 40 } }, 'collab_message', [40]],
|
||||
['delete_collab_message', { messageId: 2 }, { success: true }, 'collab_message', [2]],
|
||||
['react_collab_message', { messageId: 2, emoji: '👍' }, { reactions: [] }, 'collab_message', [2]],
|
||||
['create_collab_poll', {}, { poll: { id: 5 } }, 'collab_poll', [5]],
|
||||
['vote_collab_poll', { pollId: 5, optionIndex: 0 }, { poll: { id: 5 } }, 'collab_poll', [5]],
|
||||
['close_collab_poll', { pollId: 5 }, { poll: { id: 5 } }, 'collab_poll', [5]],
|
||||
['delete_collab_poll', { pollId: 5 }, { success: true }, 'collab_poll', [5]],
|
||||
['create_collab_note', {}, { note: { id: 9 } }, 'collab_note', [9]],
|
||||
['update_collab_note', { noteId: 9 }, { note: { id: 9 } }, 'collab_note', [9]],
|
||||
['delete_collab_note', { noteId: 9 }, { success: true }, 'collab_note', [9]],
|
||||
];
|
||||
for (const [tool, args, result, entity, ids] of cases) {
|
||||
expect(appliedChange(tool, 'c', args, result, null, null), tool).toMatchObject({ entity, ids });
|
||||
}
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-044: a new note or poll is taken back while nobody built on it; a message has no way back', () => {
|
||||
const createdNote = { note: note(9, { title: 'Pizza' }) };
|
||||
const createdPoll = { poll: poll(5, { options: [{ text: 'A', voters: [] }] }) };
|
||||
expect(ASSISTANT_UNDO_MAP.create_collab_note?.args({}, createdNote, null)).toEqual({ noteId: 9 });
|
||||
expect(ASSISTANT_UNDO_MAP.create_collab_poll?.args({}, createdPoll, null)).toEqual({ pollId: 5 });
|
||||
expect(ASSISTANT_UNDO_MAP.send_collab_message).toBeUndefined();
|
||||
expect(undoReadsOf('create_collab_note', {}, createdNote)).toEqual([{ tool: 'list_collab_notes', args: {} }]);
|
||||
const snapshot = (tool: string, value: unknown) => ({
|
||||
summary: {},
|
||||
places: {},
|
||||
reads: new Map([[readKey(tool, {}), value]]),
|
||||
});
|
||||
const noteKept = (rows: Row[]) =>
|
||||
unchangedSinceApplied('create_collab_note', createdNote, snapshot('list_collab_notes', { notes: rows }));
|
||||
const pollKept = (rows: Row[]) =>
|
||||
unchangedSinceApplied('create_collab_poll', createdPoll, snapshot('list_collab_polls', { polls: rows }));
|
||||
expect(noteKept([createdNote.note])).toBe(true);
|
||||
expect(noteKept([{ ...createdNote.note, updated_at: '2026-10-21 11:00:00' }])).toBe(false);
|
||||
// A file a teammate attached keeps the note, even when its last change stayed the same.
|
||||
expect(noteKept([{ ...createdNote.note, attachments: [{ id: 3, original_name: 'menu.pdf' }] }])).toBe(false);
|
||||
expect(pollKept([createdPoll.poll])).toBe(true);
|
||||
expect(pollKept([{ ...createdPoll.poll, options: [{ text: 'A', voters: [voter(8, 'jon')] }] }])).toBe(false);
|
||||
expect(pollKept([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the bounds', () => {
|
||||
it('ASTCOLLAB-050: a moment is read as UTC to the second; a deadline without a zone is none', () => {
|
||||
expect(stampOf('2026-10-21 09:12:00')).toBe('2026-10-21T09:12:00Z');
|
||||
expect(stampOf('2026-10-21T09:12:00.000Z')).toBe('2026-10-21T09:12:00Z');
|
||||
expect(stampOf('2026-10-21T11:12:00+02:00')).toBe('2026-10-21T09:12:00Z');
|
||||
expect(stampOf('2026-10-24')).toBe('2026-10-24T00:00:00Z');
|
||||
expect(stampOf('Friday')).toBeNull();
|
||||
expect(stampOf(null)).toBeNull();
|
||||
expect(deadlineOf('2026-10-24T18:00:00+02:00')).toBe('2026-10-24T16:00:00Z');
|
||||
expect(deadlineOf('2026-10-24T18:00:00.000Z')).toBe('2026-10-24T18:00:00Z');
|
||||
expect(deadlineOf('2026-10-24')).toBe('2026-10-24T00:00:00Z');
|
||||
expect(deadlineOf('2026-10-24T18:00')).toBeNull();
|
||||
expect(deadlineOf('2026-10-24 18:00:00')).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTCOLLAB-051: the largest chat, poll and note cards fit the card size', () => {
|
||||
const words = (n: number) => 'word '.repeat(n);
|
||||
const reactions = ['1', '2', '3', '4', '5', '6', '7', '8'].map((emoji) => ({
|
||||
emoji,
|
||||
users: [1, 2, 3, 4, 5, 6].map((id) => ({ user_id: id, username: 'n'.repeat(80) })),
|
||||
}));
|
||||
const files = [1, 2, 3, 4].map((id) => ({ id, original_name: 'f'.repeat(120), mime_type: 'image/png' }));
|
||||
const reply = { reply_username: 'r'.repeat(80), reply_text: words(40) };
|
||||
const heavy = { text: words(200), ...reply, reactions, attachments: files };
|
||||
const chat = chatOf(answer('list_collab_messages', { messages: [1, 2, 3, 4, 5].map((id) => message(id, heavy)) }));
|
||||
expect(chat.messages.length + chat.earlier).toBe(5);
|
||||
expect(chat.messages.at(-1)?.id).toBe(5);
|
||||
const options = Array.from({ length: 20 }, (_, index) => ({
|
||||
text: `${index} ${words(40)}`,
|
||||
voters: [1, 2, 3].map((id) => voter(id, 'v'.repeat(80))),
|
||||
}));
|
||||
const bigPolls = pollsOf(answer('list_collab_polls', { polls: [1, 2, 3, 4].map((id) => poll(id, { options })) }));
|
||||
expect(bigPolls.polls.length + bigPolls.more).toBe(4);
|
||||
expect(bigPolls.polls[0]?.pollId).toBe(1);
|
||||
const bigNote = { title: words(50), content: words(200), category: 'c'.repeat(100) };
|
||||
const bigNotes = notesOf(answer('list_collab_notes', { notes: [1, 2, 3, 4].map((id) => note(id, bigNote)) }));
|
||||
for (const built of [chat, bigPolls, bigNotes]) {
|
||||
expect(JSON.stringify(built).length).toBeLessThanOrEqual(MAX_CARD_CHARS);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,67 @@
|
||||
import { idSchema } from '../../common/primitives.schema';
|
||||
import { cardInstantSchema, cardPersonSchema, cardText } from './card-parts';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Messages of the trip chat, as the chat shows them. Only its kind so far; the trip chat cards give it
|
||||
* its fields.
|
||||
* Messages of the trip chat, drawn the way the chat draws them: a bubble per
|
||||
* message, the others on one side with their face and name, the user's own on
|
||||
* the other in the accent. An answer shows the last few messages a read
|
||||
* returned and counts the older ones; a held write shows the one message it
|
||||
* sends, removes or reacts to, as it will look afterwards. A file travels as
|
||||
* its id only, which the client turns into TREK's own download path.
|
||||
*/
|
||||
export const collabChatCardSchema = z.object({ kind: z.literal('collabChat') });
|
||||
|
||||
/** One emoji of a message's reactions; `mine` says the signed-in user is among them, which only the server knows. */
|
||||
const chatReactionSchema = z.object({
|
||||
emoji: z.string().min(1).max(16),
|
||||
count: z.number().int().min(1).max(999),
|
||||
mine: z.boolean(),
|
||||
/** The first names behind it, for the tooltip the chat shows. */
|
||||
names: z.array(cardText(80)).max(6),
|
||||
});
|
||||
|
||||
/** A file sent with a message: an image is drawn in the bubble, any other file named. */
|
||||
const chatFileSchema = z.object({
|
||||
fileId: idSchema,
|
||||
image: z.boolean(),
|
||||
name: cardText(120).nullable(),
|
||||
});
|
||||
|
||||
/** The message a bubble answers, quoted on top of it. */
|
||||
const chatReplySchema = z.object({
|
||||
name: cardText(80),
|
||||
text: cardText(120),
|
||||
});
|
||||
|
||||
export const COLLAB_BUBBLE_CHANGES = ['none', 'send', 'remove', 'react'] as const;
|
||||
|
||||
export const collabBubbleCardSchema = z.object({
|
||||
/** Null while a send waits for its decision. */
|
||||
id: idSchema.nullable(),
|
||||
author: cardPersonSchema,
|
||||
/** Line breaks kept; empty only for a message that is an image alone. */
|
||||
text: z.string().max(4000),
|
||||
/** The text was cut: an excerpt of a long message, or a send longer than the bubble holds. */
|
||||
cut: z.boolean(),
|
||||
/** Null while a send waits for its decision. */
|
||||
at: cardInstantSchema.nullable(),
|
||||
reply: chatReplySchema.nullable(),
|
||||
reactions: z.array(chatReactionSchema).max(8),
|
||||
files: z.array(chatFileSchema).max(4),
|
||||
/** Shared from an assistant answer: the chat marks it so. */
|
||||
fromAssistant: z.boolean(),
|
||||
change: z.enum(COLLAB_BUBBLE_CHANGES),
|
||||
/** The emoji a held reaction toggles. */
|
||||
reactEmoji: z.string().min(1).max(16).nullable(),
|
||||
});
|
||||
export type CollabBubbleCard = z.infer<typeof collabBubbleCardSchema>;
|
||||
|
||||
export const collabChatCardSchema = z.object({
|
||||
kind: z.literal('collabChat'),
|
||||
/** Oldest first, as the chat lists them. */
|
||||
messages: z.array(collabBubbleCardSchema).min(1).max(6),
|
||||
/** Older messages of the read that are not shown. */
|
||||
earlier: z.number().int().min(0).max(10_000),
|
||||
});
|
||||
export type CollabChatCard = z.infer<typeof collabChatCardSchema>;
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
import { idSchema } from '../../common/primitives.schema';
|
||||
import { cardCountSchema, cardHexColorSchema, cardPersonSchema, cardText } from './card-parts';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/** Shared notes of the trip. Only its kind so far; the trip chat cards give it its fields. */
|
||||
export const collabNotesCardSchema = z.object({ kind: z.literal('collabNotes') });
|
||||
/**
|
||||
* Shared notes of the trip, drawn like the note cards of the Collab tab: a
|
||||
* head band tinted by the category's colour with its title, and the start of
|
||||
* its text under it. An answer holds up to four notes of a read (pinned
|
||||
* first) and counts the rest; a held write holds the one note it adds,
|
||||
* changes or deletes, as it will look afterwards. A note's link travels as
|
||||
* its host only, never as an address the chat could open.
|
||||
*/
|
||||
|
||||
export const COLLAB_NOTE_CHANGES = ['none', 'create', 'update', 'remove'] as const;
|
||||
|
||||
/** The parts of a note a held change sets to something new. */
|
||||
export const COLLAB_NOTE_FIELDS = ['title', 'body', 'category', 'color', 'website', 'pinned'] as const;
|
||||
|
||||
export const collabNoteCardSchema = z.object({
|
||||
/** Null while a new note waits for its decision. */
|
||||
noteId: idSchema.nullable(),
|
||||
title: cardText(200),
|
||||
/** Markdown with its line breaks, shown in three lines as the note card does. */
|
||||
body: z.string().min(1).max(600).nullable(),
|
||||
category: cardText(100).nullable(),
|
||||
color: cardHexColorSchema.nullable(),
|
||||
pinned: z.boolean(),
|
||||
/** "example.org": the host of the note's link. */
|
||||
websiteHost: z.string().min(1).max(253).nullable(),
|
||||
files: z.number().int().min(0).max(999),
|
||||
author: cardPersonSchema.nullable(),
|
||||
change: z.enum(COLLAB_NOTE_CHANGES),
|
||||
changed: z.array(z.enum(COLLAB_NOTE_FIELDS)).max(6),
|
||||
});
|
||||
export type CollabNoteCard = z.infer<typeof collabNoteCardSchema>;
|
||||
|
||||
export const collabNotesCardSchema = z.object({
|
||||
kind: z.literal('collabNotes'),
|
||||
notes: z.array(collabNoteCardSchema).min(1).max(4),
|
||||
/** Notes of the read that are not shown. */
|
||||
more: cardCountSchema,
|
||||
});
|
||||
export type CollabNotesCard = z.infer<typeof collabNotesCardSchema>;
|
||||
|
||||
@@ -1,8 +1,54 @@
|
||||
import { idSchema } from '../../common/primitives.schema';
|
||||
import { cardCountSchema, cardInstantSchema, cardPersonSchema, cardText } from './card-parts';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Polls of the trip chat with their options. Only its kind so far; the trip chat cards give it its
|
||||
* fields.
|
||||
* Polls of the trip, drawn like the poll cards of the Collab tab: the
|
||||
* question, what kind of poll it is, and its options with their bars. An
|
||||
* answer holds up to four polls of a read (open ones first) and counts the
|
||||
* rest; a held write holds the one poll it starts, votes in, closes or
|
||||
* deletes, as it will look afterwards. Whether the signed-in user voted for
|
||||
* an option is something only the server knows, so it travels as `mine`.
|
||||
*/
|
||||
export const collabPollsCardSchema = z.object({ kind: z.literal('collabPolls') });
|
||||
|
||||
const pollOptionCardSchema = z.object({
|
||||
text: cardText(160),
|
||||
votes: z.number().int().min(0).max(9999),
|
||||
mine: z.boolean(),
|
||||
/** The first three voters, as the poll card shows them. */
|
||||
voters: z.array(cardPersonSchema).max(3),
|
||||
});
|
||||
|
||||
export const COLLAB_POLL_CHANGES = ['none', 'create', 'vote', 'close', 'remove'] as const;
|
||||
|
||||
/** Why the server would refuse a held vote: the poll is closed, or it has no such option. */
|
||||
export const COLLAB_POLL_WARNINGS = ['closed', 'unknownOption'] as const;
|
||||
|
||||
export const collabPollCardSchema = z.object({
|
||||
/** Null while a new poll waits for its decision. */
|
||||
pollId: idSchema.nullable(),
|
||||
question: cardText(300),
|
||||
options: z.array(pollOptionCardSchema).min(1).max(12),
|
||||
moreOptions: z.number().int().min(0).max(500),
|
||||
multiple: z.boolean(),
|
||||
/** Closed by hand; the client adds the deadline, as the Collab tab does. */
|
||||
closed: z.boolean(),
|
||||
deadline: cardInstantSchema.nullable(),
|
||||
author: cardPersonSchema.nullable(),
|
||||
change: z.enum(COLLAB_POLL_CHANGES),
|
||||
/** The option a held vote toggles. */
|
||||
votedIndex: z.number().int().min(0).max(499).nullable(),
|
||||
/** True when the held vote is cast, false when it is taken back. */
|
||||
votedAdds: z.boolean(),
|
||||
warning: z.enum(COLLAB_POLL_WARNINGS).nullable(),
|
||||
});
|
||||
export type CollabPollCard = z.infer<typeof collabPollCardSchema>;
|
||||
|
||||
export const collabPollsCardSchema = z.object({
|
||||
kind: z.literal('collabPolls'),
|
||||
polls: z.array(collabPollCardSchema).min(1).max(4),
|
||||
/** Polls of the read that are not shown. */
|
||||
more: cardCountSchema,
|
||||
});
|
||||
export type CollabPollsCard = z.infer<typeof collabPollsCardSchema>;
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { assistantAppliedSchema } from '../assistant.schema';
|
||||
import {
|
||||
COLLAB_APPLIED_ENTITIES,
|
||||
COLLAB_VERB_KEYS,
|
||||
type CollabChatCard,
|
||||
type CollabNotesCard,
|
||||
type CollabPollsCard,
|
||||
} from './collab.contract';
|
||||
import { storedTrekCardSchema, trekCardSchema } from './index';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const ANNA = { id: 7, name: 'Anna', avatarKey: 'anna.png' };
|
||||
|
||||
/** A message as the trip chat shows it. */
|
||||
const bubble = (over: Partial<CollabChatCard['messages'][number]> = {}): CollabChatCard['messages'][number] => ({
|
||||
id: 11,
|
||||
author: ANNA,
|
||||
text: 'Shall we take\nthe early train?',
|
||||
cut: false,
|
||||
at: '2026-10-21T09:12:00Z',
|
||||
reply: null,
|
||||
reactions: [{ emoji: '❤️', count: 2, mine: true, names: ['Anna', 'Jon'] }],
|
||||
files: [{ fileId: 4, image: true, name: 'ticket.jpg' }],
|
||||
fromAssistant: false,
|
||||
change: 'none',
|
||||
reactEmoji: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const chat = (over: Partial<CollabChatCard> = {}): CollabChatCard => ({
|
||||
kind: 'collabChat',
|
||||
messages: [bubble()],
|
||||
earlier: 3,
|
||||
...over,
|
||||
});
|
||||
|
||||
const poll = (over: Partial<CollabPollsCard['polls'][number]> = {}): CollabPollsCard['polls'][number] => ({
|
||||
pollId: 5,
|
||||
question: 'Where do we eat on Friday?',
|
||||
options: [
|
||||
{ text: 'Trattoria Roma', votes: 2, mine: true, voters: [ANNA] },
|
||||
{ text: 'Night market', votes: 0, mine: false, voters: [] },
|
||||
],
|
||||
moreOptions: 0,
|
||||
multiple: false,
|
||||
closed: false,
|
||||
deadline: '2026-10-24T18:00:00Z',
|
||||
author: ANNA,
|
||||
change: 'none',
|
||||
votedIndex: null,
|
||||
votedAdds: false,
|
||||
warning: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const note = (over: Partial<CollabNotesCard['notes'][number]> = {}): CollabNotesCard['notes'][number] => ({
|
||||
noteId: 9,
|
||||
title: 'Restaurants to try',
|
||||
body: 'Pasta at **Luigi**\nthe rooftop bar',
|
||||
category: 'Ideas',
|
||||
color: '#10b981',
|
||||
pinned: true,
|
||||
websiteHost: 'luigis.it',
|
||||
files: 2,
|
||||
author: ANNA,
|
||||
change: 'none',
|
||||
changed: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('the trip chat cards', () => {
|
||||
it('CARDS-COLLAB-001: a chat, a poll and a note card read as themselves in the union', () => {
|
||||
for (const card of [
|
||||
chat(),
|
||||
{ kind: 'collabPolls', polls: [poll()], more: 2 },
|
||||
{ kind: 'collabNotes', notes: [note()], more: 0 },
|
||||
]) {
|
||||
expect(trekCardSchema.parse(card)).toEqual(card);
|
||||
}
|
||||
});
|
||||
|
||||
it('CARDS-COLLAB-002: a held send has no id and no time yet, and an image alone has no text', () => {
|
||||
const held = chat({ messages: [bubble({ id: null, at: null, change: 'send', reactions: [], files: [] })] });
|
||||
expect(trekCardSchema.parse(held)).toEqual(held);
|
||||
expect(trekCardSchema.safeParse(chat({ messages: [bubble({ text: '' })] })).success).toBe(true);
|
||||
});
|
||||
|
||||
it('CARDS-COLLAB-003: an avatar is a stored file name, never an address or a way up', () => {
|
||||
for (const avatarKey of ['https://idp.test/me.png', '../secret.png', '/uploads/avatars/a.png']) {
|
||||
const card = chat({ messages: [bubble({ author: { ...ANNA, avatarKey } })] });
|
||||
expect(trekCardSchema.safeParse(card).success).toBe(false);
|
||||
expect(storedTrekCardSchema.parse(card)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('CARDS-COLLAB-004: a time is a UTC moment to the second, as the server normalises it', () => {
|
||||
expect(trekCardSchema.safeParse(chat({ messages: [bubble({ at: '2026-10-21 09:12:00' })] })).success).toBe(false);
|
||||
expect(trekCardSchema.safeParse(chat({ messages: [bubble({ at: '2026-10-21T09:12Z' })] })).success).toBe(false);
|
||||
const late = { kind: 'collabPolls', polls: [poll({ deadline: '2026-10-24' })], more: 0 };
|
||||
expect(trekCardSchema.safeParse(late).success).toBe(false);
|
||||
});
|
||||
|
||||
it('CARDS-COLLAB-005: lists past their bounds read as null', () => {
|
||||
const seven = Array.from({ length: 7 }, (_, index) => bubble({ id: index + 1 }));
|
||||
expect(storedTrekCardSchema.parse(chat({ messages: seven }))).toBeNull();
|
||||
expect(storedTrekCardSchema.parse(chat({ messages: [] }))).toBeNull();
|
||||
const five = [1, 2, 3, 4, 5].map((pollId) => poll({ pollId }));
|
||||
expect(storedTrekCardSchema.parse({ kind: 'collabPolls', polls: five, more: 0 })).toBeNull();
|
||||
expect(storedTrekCardSchema.parse({ kind: 'collabNotes', notes: [], more: 0 })).toBeNull();
|
||||
const many = Array.from({ length: 13 }, (_, index) => ({ text: `#${index}`, votes: 0, mine: false, voters: [] }));
|
||||
expect(storedTrekCardSchema.parse({ kind: 'collabPolls', polls: [poll({ options: many })], more: 0 })).toBeNull();
|
||||
expect(storedTrekCardSchema.parse(chat({ messages: [bubble({ text: 'x'.repeat(4001) })] }))).toBeNull();
|
||||
});
|
||||
|
||||
it('CARDS-COLLAB-006: a note colour is a hex colour and its link only a host', () => {
|
||||
const tinted = { kind: 'collabNotes', notes: [note({ color: 'url(https://x.test/p)' })], more: 0 };
|
||||
expect(trekCardSchema.safeParse(tinted).success).toBe(false);
|
||||
const linked = { kind: 'collabNotes', notes: [{ ...note(), website: 'https://x.test' }], more: 0 };
|
||||
expect(trekCardSchema.parse(linked)).toEqual({ kind: 'collabNotes', notes: [note()], more: 0 });
|
||||
});
|
||||
|
||||
it('CARDS-COLLAB-007: the trip chat adds three applied entities and no verb', () => {
|
||||
expect(COLLAB_VERB_KEYS).toEqual([]);
|
||||
for (const entity of COLLAB_APPLIED_ENTITIES) {
|
||||
const receipt = { toolCallId: 'call_1', entity, ids: [3], dayId: null, undoUntil: null };
|
||||
expect(assistantAppliedSchema.parse(receipt).entity).toBe(entity);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -8,11 +8,12 @@ export * from './collab-polls.card';
|
||||
|
||||
/**
|
||||
* What the trip chat brings to the assistant chat: the kinds of card it draws,
|
||||
* the verbs of its change cards and what an applied change of its
|
||||
* touches, beyond the core ones. cards/index.ts and assistant.schema.ts
|
||||
* the verbs of its change cards and the entities its applied changes touch,
|
||||
* beyond the core ones. cards/index.ts and assistant.schema.ts
|
||||
* spread these into the card union and the shared enums, so a new kind, verb
|
||||
* or entity is added here and nowhere else.
|
||||
* or entity is added here and nowhere else. Its changes keep the generic
|
||||
* verbs (add, change, remove) and name their tool instead.
|
||||
*/
|
||||
export const COLLAB_CARD_SCHEMAS = [collabChatCardSchema, collabPollsCardSchema, collabNotesCardSchema] as const;
|
||||
export const COLLAB_VERB_KEYS = [] as const;
|
||||
export const COLLAB_APPLIED_ENTITIES = [] as const;
|
||||
export const COLLAB_APPLIED_ENTITIES = ['collab_message', 'collab_poll', 'collab_note'] as const;
|
||||
|
||||
@@ -1,4 +1,64 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': 'محادثة الرحلة',
|
||||
'assistant.card.collab.openChat': 'فتح المحادثة',
|
||||
'assistant.card.collab.openPolls': 'فتح الاستطلاعات',
|
||||
'assistant.card.collab.openNotes': 'فتح الملاحظات',
|
||||
'assistant.card.collab.earlier': '{count} رسالة أقدم',
|
||||
'assistant.card.collab.earlier.zero': '{count} رسائل أقدم',
|
||||
'assistant.card.collab.earlier.one': '{count} رسالة أقدم',
|
||||
'assistant.card.collab.earlier.two': '{count} رسالتان أقدم',
|
||||
'assistant.card.collab.earlier.few': '{count} رسائل أقدم',
|
||||
'assistant.card.collab.earlier.many': '{count} رسالة أقدم',
|
||||
'assistant.card.collab.notSent': 'لم تُرسل بعد',
|
||||
'assistant.card.collab.sent': 'أُرسلت',
|
||||
'assistant.card.collab.failed': 'لم تُرسل',
|
||||
'assistant.card.collab.continues': 'تكملة في المحادثة',
|
||||
'assistant.card.collab.youDeleted': 'لقد حذفت رسالة',
|
||||
'assistant.card.collab.newPoll': 'جديد',
|
||||
'assistant.card.collab.yourVote': 'صوتك',
|
||||
'assistant.card.collab.voteTakenBack': 'تم سحب الصوت',
|
||||
'assistant.card.collab.pollClosedWarning': 'هذا الاستطلاع مغلق',
|
||||
'assistant.card.collab.unknownOption': 'لا يوجد هذا الخيار في الاستطلاع',
|
||||
'assistant.card.collab.moreOptions': '{count} خيار آخر',
|
||||
'assistant.card.collab.moreOptions.zero': '{count} خيارات أخرى',
|
||||
'assistant.card.collab.moreOptions.one': '{count} خيار آخر',
|
||||
'assistant.card.collab.moreOptions.two': '{count} خياران آخران',
|
||||
'assistant.card.collab.moreOptions.few': '{count} خيارات أخرى',
|
||||
'assistant.card.collab.moreOptions.many': '{count} خيارًا آخر',
|
||||
'assistant.card.collab.morePolls': '{count} استطلاع آخر',
|
||||
'assistant.card.collab.morePolls.zero': '{count} استطلاعات أخرى',
|
||||
'assistant.card.collab.morePolls.one': '{count} استطلاع آخر',
|
||||
'assistant.card.collab.morePolls.two': '{count} استطلاعان آخران',
|
||||
'assistant.card.collab.morePolls.few': '{count} استطلاعات أخرى',
|
||||
'assistant.card.collab.morePolls.many': '{count} استطلاعًا آخر',
|
||||
'assistant.card.collab.moreNotes': '{count} ملاحظة أخرى',
|
||||
'assistant.card.collab.moreNotes.zero': '{count} ملاحظات أخرى',
|
||||
'assistant.card.collab.moreNotes.one': '{count} ملاحظة أخرى',
|
||||
'assistant.card.collab.moreNotes.two': '{count} ملاحظتان أخريان',
|
||||
'assistant.card.collab.moreNotes.few': '{count} ملاحظات أخرى',
|
||||
'assistant.card.collab.moreNotes.many': '{count} ملاحظة أخرى',
|
||||
'assistant.card.collab.files': '{count} ملف',
|
||||
'assistant.card.collab.files.zero': '{count} ملفات',
|
||||
'assistant.card.collab.files.one': '{count} ملف',
|
||||
'assistant.card.collab.files.two': '{count} ملفان',
|
||||
'assistant.card.collab.files.few': '{count} ملفات',
|
||||
'assistant.card.collab.files.many': '{count} ملفًا',
|
||||
'assistant.card.collab.pinned': 'مثبتة',
|
||||
'assistant.card.collab.unpinned': 'لم تعد مثبتة',
|
||||
'assistant.card.collab.reactionRemoved': 'تم سحب التفاعل {emoji}',
|
||||
'assistant.tool.delete_collab_message': 'حذف رسالة المحادثة',
|
||||
'assistant.tool.react_collab_message': 'التفاعل في محادثة الرحلة',
|
||||
'assistant.tool.create_collab_poll': 'بدء استطلاع',
|
||||
'assistant.tool.vote_collab_poll': 'التصويت في الاستطلاع',
|
||||
'assistant.tool.close_collab_poll': 'إغلاق الاستطلاع',
|
||||
'assistant.tool.delete_collab_poll': 'حذف الاستطلاع',
|
||||
'assistant.tool.create_collab_note': 'إضافة ملاحظة مشتركة',
|
||||
'assistant.tool.update_collab_note': 'تغيير الملاحظة المشتركة',
|
||||
'assistant.tool.delete_collab_note': 'حذف الملاحظة المشتركة',
|
||||
'assistant.step.collabChat': 'قراءة محادثة الرحلة',
|
||||
'assistant.step.collabPolls': 'قراءة الاستطلاعات',
|
||||
'assistant.step.collabNotes': 'قراءة الملاحظات المشتركة',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,44 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': 'Səyahət söhbəti',
|
||||
'assistant.card.collab.openChat': 'Söhbəti aç',
|
||||
'assistant.card.collab.openPolls': 'Sorğuları aç',
|
||||
'assistant.card.collab.openNotes': 'Qeydləri aç',
|
||||
'assistant.card.collab.earlier': '{count} köhnə mesaj',
|
||||
'assistant.card.collab.earlier.one': '{count} köhnə mesaj',
|
||||
'assistant.card.collab.notSent': 'Hələ göndərilməyib',
|
||||
'assistant.card.collab.sent': 'Göndərildi',
|
||||
'assistant.card.collab.failed': 'Göndərilmədi',
|
||||
'assistant.card.collab.continues': 'Söhbətdə davam edir',
|
||||
'assistant.card.collab.youDeleted': 'Bir mesajı sildin',
|
||||
'assistant.card.collab.newPoll': 'Yeni',
|
||||
'assistant.card.collab.yourVote': 'Səsin',
|
||||
'assistant.card.collab.voteTakenBack': 'Səs geri götürüldü',
|
||||
'assistant.card.collab.pollClosedWarning': 'Bu sorğu bağlanıb',
|
||||
'assistant.card.collab.unknownOption': 'Bu sorğuda belə seçim yoxdur',
|
||||
'assistant.card.collab.moreOptions': 'Daha {count} seçim',
|
||||
'assistant.card.collab.moreOptions.one': 'Daha {count} seçim',
|
||||
'assistant.card.collab.morePolls': 'Daha {count} sorğu',
|
||||
'assistant.card.collab.morePolls.one': 'Daha {count} sorğu',
|
||||
'assistant.card.collab.moreNotes': 'Daha {count} qeyd',
|
||||
'assistant.card.collab.moreNotes.one': 'Daha {count} qeyd',
|
||||
'assistant.card.collab.files': '{count} fayl',
|
||||
'assistant.card.collab.files.one': '{count} fayl',
|
||||
'assistant.card.collab.pinned': 'Bərkidilib',
|
||||
'assistant.card.collab.unpinned': 'Artıq bərkidilməyib',
|
||||
'assistant.card.collab.reactionRemoved': '{emoji} reaksiyası geri götürüldü',
|
||||
'assistant.tool.delete_collab_message': 'Söhbət mesajını sil',
|
||||
'assistant.tool.react_collab_message': 'Səyahət söhbətində reaksiya ver',
|
||||
'assistant.tool.create_collab_poll': 'Sorğu başlat',
|
||||
'assistant.tool.vote_collab_poll': 'Sorğuda səs ver',
|
||||
'assistant.tool.close_collab_poll': 'Sorğunu bağla',
|
||||
'assistant.tool.delete_collab_poll': 'Sorğunu sil',
|
||||
'assistant.tool.create_collab_note': 'Ortaq qeyd əlavə et',
|
||||
'assistant.tool.update_collab_note': 'Ortaq qeydi dəyiş',
|
||||
'assistant.tool.delete_collab_note': 'Ortaq qeydi sil',
|
||||
'assistant.step.collabChat': 'Səyahət söhbəti oxunur',
|
||||
'assistant.step.collabPolls': 'Sorğular oxunur',
|
||||
'assistant.step.collabNotes': 'Ortaq qeydlər oxunur',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,44 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': 'Chat da viagem',
|
||||
'assistant.card.collab.openChat': 'Abrir o chat',
|
||||
'assistant.card.collab.openPolls': 'Abrir as enquetes',
|
||||
'assistant.card.collab.openNotes': 'Abrir as notas',
|
||||
'assistant.card.collab.earlier': '{count} mensagens anteriores',
|
||||
'assistant.card.collab.earlier.one': '{count} mensagem anterior',
|
||||
'assistant.card.collab.notSent': 'Ainda não enviada',
|
||||
'assistant.card.collab.sent': 'Enviada',
|
||||
'assistant.card.collab.failed': 'Não enviada',
|
||||
'assistant.card.collab.continues': 'Continua no chat',
|
||||
'assistant.card.collab.youDeleted': 'Você apagou uma mensagem',
|
||||
'assistant.card.collab.newPoll': 'Nova',
|
||||
'assistant.card.collab.yourVote': 'Seu voto',
|
||||
'assistant.card.collab.voteTakenBack': 'Voto retirado',
|
||||
'assistant.card.collab.pollClosedWarning': 'Esta enquete está encerrada',
|
||||
'assistant.card.collab.unknownOption': 'Esta enquete não tem essa opção',
|
||||
'assistant.card.collab.moreOptions': 'Mais {count} opções',
|
||||
'assistant.card.collab.moreOptions.one': 'Mais {count} opção',
|
||||
'assistant.card.collab.morePolls': 'Mais {count} enquetes',
|
||||
'assistant.card.collab.morePolls.one': 'Mais {count} enquete',
|
||||
'assistant.card.collab.moreNotes': 'Mais {count} notas',
|
||||
'assistant.card.collab.moreNotes.one': 'Mais {count} nota',
|
||||
'assistant.card.collab.files': '{count} arquivos',
|
||||
'assistant.card.collab.files.one': '{count} arquivo',
|
||||
'assistant.card.collab.pinned': 'Fixada',
|
||||
'assistant.card.collab.unpinned': 'Não está mais fixada',
|
||||
'assistant.card.collab.reactionRemoved': 'Reação {emoji} retirada',
|
||||
'assistant.tool.delete_collab_message': 'Apagar a mensagem',
|
||||
'assistant.tool.react_collab_message': 'Reagir no chat da viagem',
|
||||
'assistant.tool.create_collab_poll': 'Criar uma enquete',
|
||||
'assistant.tool.vote_collab_poll': 'Votar na enquete',
|
||||
'assistant.tool.close_collab_poll': 'Encerrar a enquete',
|
||||
'assistant.tool.delete_collab_poll': 'Apagar a enquete',
|
||||
'assistant.tool.create_collab_note': 'Adicionar uma nota compartilhada',
|
||||
'assistant.tool.update_collab_note': 'Alterar a nota compartilhada',
|
||||
'assistant.tool.delete_collab_note': 'Apagar a nota compartilhada',
|
||||
'assistant.step.collabChat': 'Lendo o chat da viagem',
|
||||
'assistant.step.collabPolls': 'Lendo as enquetes',
|
||||
'assistant.step.collabNotes': 'Lendo as notas compartilhadas',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,44 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': 'Xat del viatge',
|
||||
'assistant.card.collab.openChat': 'Obre el xat',
|
||||
'assistant.card.collab.openPolls': 'Obre les enquestes',
|
||||
'assistant.card.collab.openNotes': 'Obre les notes',
|
||||
'assistant.card.collab.earlier': '{count} missatges anteriors',
|
||||
'assistant.card.collab.earlier.one': '{count} missatge anterior',
|
||||
'assistant.card.collab.notSent': 'Encara no enviat',
|
||||
'assistant.card.collab.sent': 'Enviat',
|
||||
'assistant.card.collab.failed': 'No enviat',
|
||||
'assistant.card.collab.continues': 'Continua al xat',
|
||||
'assistant.card.collab.youDeleted': 'Has esborrat un missatge',
|
||||
'assistant.card.collab.newPoll': 'Nova',
|
||||
'assistant.card.collab.yourVote': 'El teu vot',
|
||||
'assistant.card.collab.voteTakenBack': 'Vot retirat',
|
||||
'assistant.card.collab.pollClosedWarning': 'Aquesta enquesta està tancada',
|
||||
'assistant.card.collab.unknownOption': 'Aquesta enquesta no té aquesta opció',
|
||||
'assistant.card.collab.moreOptions': '{count} opcions més',
|
||||
'assistant.card.collab.moreOptions.one': '{count} opció més',
|
||||
'assistant.card.collab.morePolls': '{count} enquestes més',
|
||||
'assistant.card.collab.morePolls.one': '{count} enquesta més',
|
||||
'assistant.card.collab.moreNotes': '{count} notes més',
|
||||
'assistant.card.collab.moreNotes.one': '{count} nota més',
|
||||
'assistant.card.collab.files': '{count} fitxers',
|
||||
'assistant.card.collab.files.one': '{count} fitxer',
|
||||
'assistant.card.collab.pinned': 'Fixada',
|
||||
'assistant.card.collab.unpinned': 'Ja no està fixada',
|
||||
'assistant.card.collab.reactionRemoved': 'Reacció {emoji} retirada',
|
||||
'assistant.tool.delete_collab_message': 'Esborra el missatge',
|
||||
'assistant.tool.react_collab_message': 'Reacciona al xat del viatge',
|
||||
'assistant.tool.create_collab_poll': 'Crea una enquesta',
|
||||
'assistant.tool.vote_collab_poll': 'Vota a l’enquesta',
|
||||
'assistant.tool.close_collab_poll': 'Tanca l’enquesta',
|
||||
'assistant.tool.delete_collab_poll': 'Esborra l’enquesta',
|
||||
'assistant.tool.create_collab_note': 'Afegeix una nota compartida',
|
||||
'assistant.tool.update_collab_note': 'Canvia la nota compartida',
|
||||
'assistant.tool.delete_collab_note': 'Esborra la nota compartida',
|
||||
'assistant.step.collabChat': 'Llegint el xat del viatge',
|
||||
'assistant.step.collabPolls': 'Llegint les enquestes',
|
||||
'assistant.step.collabNotes': 'Llegint les notes compartides',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,49 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': 'Chat cesty',
|
||||
'assistant.card.collab.openChat': 'Otevřít chat',
|
||||
'assistant.card.collab.openPolls': 'Otevřít hlasování',
|
||||
'assistant.card.collab.openNotes': 'Otevřít poznámky',
|
||||
'assistant.card.collab.earlier': '{count} starších zpráv',
|
||||
'assistant.card.collab.earlier.one': '{count} starší zpráva',
|
||||
'assistant.card.collab.earlier.few': '{count} starší zprávy',
|
||||
'assistant.card.collab.notSent': 'Zatím neodesláno',
|
||||
'assistant.card.collab.sent': 'Odesláno',
|
||||
'assistant.card.collab.failed': 'Neodesláno',
|
||||
'assistant.card.collab.continues': 'Pokračuje v chatu',
|
||||
'assistant.card.collab.youDeleted': 'Smazal(a) jsi zprávu',
|
||||
'assistant.card.collab.newPoll': 'Nové',
|
||||
'assistant.card.collab.yourVote': 'Tvůj hlas',
|
||||
'assistant.card.collab.voteTakenBack': 'Hlas vzat zpět',
|
||||
'assistant.card.collab.pollClosedWarning': 'Toto hlasování je uzavřené',
|
||||
'assistant.card.collab.unknownOption': 'Toto hlasování takovou možnost nemá',
|
||||
'assistant.card.collab.moreOptions': 'Dalších {count} možností',
|
||||
'assistant.card.collab.moreOptions.one': 'Další {count} možnost',
|
||||
'assistant.card.collab.moreOptions.few': 'Další {count} možnosti',
|
||||
'assistant.card.collab.morePolls': 'Dalších {count} hlasování',
|
||||
'assistant.card.collab.morePolls.one': 'Další {count} hlasování',
|
||||
'assistant.card.collab.morePolls.few': 'Další {count} hlasování',
|
||||
'assistant.card.collab.moreNotes': 'Dalších {count} poznámek',
|
||||
'assistant.card.collab.moreNotes.one': 'Další {count} poznámka',
|
||||
'assistant.card.collab.moreNotes.few': 'Další {count} poznámky',
|
||||
'assistant.card.collab.files': '{count} souborů',
|
||||
'assistant.card.collab.files.one': '{count} soubor',
|
||||
'assistant.card.collab.files.few': '{count} soubory',
|
||||
'assistant.card.collab.pinned': 'Připnuto',
|
||||
'assistant.card.collab.unpinned': 'Už není připnuto',
|
||||
'assistant.card.collab.reactionRemoved': 'Reakce {emoji} vzata zpět',
|
||||
'assistant.tool.delete_collab_message': 'Smazat zprávu',
|
||||
'assistant.tool.react_collab_message': 'Reagovat v chatu cesty',
|
||||
'assistant.tool.create_collab_poll': 'Zahájit hlasování',
|
||||
'assistant.tool.vote_collab_poll': 'Hlasovat',
|
||||
'assistant.tool.close_collab_poll': 'Uzavřít hlasování',
|
||||
'assistant.tool.delete_collab_poll': 'Smazat hlasování',
|
||||
'assistant.tool.create_collab_note': 'Přidat sdílenou poznámku',
|
||||
'assistant.tool.update_collab_note': 'Změnit sdílenou poznámku',
|
||||
'assistant.tool.delete_collab_note': 'Smazat sdílenou poznámku',
|
||||
'assistant.step.collabChat': 'Čtu chat cesty',
|
||||
'assistant.step.collabPolls': 'Čtu hlasování',
|
||||
'assistant.step.collabNotes': 'Čtu sdílené poznámky',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,44 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': 'Trip-Chat',
|
||||
'assistant.card.collab.openChat': 'Chat öffnen',
|
||||
'assistant.card.collab.openPolls': 'Umfragen öffnen',
|
||||
'assistant.card.collab.openNotes': 'Notizen öffnen',
|
||||
'assistant.card.collab.earlier': '{count} ältere Nachrichten',
|
||||
'assistant.card.collab.earlier.one': '{count} ältere Nachricht',
|
||||
'assistant.card.collab.notSent': 'Noch nicht gesendet',
|
||||
'assistant.card.collab.sent': 'Gesendet',
|
||||
'assistant.card.collab.failed': 'Nicht gesendet',
|
||||
'assistant.card.collab.continues': 'Geht im Chat weiter',
|
||||
'assistant.card.collab.youDeleted': 'Du hast eine Nachricht gelöscht',
|
||||
'assistant.card.collab.newPoll': 'Neu',
|
||||
'assistant.card.collab.yourVote': 'Deine Stimme',
|
||||
'assistant.card.collab.voteTakenBack': 'Stimme zurückgenommen',
|
||||
'assistant.card.collab.pollClosedWarning': 'Diese Umfrage ist geschlossen',
|
||||
'assistant.card.collab.unknownOption': 'Diese Antwort gibt es in der Umfrage nicht',
|
||||
'assistant.card.collab.moreOptions': '{count} weitere Antworten',
|
||||
'assistant.card.collab.moreOptions.one': '{count} weitere Antwort',
|
||||
'assistant.card.collab.morePolls': '{count} weitere Umfragen',
|
||||
'assistant.card.collab.morePolls.one': '{count} weitere Umfrage',
|
||||
'assistant.card.collab.moreNotes': '{count} weitere Notizen',
|
||||
'assistant.card.collab.moreNotes.one': '{count} weitere Notiz',
|
||||
'assistant.card.collab.files': '{count} Dateien',
|
||||
'assistant.card.collab.files.one': '{count} Datei',
|
||||
'assistant.card.collab.pinned': 'Angeheftet',
|
||||
'assistant.card.collab.unpinned': 'Nicht mehr angeheftet',
|
||||
'assistant.card.collab.reactionRemoved': 'Reaktion {emoji} zurückgenommen',
|
||||
'assistant.tool.delete_collab_message': 'Chatnachricht löschen',
|
||||
'assistant.tool.react_collab_message': 'Im Trip-Chat reagieren',
|
||||
'assistant.tool.create_collab_poll': 'Umfrage starten',
|
||||
'assistant.tool.vote_collab_poll': 'In der Umfrage abstimmen',
|
||||
'assistant.tool.close_collab_poll': 'Umfrage schließen',
|
||||
'assistant.tool.delete_collab_poll': 'Umfrage löschen',
|
||||
'assistant.tool.create_collab_note': 'Gemeinsame Notiz hinzufügen',
|
||||
'assistant.tool.update_collab_note': 'Gemeinsame Notiz ändern',
|
||||
'assistant.tool.delete_collab_note': 'Gemeinsame Notiz löschen',
|
||||
'assistant.step.collabChat': 'Liest den Trip-Chat',
|
||||
'assistant.step.collabPolls': 'Liest die Umfragen',
|
||||
'assistant.step.collabNotes': 'Liest die gemeinsamen Notizen',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,44 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': 'Trip chat',
|
||||
'assistant.card.collab.openChat': 'Open the chat',
|
||||
'assistant.card.collab.openPolls': 'Open the polls',
|
||||
'assistant.card.collab.openNotes': 'Open the notes',
|
||||
'assistant.card.collab.earlier': '{count} earlier messages',
|
||||
'assistant.card.collab.earlier.one': '{count} earlier message',
|
||||
'assistant.card.collab.notSent': 'Not sent yet',
|
||||
'assistant.card.collab.sent': 'Sent',
|
||||
'assistant.card.collab.failed': 'Not sent',
|
||||
'assistant.card.collab.continues': 'Continues in the chat',
|
||||
'assistant.card.collab.youDeleted': 'You deleted a message',
|
||||
'assistant.card.collab.newPoll': 'New',
|
||||
'assistant.card.collab.yourVote': 'Your vote',
|
||||
'assistant.card.collab.voteTakenBack': 'Vote taken back',
|
||||
'assistant.card.collab.pollClosedWarning': 'This poll is closed',
|
||||
'assistant.card.collab.unknownOption': 'This poll has no such option',
|
||||
'assistant.card.collab.moreOptions': '{count} more options',
|
||||
'assistant.card.collab.moreOptions.one': '{count} more option',
|
||||
'assistant.card.collab.morePolls': '{count} more polls',
|
||||
'assistant.card.collab.morePolls.one': '{count} more poll',
|
||||
'assistant.card.collab.moreNotes': '{count} more notes',
|
||||
'assistant.card.collab.moreNotes.one': '{count} more note',
|
||||
'assistant.card.collab.files': '{count} files',
|
||||
'assistant.card.collab.files.one': '{count} file',
|
||||
'assistant.card.collab.pinned': 'Pinned',
|
||||
'assistant.card.collab.unpinned': 'No longer pinned',
|
||||
'assistant.card.collab.reactionRemoved': 'Reaction {emoji} taken back',
|
||||
'assistant.tool.delete_collab_message': 'Delete the chat message',
|
||||
'assistant.tool.react_collab_message': 'React in the trip chat',
|
||||
'assistant.tool.create_collab_poll': 'Start a poll',
|
||||
'assistant.tool.vote_collab_poll': 'Vote in the poll',
|
||||
'assistant.tool.close_collab_poll': 'Close the poll',
|
||||
'assistant.tool.delete_collab_poll': 'Delete the poll',
|
||||
'assistant.tool.create_collab_note': 'Add a shared note',
|
||||
'assistant.tool.update_collab_note': 'Change the shared note',
|
||||
'assistant.tool.delete_collab_note': 'Delete the shared note',
|
||||
'assistant.step.collabChat': 'Reading the trip chat',
|
||||
'assistant.step.collabPolls': 'Reading the polls',
|
||||
'assistant.step.collabNotes': 'Reading the shared notes',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,44 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': 'Chat del viaje',
|
||||
'assistant.card.collab.openChat': 'Abrir el chat',
|
||||
'assistant.card.collab.openPolls': 'Abrir las encuestas',
|
||||
'assistant.card.collab.openNotes': 'Abrir las notas',
|
||||
'assistant.card.collab.earlier': '{count} mensajes anteriores',
|
||||
'assistant.card.collab.earlier.one': '{count} mensaje anterior',
|
||||
'assistant.card.collab.notSent': 'Aún no enviado',
|
||||
'assistant.card.collab.sent': 'Enviado',
|
||||
'assistant.card.collab.failed': 'No enviado',
|
||||
'assistant.card.collab.continues': 'Sigue en el chat',
|
||||
'assistant.card.collab.youDeleted': 'Has borrado un mensaje',
|
||||
'assistant.card.collab.newPoll': 'Nueva',
|
||||
'assistant.card.collab.yourVote': 'Tu voto',
|
||||
'assistant.card.collab.voteTakenBack': 'Voto retirado',
|
||||
'assistant.card.collab.pollClosedWarning': 'Esta encuesta está cerrada',
|
||||
'assistant.card.collab.unknownOption': 'Esta encuesta no tiene esa opción',
|
||||
'assistant.card.collab.moreOptions': '{count} opciones más',
|
||||
'assistant.card.collab.moreOptions.one': '{count} opción más',
|
||||
'assistant.card.collab.morePolls': '{count} encuestas más',
|
||||
'assistant.card.collab.morePolls.one': '{count} encuesta más',
|
||||
'assistant.card.collab.moreNotes': '{count} notas más',
|
||||
'assistant.card.collab.moreNotes.one': '{count} nota más',
|
||||
'assistant.card.collab.files': '{count} archivos',
|
||||
'assistant.card.collab.files.one': '{count} archivo',
|
||||
'assistant.card.collab.pinned': 'Fijada',
|
||||
'assistant.card.collab.unpinned': 'Ya no está fijada',
|
||||
'assistant.card.collab.reactionRemoved': 'Reacción {emoji} retirada',
|
||||
'assistant.tool.delete_collab_message': 'Eliminar el mensaje',
|
||||
'assistant.tool.react_collab_message': 'Reaccionar en el chat del viaje',
|
||||
'assistant.tool.create_collab_poll': 'Crear una encuesta',
|
||||
'assistant.tool.vote_collab_poll': 'Votar en la encuesta',
|
||||
'assistant.tool.close_collab_poll': 'Cerrar la encuesta',
|
||||
'assistant.tool.delete_collab_poll': 'Eliminar la encuesta',
|
||||
'assistant.tool.create_collab_note': 'Añadir una nota compartida',
|
||||
'assistant.tool.update_collab_note': 'Cambiar la nota compartida',
|
||||
'assistant.tool.delete_collab_note': 'Eliminar la nota compartida',
|
||||
'assistant.step.collabChat': 'Leyendo el chat del viaje',
|
||||
'assistant.step.collabPolls': 'Leyendo las encuestas',
|
||||
'assistant.step.collabNotes': 'Leyendo las notas compartidas',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,44 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': 'Reisivestlus',
|
||||
'assistant.card.collab.openChat': 'Ava vestlus',
|
||||
'assistant.card.collab.openPolls': 'Ava küsitlused',
|
||||
'assistant.card.collab.openNotes': 'Ava märkmed',
|
||||
'assistant.card.collab.earlier': '{count} varasemat sõnumit',
|
||||
'assistant.card.collab.earlier.one': '{count} varasem sõnum',
|
||||
'assistant.card.collab.notSent': 'Pole veel saadetud',
|
||||
'assistant.card.collab.sent': 'Saadetud',
|
||||
'assistant.card.collab.failed': 'Saatmata',
|
||||
'assistant.card.collab.continues': 'Jätkub vestluses',
|
||||
'assistant.card.collab.youDeleted': 'Sa kustutasid sõnumi',
|
||||
'assistant.card.collab.newPoll': 'Uus',
|
||||
'assistant.card.collab.yourVote': 'Sinu hääl',
|
||||
'assistant.card.collab.voteTakenBack': 'Hääl võetud tagasi',
|
||||
'assistant.card.collab.pollClosedWarning': 'See küsitlus on suletud',
|
||||
'assistant.card.collab.unknownOption': 'Selles küsitluses pole sellist valikut',
|
||||
'assistant.card.collab.moreOptions': 'Veel {count} valikut',
|
||||
'assistant.card.collab.moreOptions.one': 'Veel {count} valik',
|
||||
'assistant.card.collab.morePolls': 'Veel {count} küsitlust',
|
||||
'assistant.card.collab.morePolls.one': 'Veel {count} küsitlus',
|
||||
'assistant.card.collab.moreNotes': 'Veel {count} märget',
|
||||
'assistant.card.collab.moreNotes.one': 'Veel {count} märge',
|
||||
'assistant.card.collab.files': '{count} faili',
|
||||
'assistant.card.collab.files.one': '{count} fail',
|
||||
'assistant.card.collab.pinned': 'Kinnitatud',
|
||||
'assistant.card.collab.unpinned': 'Pole enam kinnitatud',
|
||||
'assistant.card.collab.reactionRemoved': 'Reaktsioon {emoji} võetud tagasi',
|
||||
'assistant.tool.delete_collab_message': 'Kustuta vestlussõnum',
|
||||
'assistant.tool.react_collab_message': 'Reageeri reisivestluses',
|
||||
'assistant.tool.create_collab_poll': 'Alusta küsitlust',
|
||||
'assistant.tool.vote_collab_poll': 'Hääleta küsitluses',
|
||||
'assistant.tool.close_collab_poll': 'Sulge küsitlus',
|
||||
'assistant.tool.delete_collab_poll': 'Kustuta küsitlus',
|
||||
'assistant.tool.create_collab_note': 'Lisa jagatud märge',
|
||||
'assistant.tool.update_collab_note': 'Muuda jagatud märget',
|
||||
'assistant.tool.delete_collab_note': 'Kustuta jagatud märge',
|
||||
'assistant.step.collabChat': 'Loen reisivestlust',
|
||||
'assistant.step.collabPolls': 'Loen küsitlusi',
|
||||
'assistant.step.collabNotes': 'Loen jagatud märkmeid',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,44 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': 'Discussion du voyage',
|
||||
'assistant.card.collab.openChat': 'Ouvrir la discussion',
|
||||
'assistant.card.collab.openPolls': 'Ouvrir les sondages',
|
||||
'assistant.card.collab.openNotes': 'Ouvrir les notes',
|
||||
'assistant.card.collab.earlier': '{count} messages plus anciens',
|
||||
'assistant.card.collab.earlier.one': '{count} message plus ancien',
|
||||
'assistant.card.collab.notSent': 'Pas encore envoyé',
|
||||
'assistant.card.collab.sent': 'Envoyé',
|
||||
'assistant.card.collab.failed': 'Non envoyé',
|
||||
'assistant.card.collab.continues': 'La suite est dans la discussion',
|
||||
'assistant.card.collab.youDeleted': 'Vous avez supprimé un message',
|
||||
'assistant.card.collab.newPoll': 'Nouveau',
|
||||
'assistant.card.collab.yourVote': 'Votre vote',
|
||||
'assistant.card.collab.voteTakenBack': 'Vote retiré',
|
||||
'assistant.card.collab.pollClosedWarning': 'Ce sondage est fermé',
|
||||
'assistant.card.collab.unknownOption': 'Ce sondage n’a pas cette option',
|
||||
'assistant.card.collab.moreOptions': '{count} autres options',
|
||||
'assistant.card.collab.moreOptions.one': '{count} autre option',
|
||||
'assistant.card.collab.morePolls': '{count} autres sondages',
|
||||
'assistant.card.collab.morePolls.one': '{count} autre sondage',
|
||||
'assistant.card.collab.moreNotes': '{count} autres notes',
|
||||
'assistant.card.collab.moreNotes.one': '{count} autre note',
|
||||
'assistant.card.collab.files': '{count} fichiers',
|
||||
'assistant.card.collab.files.one': '{count} fichier',
|
||||
'assistant.card.collab.pinned': 'Épinglée',
|
||||
'assistant.card.collab.unpinned': 'N’est plus épinglée',
|
||||
'assistant.card.collab.reactionRemoved': 'Réaction {emoji} retirée',
|
||||
'assistant.tool.delete_collab_message': 'Supprimer le message',
|
||||
'assistant.tool.react_collab_message': 'Réagir dans la discussion du voyage',
|
||||
'assistant.tool.create_collab_poll': 'Lancer un sondage',
|
||||
'assistant.tool.vote_collab_poll': 'Voter dans le sondage',
|
||||
'assistant.tool.close_collab_poll': 'Fermer le sondage',
|
||||
'assistant.tool.delete_collab_poll': 'Supprimer le sondage',
|
||||
'assistant.tool.create_collab_note': 'Ajouter une note partagée',
|
||||
'assistant.tool.update_collab_note': 'Modifier la note partagée',
|
||||
'assistant.tool.delete_collab_note': 'Supprimer la note partagée',
|
||||
'assistant.step.collabChat': 'Lecture de la discussion du voyage',
|
||||
'assistant.step.collabPolls': 'Lecture des sondages',
|
||||
'assistant.step.collabNotes': 'Lecture des notes partagées',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,44 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': 'Συνομιλία ταξιδιού',
|
||||
'assistant.card.collab.openChat': 'Άνοιγμα συνομιλίας',
|
||||
'assistant.card.collab.openPolls': 'Άνοιγμα δημοσκοπήσεων',
|
||||
'assistant.card.collab.openNotes': 'Άνοιγμα σημειώσεων',
|
||||
'assistant.card.collab.earlier': '{count} παλαιότερα μηνύματα',
|
||||
'assistant.card.collab.earlier.one': '{count} παλαιότερο μήνυμα',
|
||||
'assistant.card.collab.notSent': 'Δεν στάλθηκε ακόμα',
|
||||
'assistant.card.collab.sent': 'Στάλθηκε',
|
||||
'assistant.card.collab.failed': 'Δεν στάλθηκε',
|
||||
'assistant.card.collab.continues': 'Συνεχίζεται στη συνομιλία',
|
||||
'assistant.card.collab.youDeleted': 'Διέγραψες ένα μήνυμα',
|
||||
'assistant.card.collab.newPoll': 'Νέα',
|
||||
'assistant.card.collab.yourVote': 'Η ψήφος σου',
|
||||
'assistant.card.collab.voteTakenBack': 'Η ψήφος αποσύρθηκε',
|
||||
'assistant.card.collab.pollClosedWarning': 'Αυτή η δημοσκόπηση έχει κλείσει',
|
||||
'assistant.card.collab.unknownOption': 'Αυτή η δημοσκόπηση δεν έχει τέτοια επιλογή',
|
||||
'assistant.card.collab.moreOptions': 'Ακόμα {count} επιλογές',
|
||||
'assistant.card.collab.moreOptions.one': 'Ακόμα {count} επιλογή',
|
||||
'assistant.card.collab.morePolls': 'Ακόμα {count} δημοσκοπήσεις',
|
||||
'assistant.card.collab.morePolls.one': 'Ακόμα {count} δημοσκόπηση',
|
||||
'assistant.card.collab.moreNotes': 'Ακόμα {count} σημειώσεις',
|
||||
'assistant.card.collab.moreNotes.one': 'Ακόμα {count} σημείωση',
|
||||
'assistant.card.collab.files': '{count} αρχεία',
|
||||
'assistant.card.collab.files.one': '{count} αρχείο',
|
||||
'assistant.card.collab.pinned': 'Καρφιτσωμένη',
|
||||
'assistant.card.collab.unpinned': 'Δεν είναι πια καρφιτσωμένη',
|
||||
'assistant.card.collab.reactionRemoved': 'Η αντίδραση {emoji} αποσύρθηκε',
|
||||
'assistant.tool.delete_collab_message': 'Διαγραφή μηνύματος',
|
||||
'assistant.tool.react_collab_message': 'Αντίδραση στη συνομιλία ταξιδιού',
|
||||
'assistant.tool.create_collab_poll': 'Έναρξη δημοσκόπησης',
|
||||
'assistant.tool.vote_collab_poll': 'Ψήφος στη δημοσκόπηση',
|
||||
'assistant.tool.close_collab_poll': 'Κλείσιμο δημοσκόπησης',
|
||||
'assistant.tool.delete_collab_poll': 'Διαγραφή δημοσκόπησης',
|
||||
'assistant.tool.create_collab_note': 'Προσθήκη κοινής σημείωσης',
|
||||
'assistant.tool.update_collab_note': 'Αλλαγή κοινής σημείωσης',
|
||||
'assistant.tool.delete_collab_note': 'Διαγραφή κοινής σημείωσης',
|
||||
'assistant.step.collabChat': 'Διαβάζω τη συνομιλία ταξιδιού',
|
||||
'assistant.step.collabPolls': 'Διαβάζω τις δημοσκοπήσεις',
|
||||
'assistant.step.collabNotes': 'Διαβάζω τις κοινές σημειώσεις',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,44 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': 'Utazási csevegés',
|
||||
'assistant.card.collab.openChat': 'Csevegés megnyitása',
|
||||
'assistant.card.collab.openPolls': 'Szavazások megnyitása',
|
||||
'assistant.card.collab.openNotes': 'Jegyzetek megnyitása',
|
||||
'assistant.card.collab.earlier': '{count} korábbi üzenet',
|
||||
'assistant.card.collab.earlier.one': '{count} korábbi üzenet',
|
||||
'assistant.card.collab.notSent': 'Még nincs elküldve',
|
||||
'assistant.card.collab.sent': 'Elküldve',
|
||||
'assistant.card.collab.failed': 'Nincs elküldve',
|
||||
'assistant.card.collab.continues': 'A csevegésben folytatódik',
|
||||
'assistant.card.collab.youDeleted': 'Töröltél egy üzenetet',
|
||||
'assistant.card.collab.newPoll': 'Új',
|
||||
'assistant.card.collab.yourVote': 'A szavazatod',
|
||||
'assistant.card.collab.voteTakenBack': 'Szavazat visszavonva',
|
||||
'assistant.card.collab.pollClosedWarning': 'Ez a szavazás lezárult',
|
||||
'assistant.card.collab.unknownOption': 'Ebben a szavazásban nincs ilyen lehetőség',
|
||||
'assistant.card.collab.moreOptions': 'Még {count} lehetőség',
|
||||
'assistant.card.collab.moreOptions.one': 'Még {count} lehetőség',
|
||||
'assistant.card.collab.morePolls': 'Még {count} szavazás',
|
||||
'assistant.card.collab.morePolls.one': 'Még {count} szavazás',
|
||||
'assistant.card.collab.moreNotes': 'Még {count} jegyzet',
|
||||
'assistant.card.collab.moreNotes.one': 'Még {count} jegyzet',
|
||||
'assistant.card.collab.files': '{count} fájl',
|
||||
'assistant.card.collab.files.one': '{count} fájl',
|
||||
'assistant.card.collab.pinned': 'Kitűzve',
|
||||
'assistant.card.collab.unpinned': 'Már nincs kitűzve',
|
||||
'assistant.card.collab.reactionRemoved': '{emoji} reakció visszavonva',
|
||||
'assistant.tool.delete_collab_message': 'Csevegőüzenet törlése',
|
||||
'assistant.tool.react_collab_message': 'Reagálás az utazási csevegésben',
|
||||
'assistant.tool.create_collab_poll': 'Szavazás indítása',
|
||||
'assistant.tool.vote_collab_poll': 'Szavazás leadása',
|
||||
'assistant.tool.close_collab_poll': 'Szavazás lezárása',
|
||||
'assistant.tool.delete_collab_poll': 'Szavazás törlése',
|
||||
'assistant.tool.create_collab_note': 'Közös jegyzet hozzáadása',
|
||||
'assistant.tool.update_collab_note': 'Közös jegyzet módosítása',
|
||||
'assistant.tool.delete_collab_note': 'Közös jegyzet törlése',
|
||||
'assistant.step.collabChat': 'Az utazási csevegés olvasása',
|
||||
'assistant.step.collabPolls': 'A szavazások olvasása',
|
||||
'assistant.step.collabNotes': 'A közös jegyzetek olvasása',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,39 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': 'Obrolan perjalanan',
|
||||
'assistant.card.collab.openChat': 'Buka obrolan',
|
||||
'assistant.card.collab.openPolls': 'Buka polling',
|
||||
'assistant.card.collab.openNotes': 'Buka catatan',
|
||||
'assistant.card.collab.earlier': '{count} pesan sebelumnya',
|
||||
'assistant.card.collab.notSent': 'Belum terkirim',
|
||||
'assistant.card.collab.sent': 'Terkirim',
|
||||
'assistant.card.collab.failed': 'Tidak terkirim',
|
||||
'assistant.card.collab.continues': 'Berlanjut di obrolan',
|
||||
'assistant.card.collab.youDeleted': 'Kamu menghapus pesan',
|
||||
'assistant.card.collab.newPoll': 'Baru',
|
||||
'assistant.card.collab.yourVote': 'Suaramu',
|
||||
'assistant.card.collab.voteTakenBack': 'Suara ditarik',
|
||||
'assistant.card.collab.pollClosedWarning': 'Polling ini sudah ditutup',
|
||||
'assistant.card.collab.unknownOption': 'Polling ini tidak punya pilihan itu',
|
||||
'assistant.card.collab.moreOptions': '{count} pilihan lagi',
|
||||
'assistant.card.collab.morePolls': '{count} polling lagi',
|
||||
'assistant.card.collab.moreNotes': '{count} catatan lagi',
|
||||
'assistant.card.collab.files': '{count} berkas',
|
||||
'assistant.card.collab.pinned': 'Disematkan',
|
||||
'assistant.card.collab.unpinned': 'Tidak lagi disematkan',
|
||||
'assistant.card.collab.reactionRemoved': 'Reaksi {emoji} ditarik',
|
||||
'assistant.tool.delete_collab_message': 'Hapus pesan obrolan',
|
||||
'assistant.tool.react_collab_message': 'Beri reaksi di obrolan perjalanan',
|
||||
'assistant.tool.create_collab_poll': 'Mulai polling',
|
||||
'assistant.tool.vote_collab_poll': 'Pilih di polling',
|
||||
'assistant.tool.close_collab_poll': 'Tutup polling',
|
||||
'assistant.tool.delete_collab_poll': 'Hapus polling',
|
||||
'assistant.tool.create_collab_note': 'Tambah catatan bersama',
|
||||
'assistant.tool.update_collab_note': 'Ubah catatan bersama',
|
||||
'assistant.tool.delete_collab_note': 'Hapus catatan bersama',
|
||||
'assistant.step.collabChat': 'Membaca obrolan perjalanan',
|
||||
'assistant.step.collabPolls': 'Membaca polling',
|
||||
'assistant.step.collabNotes': 'Membaca catatan bersama',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,44 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': 'Chat del viaggio',
|
||||
'assistant.card.collab.openChat': 'Apri la chat',
|
||||
'assistant.card.collab.openPolls': 'Apri i sondaggi',
|
||||
'assistant.card.collab.openNotes': 'Apri le note',
|
||||
'assistant.card.collab.earlier': '{count} messaggi precedenti',
|
||||
'assistant.card.collab.earlier.one': '{count} messaggio precedente',
|
||||
'assistant.card.collab.notSent': 'Non ancora inviato',
|
||||
'assistant.card.collab.sent': 'Inviato',
|
||||
'assistant.card.collab.failed': 'Non inviato',
|
||||
'assistant.card.collab.continues': 'Continua nella chat',
|
||||
'assistant.card.collab.youDeleted': 'Hai eliminato un messaggio',
|
||||
'assistant.card.collab.newPoll': 'Nuovo',
|
||||
'assistant.card.collab.yourVote': 'Il tuo voto',
|
||||
'assistant.card.collab.voteTakenBack': 'Voto ritirato',
|
||||
'assistant.card.collab.pollClosedWarning': 'Questo sondaggio è chiuso',
|
||||
'assistant.card.collab.unknownOption': 'Questo sondaggio non ha questa opzione',
|
||||
'assistant.card.collab.moreOptions': 'Ancora {count} opzioni',
|
||||
'assistant.card.collab.moreOptions.one': 'Ancora {count} opzione',
|
||||
'assistant.card.collab.morePolls': 'Ancora {count} sondaggi',
|
||||
'assistant.card.collab.morePolls.one': 'Ancora {count} sondaggio',
|
||||
'assistant.card.collab.moreNotes': 'Ancora {count} note',
|
||||
'assistant.card.collab.moreNotes.one': 'Ancora {count} nota',
|
||||
'assistant.card.collab.files': '{count} file allegati',
|
||||
'assistant.card.collab.files.one': '{count} file allegato',
|
||||
'assistant.card.collab.pinned': 'Fissata',
|
||||
'assistant.card.collab.unpinned': 'Non più fissata',
|
||||
'assistant.card.collab.reactionRemoved': 'Reazione {emoji} ritirata',
|
||||
'assistant.tool.delete_collab_message': 'Elimina il messaggio',
|
||||
'assistant.tool.react_collab_message': 'Reagisci nella chat del viaggio',
|
||||
'assistant.tool.create_collab_poll': 'Avvia un sondaggio',
|
||||
'assistant.tool.vote_collab_poll': 'Vota nel sondaggio',
|
||||
'assistant.tool.close_collab_poll': 'Chiudi il sondaggio',
|
||||
'assistant.tool.delete_collab_poll': 'Elimina il sondaggio',
|
||||
'assistant.tool.create_collab_note': 'Aggiungi una nota condivisa',
|
||||
'assistant.tool.update_collab_note': 'Modifica la nota condivisa',
|
||||
'assistant.tool.delete_collab_note': 'Elimina la nota condivisa',
|
||||
'assistant.step.collabChat': 'Leggo la chat del viaggio',
|
||||
'assistant.step.collabPolls': 'Leggo i sondaggi',
|
||||
'assistant.step.collabNotes': 'Leggo le note condivise',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,39 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': '旅行チャット',
|
||||
'assistant.card.collab.openChat': 'チャットを開く',
|
||||
'assistant.card.collab.openPolls': '投票を開く',
|
||||
'assistant.card.collab.openNotes': 'ノートを開く',
|
||||
'assistant.card.collab.earlier': 'それ以前のメッセージ {count} 件',
|
||||
'assistant.card.collab.notSent': '未送信',
|
||||
'assistant.card.collab.sent': '送信済み',
|
||||
'assistant.card.collab.failed': '送信できませんでした',
|
||||
'assistant.card.collab.continues': '続きはチャットで',
|
||||
'assistant.card.collab.youDeleted': 'メッセージを削除しました',
|
||||
'assistant.card.collab.newPoll': '新規',
|
||||
'assistant.card.collab.yourVote': 'あなたの票',
|
||||
'assistant.card.collab.voteTakenBack': '投票を取り消しました',
|
||||
'assistant.card.collab.pollClosedWarning': 'この投票は終了しています',
|
||||
'assistant.card.collab.unknownOption': 'この投票にその選択肢はありません',
|
||||
'assistant.card.collab.moreOptions': 'ほかに {count} 個の選択肢',
|
||||
'assistant.card.collab.morePolls': 'ほかに {count} 件の投票',
|
||||
'assistant.card.collab.moreNotes': 'ほかに {count} 件のノート',
|
||||
'assistant.card.collab.files': 'ファイル {count} 件',
|
||||
'assistant.card.collab.pinned': 'ピン留め',
|
||||
'assistant.card.collab.unpinned': 'ピン留めを解除',
|
||||
'assistant.card.collab.reactionRemoved': 'リアクション {emoji} を取り消しました',
|
||||
'assistant.tool.delete_collab_message': 'チャットのメッセージを削除',
|
||||
'assistant.tool.react_collab_message': '旅行チャットでリアクション',
|
||||
'assistant.tool.create_collab_poll': '投票を作成',
|
||||
'assistant.tool.vote_collab_poll': '投票する',
|
||||
'assistant.tool.close_collab_poll': '投票を締め切る',
|
||||
'assistant.tool.delete_collab_poll': '投票を削除',
|
||||
'assistant.tool.create_collab_note': '共有ノートを追加',
|
||||
'assistant.tool.update_collab_note': '共有ノートを変更',
|
||||
'assistant.tool.delete_collab_note': '共有ノートを削除',
|
||||
'assistant.step.collabChat': '旅行チャットを読み込み中',
|
||||
'assistant.step.collabPolls': '投票を読み込み中',
|
||||
'assistant.step.collabNotes': '共有ノートを読み込み中',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,39 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': '여행 채팅',
|
||||
'assistant.card.collab.openChat': '채팅 열기',
|
||||
'assistant.card.collab.openPolls': '투표 열기',
|
||||
'assistant.card.collab.openNotes': '메모 열기',
|
||||
'assistant.card.collab.earlier': '이전 메시지 {count}개',
|
||||
'assistant.card.collab.notSent': '아직 보내지 않음',
|
||||
'assistant.card.collab.sent': '보냄',
|
||||
'assistant.card.collab.failed': '보내지 못함',
|
||||
'assistant.card.collab.continues': '채팅에서 이어집니다',
|
||||
'assistant.card.collab.youDeleted': '메시지를 삭제했습니다',
|
||||
'assistant.card.collab.newPoll': '새 투표',
|
||||
'assistant.card.collab.yourVote': '내 표',
|
||||
'assistant.card.collab.voteTakenBack': '투표를 취소함',
|
||||
'assistant.card.collab.pollClosedWarning': '이 투표는 종료되었습니다',
|
||||
'assistant.card.collab.unknownOption': '이 투표에는 그런 선택지가 없습니다',
|
||||
'assistant.card.collab.moreOptions': '선택지 {count}개 더',
|
||||
'assistant.card.collab.morePolls': '투표 {count}개 더',
|
||||
'assistant.card.collab.moreNotes': '메모 {count}개 더',
|
||||
'assistant.card.collab.files': '파일 {count}개',
|
||||
'assistant.card.collab.pinned': '고정됨',
|
||||
'assistant.card.collab.unpinned': '고정 해제됨',
|
||||
'assistant.card.collab.reactionRemoved': '{emoji} 반응을 취소함',
|
||||
'assistant.tool.delete_collab_message': '채팅 메시지 삭제',
|
||||
'assistant.tool.react_collab_message': '여행 채팅에서 반응하기',
|
||||
'assistant.tool.create_collab_poll': '투표 만들기',
|
||||
'assistant.tool.vote_collab_poll': '투표하기',
|
||||
'assistant.tool.close_collab_poll': '투표 마감',
|
||||
'assistant.tool.delete_collab_poll': '투표 삭제',
|
||||
'assistant.tool.create_collab_note': '공유 메모 추가',
|
||||
'assistant.tool.update_collab_note': '공유 메모 변경',
|
||||
'assistant.tool.delete_collab_note': '공유 메모 삭제',
|
||||
'assistant.step.collabChat': '여행 채팅을 읽는 중',
|
||||
'assistant.step.collabPolls': '투표를 읽는 중',
|
||||
'assistant.step.collabNotes': '공유 메모를 읽는 중',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,44 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': 'Reischat',
|
||||
'assistant.card.collab.openChat': 'Chat openen',
|
||||
'assistant.card.collab.openPolls': 'Peilingen openen',
|
||||
'assistant.card.collab.openNotes': 'Notities openen',
|
||||
'assistant.card.collab.earlier': '{count} oudere berichten',
|
||||
'assistant.card.collab.earlier.one': '{count} ouder bericht',
|
||||
'assistant.card.collab.notSent': 'Nog niet verstuurd',
|
||||
'assistant.card.collab.sent': 'Verstuurd',
|
||||
'assistant.card.collab.failed': 'Niet verstuurd',
|
||||
'assistant.card.collab.continues': 'Gaat verder in de chat',
|
||||
'assistant.card.collab.youDeleted': 'Je hebt een bericht verwijderd',
|
||||
'assistant.card.collab.newPoll': 'Nieuw',
|
||||
'assistant.card.collab.yourVote': 'Jouw stem',
|
||||
'assistant.card.collab.voteTakenBack': 'Stem ingetrokken',
|
||||
'assistant.card.collab.pollClosedWarning': 'Deze peiling is gesloten',
|
||||
'assistant.card.collab.unknownOption': 'Deze peiling heeft die optie niet',
|
||||
'assistant.card.collab.moreOptions': 'Nog {count} opties',
|
||||
'assistant.card.collab.moreOptions.one': 'Nog {count} optie',
|
||||
'assistant.card.collab.morePolls': 'Nog {count} peilingen',
|
||||
'assistant.card.collab.morePolls.one': 'Nog {count} peiling',
|
||||
'assistant.card.collab.moreNotes': 'Nog {count} notities',
|
||||
'assistant.card.collab.moreNotes.one': 'Nog {count} notitie',
|
||||
'assistant.card.collab.files': '{count} bestanden',
|
||||
'assistant.card.collab.files.one': '{count} bestand',
|
||||
'assistant.card.collab.pinned': 'Vastgezet',
|
||||
'assistant.card.collab.unpinned': 'Niet meer vastgezet',
|
||||
'assistant.card.collab.reactionRemoved': 'Reactie {emoji} ingetrokken',
|
||||
'assistant.tool.delete_collab_message': 'Chatbericht verwijderen',
|
||||
'assistant.tool.react_collab_message': 'Reageren in de reischat',
|
||||
'assistant.tool.create_collab_poll': 'Peiling starten',
|
||||
'assistant.tool.vote_collab_poll': 'Stemmen in de peiling',
|
||||
'assistant.tool.close_collab_poll': 'Peiling sluiten',
|
||||
'assistant.tool.delete_collab_poll': 'Peiling verwijderen',
|
||||
'assistant.tool.create_collab_note': 'Gedeelde notitie toevoegen',
|
||||
'assistant.tool.update_collab_note': 'Gedeelde notitie wijzigen',
|
||||
'assistant.tool.delete_collab_note': 'Gedeelde notitie verwijderen',
|
||||
'assistant.step.collabChat': 'De reischat lezen',
|
||||
'assistant.step.collabPolls': 'De peilingen lezen',
|
||||
'assistant.step.collabNotes': 'De gedeelde notities lezen',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,54 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': 'Czat podróży',
|
||||
'assistant.card.collab.openChat': 'Otwórz czat',
|
||||
'assistant.card.collab.openPolls': 'Otwórz ankiety',
|
||||
'assistant.card.collab.openNotes': 'Otwórz notatki',
|
||||
'assistant.card.collab.earlier': '{count} starszej wiadomości',
|
||||
'assistant.card.collab.earlier.one': '{count} starsza wiadomość',
|
||||
'assistant.card.collab.earlier.few': '{count} starsze wiadomości',
|
||||
'assistant.card.collab.earlier.many': '{count} starszych wiadomości',
|
||||
'assistant.card.collab.notSent': 'Jeszcze nie wysłano',
|
||||
'assistant.card.collab.sent': 'Wysłano',
|
||||
'assistant.card.collab.failed': 'Nie wysłano',
|
||||
'assistant.card.collab.continues': 'Ciąg dalszy na czacie',
|
||||
'assistant.card.collab.youDeleted': 'Usunąłeś(-aś) wiadomość',
|
||||
'assistant.card.collab.newPoll': 'Nowa',
|
||||
'assistant.card.collab.yourVote': 'Twój głos',
|
||||
'assistant.card.collab.voteTakenBack': 'Głos wycofany',
|
||||
'assistant.card.collab.pollClosedWarning': 'Ta ankieta jest zamknięta',
|
||||
'assistant.card.collab.unknownOption': 'Ta ankieta nie ma takiej opcji',
|
||||
'assistant.card.collab.moreOptions': 'Jeszcze {count} opcji',
|
||||
'assistant.card.collab.moreOptions.one': 'Jeszcze {count} opcja',
|
||||
'assistant.card.collab.moreOptions.few': 'Jeszcze {count} opcje',
|
||||
'assistant.card.collab.moreOptions.many': 'Jeszcze {count} opcji',
|
||||
'assistant.card.collab.morePolls': 'Jeszcze {count} ankiety',
|
||||
'assistant.card.collab.morePolls.one': 'Jeszcze {count} ankieta',
|
||||
'assistant.card.collab.morePolls.few': 'Jeszcze {count} ankiety',
|
||||
'assistant.card.collab.morePolls.many': 'Jeszcze {count} ankiet',
|
||||
'assistant.card.collab.moreNotes': 'Jeszcze {count} notatki',
|
||||
'assistant.card.collab.moreNotes.one': 'Jeszcze {count} notatka',
|
||||
'assistant.card.collab.moreNotes.few': 'Jeszcze {count} notatki',
|
||||
'assistant.card.collab.moreNotes.many': 'Jeszcze {count} notatek',
|
||||
'assistant.card.collab.files': '{count} pliku',
|
||||
'assistant.card.collab.files.one': '{count} plik',
|
||||
'assistant.card.collab.files.few': '{count} pliki',
|
||||
'assistant.card.collab.files.many': '{count} plików',
|
||||
'assistant.card.collab.pinned': 'Przypięta',
|
||||
'assistant.card.collab.unpinned': 'Już nie przypięta',
|
||||
'assistant.card.collab.reactionRemoved': 'Reakcja {emoji} wycofana',
|
||||
'assistant.tool.delete_collab_message': 'Usuń wiadomość',
|
||||
'assistant.tool.react_collab_message': 'Zareaguj na czacie podróży',
|
||||
'assistant.tool.create_collab_poll': 'Utwórz ankietę',
|
||||
'assistant.tool.vote_collab_poll': 'Zagłosuj w ankiecie',
|
||||
'assistant.tool.close_collab_poll': 'Zamknij ankietę',
|
||||
'assistant.tool.delete_collab_poll': 'Usuń ankietę',
|
||||
'assistant.tool.create_collab_note': 'Dodaj wspólną notatkę',
|
||||
'assistant.tool.update_collab_note': 'Zmień wspólną notatkę',
|
||||
'assistant.tool.delete_collab_note': 'Usuń wspólną notatkę',
|
||||
'assistant.step.collabChat': 'Czytam czat podróży',
|
||||
'assistant.step.collabPolls': 'Czytam ankiety',
|
||||
'assistant.step.collabNotes': 'Czytam wspólne notatki',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,54 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': 'Чат поездки',
|
||||
'assistant.card.collab.openChat': 'Открыть чат',
|
||||
'assistant.card.collab.openPolls': 'Открыть опросы',
|
||||
'assistant.card.collab.openNotes': 'Открыть заметки',
|
||||
'assistant.card.collab.earlier': '{count} более ранних сообщения',
|
||||
'assistant.card.collab.earlier.one': '{count} более раннее сообщение',
|
||||
'assistant.card.collab.earlier.few': '{count} более ранних сообщения',
|
||||
'assistant.card.collab.earlier.many': '{count} более ранних сообщений',
|
||||
'assistant.card.collab.notSent': 'Ещё не отправлено',
|
||||
'assistant.card.collab.sent': 'Отправлено',
|
||||
'assistant.card.collab.failed': 'Не отправлено',
|
||||
'assistant.card.collab.continues': 'Продолжение в чате',
|
||||
'assistant.card.collab.youDeleted': 'Вы удалили сообщение',
|
||||
'assistant.card.collab.newPoll': 'Новый',
|
||||
'assistant.card.collab.yourVote': 'Ваш голос',
|
||||
'assistant.card.collab.voteTakenBack': 'Голос отозван',
|
||||
'assistant.card.collab.pollClosedWarning': 'Этот опрос закрыт',
|
||||
'assistant.card.collab.unknownOption': 'В этом опросе нет такого варианта',
|
||||
'assistant.card.collab.moreOptions': 'Ещё {count} варианта',
|
||||
'assistant.card.collab.moreOptions.one': 'Ещё {count} вариант',
|
||||
'assistant.card.collab.moreOptions.few': 'Ещё {count} варианта',
|
||||
'assistant.card.collab.moreOptions.many': 'Ещё {count} вариантов',
|
||||
'assistant.card.collab.morePolls': 'Ещё {count} опроса',
|
||||
'assistant.card.collab.morePolls.one': 'Ещё {count} опрос',
|
||||
'assistant.card.collab.morePolls.few': 'Ещё {count} опроса',
|
||||
'assistant.card.collab.morePolls.many': 'Ещё {count} опросов',
|
||||
'assistant.card.collab.moreNotes': 'Ещё {count} заметки',
|
||||
'assistant.card.collab.moreNotes.one': 'Ещё {count} заметка',
|
||||
'assistant.card.collab.moreNotes.few': 'Ещё {count} заметки',
|
||||
'assistant.card.collab.moreNotes.many': 'Ещё {count} заметок',
|
||||
'assistant.card.collab.files': '{count} файла',
|
||||
'assistant.card.collab.files.one': '{count} файл',
|
||||
'assistant.card.collab.files.few': '{count} файла',
|
||||
'assistant.card.collab.files.many': '{count} файлов',
|
||||
'assistant.card.collab.pinned': 'Закреплена',
|
||||
'assistant.card.collab.unpinned': 'Больше не закреплена',
|
||||
'assistant.card.collab.reactionRemoved': 'Реакция {emoji} отозвана',
|
||||
'assistant.tool.delete_collab_message': 'Удалить сообщение',
|
||||
'assistant.tool.react_collab_message': 'Отреагировать в чате поездки',
|
||||
'assistant.tool.create_collab_poll': 'Создать опрос',
|
||||
'assistant.tool.vote_collab_poll': 'Проголосовать в опросе',
|
||||
'assistant.tool.close_collab_poll': 'Закрыть опрос',
|
||||
'assistant.tool.delete_collab_poll': 'Удалить опрос',
|
||||
'assistant.tool.create_collab_note': 'Добавить общую заметку',
|
||||
'assistant.tool.update_collab_note': 'Изменить общую заметку',
|
||||
'assistant.tool.delete_collab_note': 'Удалить общую заметку',
|
||||
'assistant.step.collabChat': 'Читаю чат поездки',
|
||||
'assistant.step.collabPolls': 'Читаю опросы',
|
||||
'assistant.step.collabNotes': 'Читаю общие заметки',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,49 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': 'Chat cesty',
|
||||
'assistant.card.collab.openChat': 'Otvoriť chat',
|
||||
'assistant.card.collab.openPolls': 'Otvoriť ankety',
|
||||
'assistant.card.collab.openNotes': 'Otvoriť poznámky',
|
||||
'assistant.card.collab.earlier': '{count} starších správ',
|
||||
'assistant.card.collab.earlier.one': '{count} staršia správa',
|
||||
'assistant.card.collab.earlier.few': '{count} staršie správy',
|
||||
'assistant.card.collab.notSent': 'Zatiaľ neodoslané',
|
||||
'assistant.card.collab.sent': 'Odoslané',
|
||||
'assistant.card.collab.failed': 'Neodoslané',
|
||||
'assistant.card.collab.continues': 'Pokračuje v chate',
|
||||
'assistant.card.collab.youDeleted': 'Vymazal(a) si správu',
|
||||
'assistant.card.collab.newPoll': 'Nová',
|
||||
'assistant.card.collab.yourVote': 'Tvoj hlas',
|
||||
'assistant.card.collab.voteTakenBack': 'Hlas vzatý späť',
|
||||
'assistant.card.collab.pollClosedWarning': 'Táto anketa je uzavretá',
|
||||
'assistant.card.collab.unknownOption': 'Táto anketa takú možnosť nemá',
|
||||
'assistant.card.collab.moreOptions': 'Ďalších {count} možností',
|
||||
'assistant.card.collab.moreOptions.one': 'Ďalšia {count} možnosť',
|
||||
'assistant.card.collab.moreOptions.few': 'Ďalšie {count} možnosti',
|
||||
'assistant.card.collab.morePolls': 'Ďalších {count} ankiet',
|
||||
'assistant.card.collab.morePolls.one': 'Ďalšia {count} anketa',
|
||||
'assistant.card.collab.morePolls.few': 'Ďalšie {count} ankety',
|
||||
'assistant.card.collab.moreNotes': 'Ďalších {count} poznámok',
|
||||
'assistant.card.collab.moreNotes.one': 'Ďalšia {count} poznámka',
|
||||
'assistant.card.collab.moreNotes.few': 'Ďalšie {count} poznámky',
|
||||
'assistant.card.collab.files': '{count} súborov',
|
||||
'assistant.card.collab.files.one': '{count} súbor',
|
||||
'assistant.card.collab.files.few': '{count} súbory',
|
||||
'assistant.card.collab.pinned': 'Pripnuté',
|
||||
'assistant.card.collab.unpinned': 'Už nie je pripnuté',
|
||||
'assistant.card.collab.reactionRemoved': 'Reakcia {emoji} vzatá späť',
|
||||
'assistant.tool.delete_collab_message': 'Vymazať správu',
|
||||
'assistant.tool.react_collab_message': 'Reagovať v chate cesty',
|
||||
'assistant.tool.create_collab_poll': 'Spustiť anketu',
|
||||
'assistant.tool.vote_collab_poll': 'Hlasovať v ankete',
|
||||
'assistant.tool.close_collab_poll': 'Uzavrieť anketu',
|
||||
'assistant.tool.delete_collab_poll': 'Vymazať anketu',
|
||||
'assistant.tool.create_collab_note': 'Pridať zdieľanú poznámku',
|
||||
'assistant.tool.update_collab_note': 'Zmeniť zdieľanú poznámku',
|
||||
'assistant.tool.delete_collab_note': 'Vymazať zdieľanú poznámku',
|
||||
'assistant.step.collabChat': 'Čítam chat cesty',
|
||||
'assistant.step.collabPolls': 'Čítam ankety',
|
||||
'assistant.step.collabNotes': 'Čítam zdieľané poznámky',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,44 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': 'Reschatt',
|
||||
'assistant.card.collab.openChat': 'Öppna chatten',
|
||||
'assistant.card.collab.openPolls': 'Öppna omröstningarna',
|
||||
'assistant.card.collab.openNotes': 'Öppna noteringarna',
|
||||
'assistant.card.collab.earlier': '{count} äldre meddelanden',
|
||||
'assistant.card.collab.earlier.one': '{count} äldre meddelande',
|
||||
'assistant.card.collab.notSent': 'Inte skickat än',
|
||||
'assistant.card.collab.sent': 'Skickat',
|
||||
'assistant.card.collab.failed': 'Inte skickat',
|
||||
'assistant.card.collab.continues': 'Fortsätter i chatten',
|
||||
'assistant.card.collab.youDeleted': 'Du raderade ett meddelande',
|
||||
'assistant.card.collab.newPoll': 'Ny',
|
||||
'assistant.card.collab.yourVote': 'Din röst',
|
||||
'assistant.card.collab.voteTakenBack': 'Rösten återtagen',
|
||||
'assistant.card.collab.pollClosedWarning': 'Den här omröstningen är stängd',
|
||||
'assistant.card.collab.unknownOption': 'Omröstningen har inget sådant alternativ',
|
||||
'assistant.card.collab.moreOptions': '{count} alternativ till',
|
||||
'assistant.card.collab.moreOptions.one': '{count} alternativ till',
|
||||
'assistant.card.collab.morePolls': '{count} omröstningar till',
|
||||
'assistant.card.collab.morePolls.one': '{count} omröstning till',
|
||||
'assistant.card.collab.moreNotes': '{count} noteringar till',
|
||||
'assistant.card.collab.moreNotes.one': '{count} notering till',
|
||||
'assistant.card.collab.files': '{count} filer',
|
||||
'assistant.card.collab.files.one': '{count} fil',
|
||||
'assistant.card.collab.pinned': 'Fäst',
|
||||
'assistant.card.collab.unpinned': 'Inte längre fäst',
|
||||
'assistant.card.collab.reactionRemoved': 'Reaktionen {emoji} återtagen',
|
||||
'assistant.tool.delete_collab_message': 'Radera chattmeddelandet',
|
||||
'assistant.tool.react_collab_message': 'Reagera i reschatten',
|
||||
'assistant.tool.create_collab_poll': 'Starta en omröstning',
|
||||
'assistant.tool.vote_collab_poll': 'Rösta i omröstningen',
|
||||
'assistant.tool.close_collab_poll': 'Stäng omröstningen',
|
||||
'assistant.tool.delete_collab_poll': 'Radera omröstningen',
|
||||
'assistant.tool.create_collab_note': 'Lägg till en delad notering',
|
||||
'assistant.tool.update_collab_note': 'Ändra den delade noteringen',
|
||||
'assistant.tool.delete_collab_note': 'Radera den delade noteringen',
|
||||
'assistant.step.collabChat': 'Läser reschatten',
|
||||
'assistant.step.collabPolls': 'Läser omröstningarna',
|
||||
'assistant.step.collabNotes': 'Läser de delade noteringarna',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,39 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': 'แชตทริป',
|
||||
'assistant.card.collab.openChat': 'เปิดแชต',
|
||||
'assistant.card.collab.openPolls': 'เปิดโพล',
|
||||
'assistant.card.collab.openNotes': 'เปิดหมายเหตุ',
|
||||
'assistant.card.collab.earlier': 'ข้อความก่อนหน้า {count} ข้อความ',
|
||||
'assistant.card.collab.notSent': 'ยังไม่ได้ส่ง',
|
||||
'assistant.card.collab.sent': 'ส่งแล้ว',
|
||||
'assistant.card.collab.failed': 'ส่งไม่สำเร็จ',
|
||||
'assistant.card.collab.continues': 'อ่านต่อในแชต',
|
||||
'assistant.card.collab.youDeleted': 'คุณลบข้อความหนึ่งข้อความ',
|
||||
'assistant.card.collab.newPoll': 'ใหม่',
|
||||
'assistant.card.collab.yourVote': 'คะแนนของคุณ',
|
||||
'assistant.card.collab.voteTakenBack': 'ถอนคะแนนแล้ว',
|
||||
'assistant.card.collab.pollClosedWarning': 'โพลนี้ปิดแล้ว',
|
||||
'assistant.card.collab.unknownOption': 'โพลนี้ไม่มีตัวเลือกนั้น',
|
||||
'assistant.card.collab.moreOptions': 'อีก {count} ตัวเลือก',
|
||||
'assistant.card.collab.morePolls': 'อีก {count} โพล',
|
||||
'assistant.card.collab.moreNotes': 'อีก {count} หมายเหตุ',
|
||||
'assistant.card.collab.files': '{count} ไฟล์',
|
||||
'assistant.card.collab.pinned': 'ปักหมุดแล้ว',
|
||||
'assistant.card.collab.unpinned': 'เลิกปักหมุดแล้ว',
|
||||
'assistant.card.collab.reactionRemoved': 'ถอนรีแอ็กชัน {emoji} แล้ว',
|
||||
'assistant.tool.delete_collab_message': 'ลบข้อความแชต',
|
||||
'assistant.tool.react_collab_message': 'แสดงความรู้สึกในแชตทริป',
|
||||
'assistant.tool.create_collab_poll': 'เริ่มโพล',
|
||||
'assistant.tool.vote_collab_poll': 'โหวตในโพล',
|
||||
'assistant.tool.close_collab_poll': 'ปิดโพล',
|
||||
'assistant.tool.delete_collab_poll': 'ลบโพล',
|
||||
'assistant.tool.create_collab_note': 'เพิ่มหมายเหตุที่แชร์',
|
||||
'assistant.tool.update_collab_note': 'แก้ไขหมายเหตุที่แชร์',
|
||||
'assistant.tool.delete_collab_note': 'ลบหมายเหตุที่แชร์',
|
||||
'assistant.step.collabChat': 'กำลังอ่านแชตทริป',
|
||||
'assistant.step.collabPolls': 'กำลังอ่านโพล',
|
||||
'assistant.step.collabNotes': 'กำลังอ่านหมายเหตุที่แชร์',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,44 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': 'Gezi sohbeti',
|
||||
'assistant.card.collab.openChat': 'Sohbeti aç',
|
||||
'assistant.card.collab.openPolls': 'Anketleri aç',
|
||||
'assistant.card.collab.openNotes': 'Notları aç',
|
||||
'assistant.card.collab.earlier': '{count} eski mesaj',
|
||||
'assistant.card.collab.earlier.one': '{count} eski mesaj',
|
||||
'assistant.card.collab.notSent': 'Henüz gönderilmedi',
|
||||
'assistant.card.collab.sent': 'Gönderildi',
|
||||
'assistant.card.collab.failed': 'Gönderilmedi',
|
||||
'assistant.card.collab.continues': 'Sohbette devam ediyor',
|
||||
'assistant.card.collab.youDeleted': 'Bir mesajı sildin',
|
||||
'assistant.card.collab.newPoll': 'Yeni',
|
||||
'assistant.card.collab.yourVote': 'Oyun',
|
||||
'assistant.card.collab.voteTakenBack': 'Oy geri alındı',
|
||||
'assistant.card.collab.pollClosedWarning': 'Bu anket kapalı',
|
||||
'assistant.card.collab.unknownOption': 'Bu ankette böyle bir seçenek yok',
|
||||
'assistant.card.collab.moreOptions': '{count} seçenek daha',
|
||||
'assistant.card.collab.moreOptions.one': '{count} seçenek daha',
|
||||
'assistant.card.collab.morePolls': '{count} anket daha',
|
||||
'assistant.card.collab.morePolls.one': '{count} anket daha',
|
||||
'assistant.card.collab.moreNotes': '{count} not daha',
|
||||
'assistant.card.collab.moreNotes.one': '{count} not daha',
|
||||
'assistant.card.collab.files': '{count} dosya',
|
||||
'assistant.card.collab.files.one': '{count} dosya',
|
||||
'assistant.card.collab.pinned': 'Sabitlendi',
|
||||
'assistant.card.collab.unpinned': 'Artık sabitli değil',
|
||||
'assistant.card.collab.reactionRemoved': '{emoji} tepkisi geri alındı',
|
||||
'assistant.tool.delete_collab_message': 'Sohbet mesajını sil',
|
||||
'assistant.tool.react_collab_message': 'Gezi sohbetinde tepki ver',
|
||||
'assistant.tool.create_collab_poll': 'Anket başlat',
|
||||
'assistant.tool.vote_collab_poll': 'Ankette oy ver',
|
||||
'assistant.tool.close_collab_poll': 'Anketi kapat',
|
||||
'assistant.tool.delete_collab_poll': 'Anketi sil',
|
||||
'assistant.tool.create_collab_note': 'Ortak not ekle',
|
||||
'assistant.tool.update_collab_note': 'Ortak notu değiştir',
|
||||
'assistant.tool.delete_collab_note': 'Ortak notu sil',
|
||||
'assistant.step.collabChat': 'Gezi sohbeti okunuyor',
|
||||
'assistant.step.collabPolls': 'Anketler okunuyor',
|
||||
'assistant.step.collabNotes': 'Ortak notlar okunuyor',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,54 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': 'Чат подорожі',
|
||||
'assistant.card.collab.openChat': 'Відкрити чат',
|
||||
'assistant.card.collab.openPolls': 'Відкрити опитування',
|
||||
'assistant.card.collab.openNotes': 'Відкрити нотатки',
|
||||
'assistant.card.collab.earlier': '{count} давнішого повідомлення',
|
||||
'assistant.card.collab.earlier.one': '{count} давніше повідомлення',
|
||||
'assistant.card.collab.earlier.few': '{count} давніші повідомлення',
|
||||
'assistant.card.collab.earlier.many': '{count} давніших повідомлень',
|
||||
'assistant.card.collab.notSent': 'Ще не надіслано',
|
||||
'assistant.card.collab.sent': 'Надіслано',
|
||||
'assistant.card.collab.failed': 'Не надіслано',
|
||||
'assistant.card.collab.continues': 'Продовження в чаті',
|
||||
'assistant.card.collab.youDeleted': 'Ви видалили повідомлення',
|
||||
'assistant.card.collab.newPoll': 'Нове',
|
||||
'assistant.card.collab.yourVote': 'Ваш голос',
|
||||
'assistant.card.collab.voteTakenBack': 'Голос відкликано',
|
||||
'assistant.card.collab.pollClosedWarning': 'Це опитування закрите',
|
||||
'assistant.card.collab.unknownOption': 'У цьому опитуванні немає такого варіанта',
|
||||
'assistant.card.collab.moreOptions': 'Ще {count} варіанта',
|
||||
'assistant.card.collab.moreOptions.one': 'Ще {count} варіант',
|
||||
'assistant.card.collab.moreOptions.few': 'Ще {count} варіанти',
|
||||
'assistant.card.collab.moreOptions.many': 'Ще {count} варіантів',
|
||||
'assistant.card.collab.morePolls': 'Ще {count} опитування',
|
||||
'assistant.card.collab.morePolls.one': 'Ще {count} опитування',
|
||||
'assistant.card.collab.morePolls.few': 'Ще {count} опитування',
|
||||
'assistant.card.collab.morePolls.many': 'Ще {count} опитувань',
|
||||
'assistant.card.collab.moreNotes': 'Ще {count} нотатки',
|
||||
'assistant.card.collab.moreNotes.one': 'Ще {count} нотатка',
|
||||
'assistant.card.collab.moreNotes.few': 'Ще {count} нотатки',
|
||||
'assistant.card.collab.moreNotes.many': 'Ще {count} нотаток',
|
||||
'assistant.card.collab.files': '{count} файлу',
|
||||
'assistant.card.collab.files.one': '{count} файл',
|
||||
'assistant.card.collab.files.few': '{count} файли',
|
||||
'assistant.card.collab.files.many': '{count} файлів',
|
||||
'assistant.card.collab.pinned': 'Закріплено',
|
||||
'assistant.card.collab.unpinned': 'Більше не закріплено',
|
||||
'assistant.card.collab.reactionRemoved': 'Реакцію {emoji} відкликано',
|
||||
'assistant.tool.delete_collab_message': 'Видалити повідомлення',
|
||||
'assistant.tool.react_collab_message': 'Відреагувати в чаті подорожі',
|
||||
'assistant.tool.create_collab_poll': 'Створити опитування',
|
||||
'assistant.tool.vote_collab_poll': 'Проголосувати в опитуванні',
|
||||
'assistant.tool.close_collab_poll': 'Закрити опитування',
|
||||
'assistant.tool.delete_collab_poll': 'Видалити опитування',
|
||||
'assistant.tool.create_collab_note': 'Додати спільну нотатку',
|
||||
'assistant.tool.update_collab_note': 'Змінити спільну нотатку',
|
||||
'assistant.tool.delete_collab_note': 'Видалити спільну нотатку',
|
||||
'assistant.step.collabChat': 'Читаю чат подорожі',
|
||||
'assistant.step.collabPolls': 'Читаю опитування',
|
||||
'assistant.step.collabNotes': 'Читаю спільні нотатки',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,39 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': 'Trò chuyện chuyến đi',
|
||||
'assistant.card.collab.openChat': 'Mở trò chuyện',
|
||||
'assistant.card.collab.openPolls': 'Mở thăm dò ý kiến',
|
||||
'assistant.card.collab.openNotes': 'Mở ghi chú',
|
||||
'assistant.card.collab.earlier': '{count} tin nhắn cũ hơn',
|
||||
'assistant.card.collab.notSent': 'Chưa gửi',
|
||||
'assistant.card.collab.sent': 'Đã gửi',
|
||||
'assistant.card.collab.failed': 'Không gửi được',
|
||||
'assistant.card.collab.continues': 'Còn tiếp trong trò chuyện',
|
||||
'assistant.card.collab.youDeleted': 'Bạn đã xóa một tin nhắn',
|
||||
'assistant.card.collab.newPoll': 'Mới',
|
||||
'assistant.card.collab.yourVote': 'Phiếu của bạn',
|
||||
'assistant.card.collab.voteTakenBack': 'Đã rút phiếu',
|
||||
'assistant.card.collab.pollClosedWarning': 'Cuộc thăm dò này đã đóng',
|
||||
'assistant.card.collab.unknownOption': 'Cuộc thăm dò này không có lựa chọn đó',
|
||||
'assistant.card.collab.moreOptions': 'Thêm {count} lựa chọn',
|
||||
'assistant.card.collab.morePolls': 'Thêm {count} cuộc thăm dò',
|
||||
'assistant.card.collab.moreNotes': 'Thêm {count} ghi chú',
|
||||
'assistant.card.collab.files': '{count} tệp',
|
||||
'assistant.card.collab.pinned': 'Đã ghim',
|
||||
'assistant.card.collab.unpinned': 'Không còn ghim',
|
||||
'assistant.card.collab.reactionRemoved': 'Đã rút biểu cảm {emoji}',
|
||||
'assistant.tool.delete_collab_message': 'Xóa tin nhắn',
|
||||
'assistant.tool.react_collab_message': 'Thả cảm xúc trong trò chuyện chuyến đi',
|
||||
'assistant.tool.create_collab_poll': 'Tạo cuộc thăm dò',
|
||||
'assistant.tool.vote_collab_poll': 'Bỏ phiếu',
|
||||
'assistant.tool.close_collab_poll': 'Đóng cuộc thăm dò',
|
||||
'assistant.tool.delete_collab_poll': 'Xóa cuộc thăm dò',
|
||||
'assistant.tool.create_collab_note': 'Thêm ghi chú chung',
|
||||
'assistant.tool.update_collab_note': 'Sửa ghi chú chung',
|
||||
'assistant.tool.delete_collab_note': 'Xóa ghi chú chung',
|
||||
'assistant.step.collabChat': 'Đang đọc trò chuyện chuyến đi',
|
||||
'assistant.step.collabPolls': 'Đang đọc các cuộc thăm dò',
|
||||
'assistant.step.collabNotes': 'Đang đọc ghi chú chung',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,39 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': '行程聊天',
|
||||
'assistant.card.collab.openChat': '開啟聊天',
|
||||
'assistant.card.collab.openPolls': '開啟投票',
|
||||
'assistant.card.collab.openNotes': '開啟筆記',
|
||||
'assistant.card.collab.earlier': '還有 {count} 則較早的訊息',
|
||||
'assistant.card.collab.notSent': '尚未傳送',
|
||||
'assistant.card.collab.sent': '已傳送',
|
||||
'assistant.card.collab.failed': '未傳送',
|
||||
'assistant.card.collab.continues': '後續內容在聊天中',
|
||||
'assistant.card.collab.youDeleted': '你刪除了一則訊息',
|
||||
'assistant.card.collab.newPoll': '新投票',
|
||||
'assistant.card.collab.yourVote': '你的投票',
|
||||
'assistant.card.collab.voteTakenBack': '已撤回投票',
|
||||
'assistant.card.collab.pollClosedWarning': '此投票已關閉',
|
||||
'assistant.card.collab.unknownOption': '此投票沒有這個選項',
|
||||
'assistant.card.collab.moreOptions': '還有 {count} 個選項',
|
||||
'assistant.card.collab.morePolls': '還有 {count} 個投票',
|
||||
'assistant.card.collab.moreNotes': '還有 {count} 則筆記',
|
||||
'assistant.card.collab.files': '{count} 個檔案',
|
||||
'assistant.card.collab.pinned': '已釘選',
|
||||
'assistant.card.collab.unpinned': '已取消釘選',
|
||||
'assistant.card.collab.reactionRemoved': '已撤回表情回應 {emoji}',
|
||||
'assistant.tool.delete_collab_message': '刪除聊天訊息',
|
||||
'assistant.tool.react_collab_message': '在行程聊天中回應',
|
||||
'assistant.tool.create_collab_poll': '發起投票',
|
||||
'assistant.tool.vote_collab_poll': '在投票中投票',
|
||||
'assistant.tool.close_collab_poll': '關閉投票',
|
||||
'assistant.tool.delete_collab_poll': '刪除投票',
|
||||
'assistant.tool.create_collab_note': '新增共享筆記',
|
||||
'assistant.tool.update_collab_note': '修改共享筆記',
|
||||
'assistant.tool.delete_collab_note': '刪除共享筆記',
|
||||
'assistant.step.collabChat': '正在讀取行程聊天',
|
||||
'assistant.step.collabPolls': '正在讀取投票',
|
||||
'assistant.step.collabNotes': '正在讀取共享筆記',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
@@ -1,4 +1,39 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const assistantCollab: TranslationStrings = {};
|
||||
const assistantCollab: TranslationStrings = {
|
||||
'assistant.card.collab.chatTitle': '行程聊天',
|
||||
'assistant.card.collab.openChat': '打开聊天',
|
||||
'assistant.card.collab.openPolls': '打开投票',
|
||||
'assistant.card.collab.openNotes': '打开笔记',
|
||||
'assistant.card.collab.earlier': '还有 {count} 条更早的消息',
|
||||
'assistant.card.collab.notSent': '尚未发送',
|
||||
'assistant.card.collab.sent': '已发送',
|
||||
'assistant.card.collab.failed': '未发送',
|
||||
'assistant.card.collab.continues': '后续内容在聊天中',
|
||||
'assistant.card.collab.youDeleted': '你删除了一条消息',
|
||||
'assistant.card.collab.newPoll': '新投票',
|
||||
'assistant.card.collab.yourVote': '你的投票',
|
||||
'assistant.card.collab.voteTakenBack': '已撤回投票',
|
||||
'assistant.card.collab.pollClosedWarning': '此投票已关闭',
|
||||
'assistant.card.collab.unknownOption': '此投票没有这个选项',
|
||||
'assistant.card.collab.moreOptions': '还有 {count} 个选项',
|
||||
'assistant.card.collab.morePolls': '还有 {count} 个投票',
|
||||
'assistant.card.collab.moreNotes': '还有 {count} 条笔记',
|
||||
'assistant.card.collab.files': '{count} 个文件',
|
||||
'assistant.card.collab.pinned': '已置顶',
|
||||
'assistant.card.collab.unpinned': '已取消置顶',
|
||||
'assistant.card.collab.reactionRemoved': '已撤回表情回应 {emoji}',
|
||||
'assistant.tool.delete_collab_message': '删除聊天消息',
|
||||
'assistant.tool.react_collab_message': '在行程聊天中回应',
|
||||
'assistant.tool.create_collab_poll': '发起投票',
|
||||
'assistant.tool.vote_collab_poll': '在投票中投票',
|
||||
'assistant.tool.close_collab_poll': '关闭投票',
|
||||
'assistant.tool.delete_collab_poll': '删除投票',
|
||||
'assistant.tool.create_collab_note': '添加共享笔记',
|
||||
'assistant.tool.update_collab_note': '修改共享笔记',
|
||||
'assistant.tool.delete_collab_note': '删除共享笔记',
|
||||
'assistant.step.collabChat': '正在读取行程聊天',
|
||||
'assistant.step.collabPolls': '正在读取投票',
|
||||
'assistant.step.collabNotes': '正在读取共享笔记',
|
||||
};
|
||||
export default assistantCollab;
|
||||
|
||||
Reference in New Issue
Block a user