Introduced 1 production defect in 180 days, median 5 days to fix.
'use client';
/**
* The editor for an OUTPUT cell — and the one place a draft in a table gets sent.
*
* An output cell holds JSON (see `cell-output.ts`), and JSON is not something to hand a person in
* a text box. So this is a real form over the payload: where it goes, what it says, and a Send.
* The rich `CellEditor` is deliberately not reused — it would show the raw blob, and a stray
* keystroke inside it would corrupt a structured value into prose.
*
* ── Sending needs no task ──
* `integrations.slack.sendMessage`, `linkedin.messaging.sendDm` and
* `outbound.whatsapp.sendMessage` each take the payload and nothing else, so the cell can send
* itself. That is the whole reason an output cell does not need a `user_tasks` row behind it: the
* only thing a task was buying was its send button.
*
* Which of the three fires is decided ONCE, by `sendableCellOutput`, which resolves the cell
* into a payload whose fields are all required. The alternative — narrow the union at the call
* site, then assert the optional fields are there — is how `undefined` reaches a provider API.
*
* The send is recorded back INTO the cell (`sentAt`, `sentTs`) rather than anywhere else, so the
* table stays the single record of what went out — and a sent row is visibly different from a
* drafted one on the next render, with no second source to consult.
*/
import { useEffect, useRef, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { Loader2, Send } from 'lucide-react';
import { toast } from 'sonner';
import { TABLE_OUTPUT_KIND } from '@zero/server/table';
import { useTRPC } from '@/providers/query-provider';
import {
cellOutputText,
decodeMentionsForEditing,
describeUnsendable,
encodeMentionsFromEditing,
formatCellOutput,
isChatCellOutput,
isOutputSent,
parseCellOutput,
sendableCellOutput,
summarizeCellOutput,
withCellOutputText,
type CellOutput,
type SendableCellOutput,
type SlackCellOutput,
} from './cell-output';
import { OutputTargetPill } from './OutputTargetPill';
import { SlackMentionTextarea } from '@/modules/conversations/components/timeline/composer/SlackMentionTextarea';
import { CELL_POPOVER_MAX_WIDTH } from './constants';
import type { TypedCellProps } from './TypedCells';
/** Keystrokes settle before a write, so one edit is one Y item rather than one per character. */
const AUTOSAVE_MS = 600;
export function OutputCellEditor({ column, value, onCommit, onDone }: TypedCellProps) {
const trpc = useTRPC();
// Assigned below, once the auto-save state it closes over exists. The send mutation is
// declared before it and has to be able to call it.
const dropPendingRef = useRef<() => void>(() => {});
// Seeded once from the cell. An agent writing this same cell mid-edit must not re-seed the form
// under the user's cursor — the same rule `CellEditor` follows for its own content.
const [draft, setDraft] = useState<CellOutput>(
() =>
parseCellOutput(value) ?? {
kind: column.outputKind ?? TABLE_OUTPUT_KIND.SLACK,
},
);
const commit = (next: CellOutput) => onCommit(formatCellOutput(next));
/**
* One success path for all three channels: stamp the cell, drop the un-stamped edit, close.
*
* `sentTs` is Slack's alone (its `ts` is what a posted message is addressed by); LinkedIn and
* WhatsApp send through Unipile, whose id arrives later on the ingest path rather than in the
* response, so those cells carry `sentAt` and nothing else. The stamp goes INTO the cell, not
* into a toast: the record of "this one went out" has to survive a reload, and the table is
* the only place holding it.
*/
const stampSent = (channelName: string, sentTs?: string) => {
const sent = {
...draft,
sentAt: new Date().toISOString(),
...(sentTs ? { sentTs } : {}),
};
setDraft(sent);
dropPendingRef.current();
commit(sent);
toast.success(`Sent on ${channelName}`);
onDone(null);
};
// Typed structurally, not as `Error`: a tRPC client error is not an `Error` instance.
const failed = (error: { message?: string }) => toast.error(error.message || 'Could not send');
const slackSend = useMutation(
trpc.integrations.slack.sendMessage.mutationOptions({
onSuccess: (result: { messageTs?: string } | undefined) =>
stampSent('Slack', result?.messageTs),
onError: failed,
}),
);
const linkedinSend = useMutation(
trpc.linkedin.messaging.sendDm.mutationOptions({
onSuccess: () => stampSent('LinkedIn'),
onError: failed,
}),
);
const whatsappSend = useMutation(
trpc.outbound.whatsapp.sendMessage.mutationOptions({
onSuccess: () => stampSent('WhatsApp'),
onError: failed,
}),
);
const isSending = slackSend.isPending || linkedinSend.isPending || whatsappSend.isPending;
/**
* Fire the one mutation this payload is for.
*
* Saving FIRST is not incidental: a send that succeeds must not leave the table showing older
* text than what actually went out, and the auto-save debounce means the newest keystroke is
* still in flight at the moment the button is pressed.
*/
const send = (payload: SendableCellOutput) => {
commit(draft);
dropPendingRef.current();
if (payload.channel === 'slack') {
slackSend.mutate({
workspaceId: payload.workspaceId,
channelId: payload.channelId,
message: payload.message,
...(payload.threadTs ? { threadTs: payload.threadTs } : {}),
});
return;
}
if (payload.channel === 'linkedin') {
linkedinSend.mutate({
unipileAccountId: payload.unipileAccountId,
text: payload.text,
...(payload.chatId ? { chatId: payload.chatId } : {}),
...(payload.attendeeProviderId
? { attendeeProviderId: payload.attendeeProviderId }
: {}),
});
return;
}
whatsappSend.mutate({
unipileAccountId: payload.unipileAccountId,
text: payload.text,
...(payload.chatId ? { chatId: payload.chatId } : {}),
...(payload.phoneE164 ? { phoneE164: payload.phoneE164 } : {}),
});
};
// The resolved payload, not a boolean — so the send reads its fields off a value that is
// PROVEN to have them rather than off the union with non-null assertions.
const sendable = sendableCellOutput(draft);
const sent = isOutputSent(draft);
// Narrowed ONCE, into a value. `isSlack` as a bare boolean does not narrow `draft` at the use
// sites, and `channelId` / `workspaceId` exist on no other member of the union — which is what
// the five type errors were saying.
const slack: SlackCellOutput | null =
draft.kind === TABLE_OUTPUT_KIND.SLACK ? draft : null;
const message = slack?.message ?? '';
// Under whatever key THIS kind stores its prose — `message` for Slack, `body` for email —
// rather than assuming `message` for all of them, which type-checks against the wide union and
// is silently unreadable on an email payload. `null` for `file`, which has no prose at all.
const body = cellOutputText(draft);
// The box shows `@brock`; the cell stores `<@U0A…|brock>`. Decoding is mention-only, so the
// round trip cannot eat the author's `*bold*` the way a full mrkdwn-to-plain pass would — and
// it is Slack-only, because no other kind's text is mrkdwn.
const editable = slack ? decodeMentionsForEditing(message) : (body ?? '');
/**
* Auto-save. There is no Save button: a draft you edited and clicked away from must not
* silently revert, and a button that only ever gets pressed is ceremony. Debounced so a
* keystroke is not a Y.js transaction — the same "one edit, one item" rule every other cell
* follows (see `PlainCellInput`), just reached on a timer instead of on blur.
*/
const commitRef = useRef(commit);
commitRef.current = commit;
const [pending, setPending] = useState<CellOutput | null>(null);
useEffect(() => {
if (!pending) return;
const timer = setTimeout(() => {
commitRef.current(pending);
setPending(null);
}, AUTOSAVE_MS);
return () => clearTimeout(timer);
}, [pending]);
const edit = (next: CellOutput) => {
setDraft(next);
setPending(next);
};
/**
* Flush on unmount. The debounce above is what keeps a keystroke from being a transaction, but
* it also means the last edit is still in flight when the editor closes — and closing is
* exactly what clicking another cell does. Without this, the final thing you typed is the one
* thing that never gets written, which is the worst possible failure for an auto-save.
*
* Reads `pending` through a ref so the effect can have an empty dep array and genuinely run
* only on unmount, rather than re-subscribing (and flushing early) on every keystroke.
*/
const pendingRef = useRef(pending);
pendingRef.current = pending;
useEffect(
() => () => {
if (pendingRef.current) commitRef.current(pendingRef.current);
},
[],
);
/**
* Drop the un-stamped edit, synchronously.
*
* The flush above and the send path race, and the send loses: a `chat.postMessage` round trip
* is comfortably inside `AUTOSAVE_MS`, so the usual order was commit(sent) → onDone → unmount →
* flush the PRE-send draft back over it. `sentAt` vanished, the cell re-rendered as an unsent
* draft with a live Send button, and the next click sent the message a second time.
*
* The ref is cleared directly rather than only through `setPending`, because the unmount
* happens in the same tick as `onDone` and a queued state update would not have landed yet.
*/
const dropPending = () => {
pendingRef.current = null;
setPending(null);
};
dropPendingRef.current = dropPending;
return (
<div
// Layered over the grid for the same reason a clipped cell's editor is: a draft is a
// paragraph, and a 200px column cannot show one.
className="absolute left-0 top-0 z-30 flex flex-col gap-2 rounded-sm border-2 border-action bg-background p-2 shadow-lg"
style={{ minWidth: 320, width: 'max-content', maxWidth: CELL_POPOVER_MAX_WIDTH }}
>
{/* The destination, as the same pill the collapsed cell shows — so the badge follows the
KIND on both surfaces, and clicking it lands in that channel either way. It is not
editable here: a channel is picked, not typed, and a hand-typed name has no id behind
it so the Send would be dead on arrival. */}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<OutputTargetPill output={draft} fallbackLabel={column.label} />
{sent && <span className="ml-auto shrink-0 text-emerald-600">Sent</span>}
</div>
{/* ONE editing surface — the unibox's own Slack reply box, so `@` opens the same mention
menu and a picked person is stored as a real `<@U…|name>` token rather than text that
looks like a ping and notifies nobody. */}
{sent ? (
/*
* A sent message is a RECORD, not a draft, so it is rendered as static text rather than
* a disabled input. `SlackMentionTextarea` has no readOnly prop, and leaving it editable
* next to an auto-save meant typing into a sent cell silently rewrote what the table
* claims was sent — which is precisely the invariant the `sentAt` stamp exists to hold.
*/
<p className="max-h-64 w-full overflow-y-auto whitespace-pre-wrap break-words rounded-sm border border-border p-2 text-sm text-muted-foreground">
{editable}
</p>
) : slack ? (
<SlackMentionTextarea
autoFocus
aria-label={`${column.label} message`}
value={editable}
onValueChange={(next) =>
edit({ ...slack, message: encodeMentionsFromEditing(next, message) })
}
workspaceId={slack.workspaceId ?? null}
channelId={slack.channelId ?? null}
rows={6}
placeholder="Nothing drafted yet"
className="w-full resize-y rounded-sm border border-border bg-transparent p-2 text-sm outline-none focus:ring-1 focus:ring-action"
/>
) : body !== null ? (
/*
* Every other kind gets a PLAIN box. The mention menu is Slack's — pointing it at an
* email would offer Slack members as recipients — and `@` in an email body is just an
* `@`. Routed through `withCellOutputText` so an email edit lands on `body`, which is
* what `summarizeCellOutput` reads back.
*/
<textarea
autoFocus
aria-label={`${column.label} message`}
value={editable}
rows={6}
placeholder="Nothing drafted yet"
onChange={(event) => edit(withCellOutputText(draft, event.target.value))}
className="w-full resize-y rounded-sm border border-border bg-transparent p-2 text-sm outline-none focus:ring-1 focus:ring-action"
/>
) : (
// `file` has no prose to edit — only the artifact the agent produced.
<p className="w-full rounded-sm border border-border p-2 text-sm text-muted-foreground">
{summarizeCellOutput(draft)}
</p>
)}
<div className="flex items-center justify-end gap-2">
{isChatCellOutput(draft) && !sent && (
<button
type="button"
// Disabled rather than hidden while incomplete: the reason a draft cannot go yet
// (no chat resolved, no text, no account) is worth showing rather than hiding the
// action, and `describeUnsendable` names the missing piece per channel.
disabled={!sendable || isSending}
title={sendable ? 'Send this message' : describeUnsendable(draft)}
onClick={() => {
if (!sendable) return;
send(sendable);
}}
className="flex cursor-pointer items-center gap-1 rounded bg-action px-2 py-1 text-xs text-action-foreground transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
>
{isSending ? <Loader2 className="size-3 animate-spin" /> : <Send className="size-3" />}
Send
</button>
)}
</div>
</div>
);
}