OutputTargetPill.tsx4.6 KBView on GitHub
'use client';

/**
 * Where an output cell's artifact is going, as a pill — and, for a chat draft, the way to it.
 *
 * The destination is the one thing that tells a column of drafts apart, so it leads: a pill at
 * the top of the cell, the channel name at the cell's own `text-sm` in the ordinary foreground
 * colour rather than the muted `text-xs` it used to whisper in. The message follows underneath,
 * which is the part you read rather than scan.
 *
 * One component, used by the cell at rest AND by the open editor, so the two cannot drift on
 * either the look or the click — the same reason `CellRefChip` is shared with the prose mentions.
 *
 * ── Clicking ──
 *
 * A drafted message is only useful next to the conversation it is going to, so the pill opens
 * exactly that — the unibox's own chat view, with the draft in its composer (see
 * `useOpenOutputInChannel`), on Slack, LinkedIn and WhatsApp alike. Every pointer event is
 * stopped before it reaches the cell shell: in the grid a click means "select this cell" and a
 * second one means "open the editor", and neither is what someone aiming at the destination
 * asked for.
 *
 * Kinds with nowhere to go — email, file, a payload whose chat never resolved — render
 * `PillShell` on its own. A chip that looks clickable and does nothing is worse than a plain
 * one, and splitting the openable case into its own component is what keeps `useNavigate` — and
 * so a router context — off the path of an email or a generated file, which can never use it.
 */

import { cn } from '@/lib/utils';
import { ChannelBadgeIcon } from '@/modules/inbox/components/channel-icons';
import {
  describeCellOutputTarget,
  OUTPUT_BADGE_CHANNEL,
  type CellOutput,
} from './cell-output';
import {
  openableOutput,
  useOpenOutputInChannel,
  type OpenableOutput,
} from './open-output-in-channel';

type BadgeChannel = (typeof OUTPUT_BADGE_CHANNEL)[keyof typeof OUTPUT_BADGE_CHANNEL];

export interface OutputTargetPillProps {
  output: CellOutput;
  /** Shown when the payload names no destination yet — the column's own label. */
  fallbackLabel: string;
  className?: string;
}

export function OutputTargetPill({ output, fallbackLabel, className }: OutputTargetPillProps) {
  const badgeChannel = OUTPUT_BADGE_CHANNEL[output.kind];
  const label = describeCellOutputTarget(output) ?? fallbackLabel;
  // Resolved to a VALUE, not a boolean, so the handler reads an address off a payload that is
  // proven to have one — the same rule `OutputCellEditor` follows for its own send.
  const openable = openableOutput(output);

  if (!openable) return <PillShell badgeChannel={badgeChannel} label={label} className={className} />;
  return (
    <OpenablePill
      target={openable}
      badgeChannel={badgeChannel}
      label={label}
      className={className}
    />
  );
}

interface PillShellProps {
  badgeChannel: BadgeChannel;
  label: string;
  className?: string;
  interactive?: React.HTMLAttributes<HTMLSpanElement> & { role?: string; tabIndex?: number };
}

function PillShell({ badgeChannel, label, className, interactive }: PillShellProps) {
  return (
    <span
      {...interactive}
      className={cn(
        'bg-sunken flex max-w-full shrink-0 items-center gap-1.5 rounded-full px-2 py-0.5 leading-5',
        interactive && 'cursor-pointer transition-colors hover:bg-muted',
        className,
      )}
    >
      {badgeChannel && <ChannelBadgeIcon channel={badgeChannel} className="size-3.5 shrink-0" />}
      <span className="truncate text-sm font-medium text-foreground">{label}</span>
    </span>
  );
}

function OpenablePill({
  target,
  badgeChannel,
  label,
  className,
}: {
  target: OpenableOutput;
  badgeChannel: BadgeChannel;
  label: string;
  className?: string;
}) {
  const { open } = useOpenOutputInChannel();

  const activate = (event: React.SyntheticEvent) => {
    event.preventDefault();
    event.stopPropagation();
    open(target);
  };

  return (
    <PillShell
      badgeChannel={badgeChannel}
      label={label}
      className={className}
      interactive={{
        role: 'button',
        tabIndex: 0,
        title: `Open ${label} with this draft in the composer`,
        // Selection and edit both begin on mousedown, and a double click opens the editor. All
        // three have to stop here or opening the channel also opens the cell behind it.
        onMouseDown: (event) => event.stopPropagation(),
        onDoubleClick: (event) => event.stopPropagation(),
        onClick: activate,
        onKeyDown: (event) => {
          if (event.key !== 'Enter' && event.key !== ' ') return;
          activate(event);
        },
      }}
    />
  );
}