mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
feat(assistant): chat, note and poll cards drawn like the Collab tab
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user