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

This commit is contained in:
Maurice
2026-10-22 15:50:51 +02:00
parent b4e188fbfc
commit 6e92c2777c
63 changed files with 5147 additions and 251 deletions
@@ -0,0 +1,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;
}
+121 -2
View File
@@ -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'),
},
},
};
+22 -2
View File
@@ -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,