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