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
+62 -3
View File
@@ -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;