OutputCellEditor.tsx14.0 KBView on GitHub

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>
  );
}