cell-output.ts18.7 KBView on GitHub /**
* What an OUTPUT cell holds, and how it survives being a string.
*
* ── Why the cell, and not a task ──
*
* The obvious move was to reuse the Tasks output axis end to end: create a `user_tasks` row per
* cell and put the artifact on its `task_output`. That is where a Slack draft lives today, and it
* comes with a send button for free. It is also wrong for a table: a 40-row fan-out would put 40
* rows in the agenda that duplicate, one for one, the 40 rows the user is already looking at. The
* table IS the review surface; a second one is noise.
*
* And nothing is lost by not having a task. A Slack "draft" has no existence on Slack's side — it
* is data waiting for a send call — so the artifact genuinely IS its payload. Sending goes
* straight to `integrations.slack.sendMessage`, which takes the payload and no task.
*
* The SHAPE is still the Tasks one (`TaskOutput` in aop-schema.ts), field for field, so a cell can
* be lifted onto a task later without a translation layer.
*
* ── Why JSON, in a cell that is a string ──
*
* Every cell is a string, and the pipe mirror is the authoritative serialization
* (`writeFileAsYjs` rebuilds the Y.Doc FROM it). `escapeCellValue` already pins `|`, newlines and
* edge whitespace losslessly, so JSON round-trips through the mirror byte for byte — and a model
* emits JSON far more reliably than any bespoke grammar we could invent for it.
*
* Parsing is TOLERANT on purpose. A cell is not validated at write time (the `table` tool writes
* strings), so anything malformed has to render as what it is — text — rather than throw inside a
* virtualized row and take the grid down.
*/
import { TABLE_OUTPUT_KIND, type TableOutputKind, isTableOutputKind } from '@zero/server/table';
/**
* A produced Slack message. Mirrors `SlackTaskOutput` minus `draftId`, which only exists to tie a
* draft to a task — the thing this deliberately does not have.
*/
export interface SlackCellOutput {
kind: typeof TABLE_OUTPUT_KIND.SLACK;
channelId?: string;
/** Without the `#`. Carried so a cell reads as something without a Slack round-trip. */
channelName?: string;
workspaceId?: string;
/** Set to reply inside a thread rather than post to the channel. */
threadTs?: string;
/** Slack mrkdwn. `<@U123>` mentions are live once sent. */
message?: string;
/** ISO timestamp, written when the message is actually sent. Presence IS "sent". */
sentAt?: string;
/** Slack's `ts` for the posted message, so a sent cell can link to it. */
sentTs?: string;
}
/**
* A LinkedIn DM, drafted in a cell.
*
* Mirrors `LinkedinTaskOutput` (aop-schema.ts) field for field, plus a `recipientName` the
* Slack payload's `channelName` is the precedent for: without it a column of LinkedIn drafts
* is a column of Unipile chat ids, and the cell cannot say who it is to without a round trip.
*
* ── Two ways to address a person, because LinkedIn genuinely has two ──
*
* `chatId` names a conversation that already exists. `attendeeProviderId` names a PERSON, and
* is what a fan-out over a lead list has — those rows have no chat yet, and requiring one
* would make the column fillable only for people you have already spoken to, which is the
* opposite of the case it is for. `linkedin.messaging.sendDm` accepts either.
*/
export interface LinkedinCellOutput {
kind: typeof TABLE_OUTPUT_KIND.LINKEDIN;
/** The Unipile account sending. Required to send: it is which of your seats this goes from. */
unipileAccountId?: string;
/** An existing Unipile chat. */
chatId?: string;
/** The counterpart's LinkedIn provider id, for a first message with no chat behind it. */
attendeeProviderId?: string;
/** Display only — who this is to, so a cell reads as something. */
recipientName?: string;
message?: string;
/** ISO timestamp, written when the message is actually sent. Presence IS "sent". */
sentAt?: string;
}
/** A WhatsApp message. Same shape, and `phoneE164` is its second address — see LinkedIn above. */
export interface WhatsappCellOutput {
kind: typeof TABLE_OUTPUT_KIND.WHATSAPP;
unipileAccountId?: string;
chatId?: string;
/** E.164, e.g. `+14155551234`. Starts a chat with someone there is no thread with yet. */
phoneE164?: string;
recipientName?: string;
message?: string;
sentAt?: string;
}
export interface EmailCellOutput {
kind: typeof TABLE_OUTPUT_KIND.EMAIL;
to?: string[];
subject?: string;
body?: string;
draftId?: string;
threadId?: string;
sentAt?: string;
}
export interface FileCellOutput {
kind: typeof TABLE_OUTPUT_KIND.FILE;
documentId?: string;
documentPath?: string;
title?: string;
/** Present for uniformity across the union, so `sentAt` can be stamped without narrowing. */
sentAt?: string;
}
/** The kinds with no bespoke payload yet — carried so the column type is complete, not partial. */
export interface GenericCellOutput {
kind: Exclude<
TableOutputKind,
| typeof TABLE_OUTPUT_KIND.SLACK
| typeof TABLE_OUTPUT_KIND.EMAIL
| typeof TABLE_OUTPUT_KIND.FILE
| typeof TABLE_OUTPUT_KIND.LINKEDIN
| typeof TABLE_OUTPUT_KIND.WHATSAPP
>;
message?: string;
sentAt?: string;
}
export type CellOutput =
| SlackCellOutput
| EmailCellOutput
| FileCellOutput
| LinkedinCellOutput
| WhatsappCellOutput
| GenericCellOutput;
/** The kinds whose payload is a message to a person on a chat channel. */
export type ChatCellOutput = SlackCellOutput | LinkedinCellOutput | WhatsappCellOutput;
/**
* Read an output cell.
*
* `null` means "this cell does not hold an output" — empty, prose a human typed, a half-written
* blob. Every caller renders that as plain text, which is the only honest thing to do with it.
*/
export function parseCellOutput(value: string): CellOutput | null {
const trimmed = value.trim();
// Cheap reject before `JSON.parse`, because this runs per visible cell per render.
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null;
let raw: unknown;
try {
raw = JSON.parse(trimmed);
} catch {
return null;
}
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null;
// `raw` is already narrowed to a non-null, non-array object, so indexing it needs no cast —
// `Reflect.get` reads an unknown key off it and yields `unknown`, which is the truth.
const read = (key=[redacted] unknown => Reflect.get(raw, key);
const kind = read('kind');
if (!isTableOutputKind(kind)) return null;
// Field-by-field, because the JSON is untrusted (a model wrote it, or an import did) and a
// `channelId` that is secretly a number would reach the Slack API as one. The accumulator is
// typed as the OPTIONAL half of the union — every member's fields are optional strings (plus
// `to`), so one shape covers them all and the union is completed by `kind` on return. That is
// what lets this return a real `CellOutput` with no assertion anywhere in the function.
const fields: Partial<Record<(typeof STRING_FIELDS)[number], string>> & { to?: string[] } = {};
for (const key of STRING_FIELDS) {
const value = read(key);
if (typeof value === 'string' && value !== '') fields[key] = value;
}
const to = read('to');
if (Array.isArray(to)) {
const recipients = to.filter((v): v is string => typeof v === 'string' && v !== '');
if (recipients.length > 0) fields.to = recipients;
}
return { ...fields, kind };
}
const STRING_FIELDS = [
'channelId',
'channelName',
'workspaceId',
'threadTs',
'message',
'sentAt',
'sentTs',
'subject',
'body',
'draftId',
'threadId',
'documentId',
'documentPath',
'title',
// LinkedIn / WhatsApp
'unipileAccountId',
'chatId',
'attendeeProviderId',
'phoneE164',
'recipientName',
] as const;
/**
* Write an output cell.
*
* Keys with no value are dropped rather than emitted as `null`, so an unproduced output is
* `{"kind":"slack"}` — the same "intent declared, artifact not yet made" that `{ kind }` alone
* means on a task.
*/
export function formatCellOutput(output: CellOutput): string {
const compact: Record<string, unknown> = {};
for (const [key, value] of Object.entries(output)) {
if (value === undefined || value === null || value === '') continue;
if (Array.isArray(value) && value.length === 0) continue;
compact[key] = value;
}
return JSON.stringify(compact);
}
/** True once the artifact has actually gone out. Presence of the timestamp IS the signal. */
export function isOutputSent(output: CellOutput): boolean {
return 'sentAt' in output && !!output.sentAt;
}
/** True for the kinds whose payload is a message to a person on a chat channel. */
export function isChatCellOutput(output: CellOutput): output is ChatCellOutput {
return (
output.kind === TABLE_OUTPUT_KIND.SLACK ||
output.kind === TABLE_OUTPUT_KIND.LINKEDIN ||
output.kind === TABLE_OUTPUT_KIND.WHATSAPP
);
}
/** One line of what this cell holds, for the collapsed display. */
export function summarizeCellOutput(output: CellOutput): string {
if (output.kind === TABLE_OUTPUT_KIND.EMAIL) {
return output.subject?.trim() || output.body?.trim() || 'No draft yet';
}
if (output.kind === TABLE_OUTPUT_KIND.FILE) {
return output.title?.trim() || output.documentPath?.trim() || 'No file yet';
}
if (isChatCellOutput(output)) return output.message?.trim() || 'No message yet';
return output.message?.trim() || 'Nothing yet';
}
/**
* The destination, when the kind has one — `#channel` for Slack, recipients for email, the
* counterpart for a DM.
*
* A LinkedIn or WhatsApp cell falls back to its raw address rather than to nothing: a Unipile
* chat id is not a name, but "this is going somewhere I can identify" is a materially different
* state from "this has no recipient at all", and the pill is where that difference shows.
*/
export function describeCellOutputTarget(output: CellOutput): string | null {
if (output.kind === TABLE_OUTPUT_KIND.SLACK) {
return output.channelName ? `#${output.channelName}` : (output.channelId ?? null);
}
if (output.kind === TABLE_OUTPUT_KIND.EMAIL) {
return output.to?.join(', ') ?? null;
}
if (output.kind === TABLE_OUTPUT_KIND.LINKEDIN) {
return output.recipientName?.trim() || output.chatId || output.attendeeProviderId || null;
}
if (output.kind === TABLE_OUTPUT_KIND.WHATSAPP) {
return output.recipientName?.trim() || output.phoneE164 || output.chatId || null;
}
return null;
}
/**
* Output kinds that ARE a messaging channel, and the badge each maps to.
*
* `file` and `calendar` are outputs with no channel, so they get no brand badge rather than
* `ChannelBadgeIcon`'s email fallback — a Gmail mark on a generated file would be a lie. Keyed by
* `TableOutputKind` so a kind added later is a compile error here if it is spelled wrong, rather
* than silently badge-less at runtime.
*/
export const OUTPUT_BADGE_CHANNEL: Partial<
Record<TableOutputKind, 'slack' | 'email' | 'linkedin' | 'whatsapp'>
> = {
slack: 'slack',
email: 'email',
linkedin: 'linkedin',
whatsapp: 'whatsapp',
};
/**
* The free-text body of an output, under whatever key its kind actually stores it — `message`
* for Slack, `body` for email — or `null` for a kind with no prose at all.
*
* The pair exists so an editor never has to guess. Writing `message` onto an email payload
* type-checks (the union is wide) and is silently meaningless: `summarizeCellOutput` reads
* `subject`/`body` and would never show it back.
*/
export function cellOutputText(output: CellOutput): string | null {
if (output.kind === TABLE_OUTPUT_KIND.EMAIL) return output.body ?? '';
if (output.kind === TABLE_OUTPUT_KIND.FILE) return null;
return output.message ?? '';
}
/** The setter half of `cellOutputText`. A kind with no body is returned unchanged. */
export function withCellOutputText(output: CellOutput, text: string): CellOutput {
if (output.kind === TABLE_OUTPUT_KIND.EMAIL) return { ...output, body: text };
if (output.kind === TABLE_OUTPUT_KIND.FILE) return output;
return { ...output, message: text };
}
/**
* What a cell needs in hand before it can be sent — one variant per channel, every field
* REQUIRED.
*
* This is a resolved payload rather than a `boolean` or a narrowed union member, and that is
* the point: the stored types have every field optional (a half-drafted cell is a real state),
* so a call site narrowed to `SlackCellOutput` still had to reach for `workspaceId!` to build
* the mutation. Three non-null assertions on a value a model wrote is exactly the shape that
* ships `undefined` to an API. Resolving once, here, means the send site holds a value that
* cannot be missing anything.
*/
export type SendableCellOutput =
| { channel: 'slack'; workspaceId: string; channelId: string; message: string; threadTs?: string }
| {
channel: 'linkedin';
unipileAccountId: string;
text: string;
chatId?: string;
attendeeProviderId?: string;
}
| {
channel: 'whatsapp';
unipileAccountId: string;
text: string;
chatId?: string;
phoneE164?: string;
};
/**
* The send this cell is ready for, or null.
*
* Null covers three different "not yet"s and deliberately does not distinguish them — already
* sent, no recipient resolved, nothing written. The editor renders one disabled button whose
* tooltip names what is missing; the states differ in prose, not in what may happen next.
*/
export function sendableCellOutput(output: CellOutput): SendableCellOutput | null {
// Only a message to a person can be sent from a cell. An email draft lives in Gmail and a
// file is already made — neither has a "send" this surface could perform.
if (!isChatCellOutput(output) || isOutputSent(output)) return null;
const message = output.message?.trim() ?? '';
if (!message) return null;
if (output.kind === TABLE_OUTPUT_KIND.SLACK) {
// A workspace AND a channel: a channel id alone does not say which Slack it belongs to,
// and a NAME is not an address — `#growth` cannot be posted to.
if (!output.workspaceId || !output.channelId) return null;
return {
channel: 'slack',
workspaceId: output.workspaceId,
channelId: output.channelId,
message,
...(output.threadTs ? { threadTs: output.threadTs } : {}),
};
}
if (output.kind === TABLE_OUTPUT_KIND.LINKEDIN) {
// The seat is not optional — it is which of your accounts this goes from — and there has
// to be somewhere for it to land: an existing chat, or a person to open one with.
if (!output.unipileAccountId) return null;
if (!output.chatId && !output.attendeeProviderId) return null;
return {
channel: 'linkedin',
unipileAccountId: output.unipileAccountId,
text: message,
...(output.chatId ? { chatId: output.chatId } : {}),
...(output.attendeeProviderId ? { attendeeProviderId: output.attendeeProviderId } : {}),
};
}
if (output.kind === TABLE_OUTPUT_KIND.WHATSAPP) {
if (!output.unipileAccountId) return null;
if (!output.chatId && !output.phoneE164) return null;
return {
channel: 'whatsapp',
unipileAccountId: output.unipileAccountId,
text: message,
...(output.chatId ? { chatId: output.chatId } : {}),
...(output.phoneE164 ? { phoneE164: output.phoneE164 } : {}),
};
}
return null;
}
/** Why the Send button is disabled, in the words of the channel it would have sent on. */
export function describeUnsendable(output: CellOutput): string {
if (isOutputSent(output)) return 'Already sent';
if (!output.kind || !isChatCellOutput(output)) return 'This kind cannot be sent from a table';
if (!output.message?.trim()) return 'Needs a message';
if (output.kind === TABLE_OUTPUT_KIND.SLACK) {
return 'Needs a resolved channel and workspace';
}
if (!output.unipileAccountId) return 'Needs the account to send from';
return output.kind === TABLE_OUTPUT_KIND.LINKEDIN
? 'Needs a chat or a LinkedIn profile id'
: 'Needs a chat or a phone number';
}
// ── Mentions, for an editable surface ─────────────────────────────────────────
//
// A stored mention is `<@U0A152K8J5V|brock>` — correct for Slack, unreadable for a person. The
// editor therefore shows `@brock` and converts back on the way in.
//
// This is deliberately NOT `slackTextToPlain`: that also strips `*bold*`, `~strike~` and code
// fences, which is right for a read-only preview and catastrophic for an editable box — every
// decode/encode round trip would silently eat the author's formatting. These two touch mentions
// and nothing else, so anything that is not a mention survives byte for byte.
const MENTION_TOKEN=[redacted];
/** Splits text into `<…>` tokens and the plain runs between them, so encoding skips tokens. */
const TOKEN_OR_TEXT = /<[^>]*>|[^<]+/g;
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** `<@U0A…|brock> hi` → `@brock hi`. Mentions only; every other character is untouched. */
export function decodeMentionsForEditing(raw: string): string {
return raw.replace(MENTION_TOKEN, (_match, id: string, label?: string) => `@${label || id}`);
}
/**
* The inverse, against the mentions the value ALREADY had.
*
* Only handles that were real mentions before the edit are re-encoded — a name typed from
* scratch has no user id to become, and inventing one would ping the wrong person. Those stay
* plain text, which is exactly what Slack would render them as. The `@` menu is the way to add
* a new one, and it inserts a full token that this leaves alone.
*
* Matched from the KNOWN handles rather than from a generic `@\w+` pattern, because a Slack
* label is a display name and may contain spaces: `<@U123|Brock Smith>` decodes to
* `@Brock Smith`, which `@[\w.-]+` could only see as `@Brock`. That missed the map, the mention
* degraded to plain text, and the ping was silently dropped on the next keystroke. Longest
* handle first, so `@Brock Smith` is never eaten by a shorter `@Brock` that also exists.
*/
export function encodeMentionsFromEditing(text: string, original: string): string {
const byHandle = new Map<string, string>();
for (const match of original.matchAll(MENTION_TOKEN)) {
byHandle.set(`@${match[2] || match[1]}`, match[0]);
}
if (byHandle.size === 0) return text;
const handles = [...byHandle.keys()].sort((a, b) => b.length - a.length);
// The trailing guard stops `@brock` from matching inside `@brockton`; a following `.` or `-`
// is allowed through because sentence punctuation is far commoner than a longer handle that
// is not itself in the map.
const pattern = new RegExp(`(?:${handles.map(escapeRegExp).join('|')})(?![\\w])`, 'g');
return text.replace(TOKEN_OR_TEXT, (segment) =>
segment.startsWith('<')
? segment
: segment.replace(pattern, (handle) => byHandle.get(handle) ?? handle),
);
}