CellRefChip.tsx11.6 KBView on GitHub
'use client';

/**
 * A `[[type: id]]` token, rendered as a live chip.
 *
 * The dispatch below is the whole point of the design's step 10: a conversation cell and a
 * conversation `@mention` in a prose paragraph are LITERALLY the same component, so they
 * cannot drift — same data hook, same label rules, same markup. `doc` reuses
 * `FileLinkChipContent` and `event` reuses `EventChipContent` for the same reason. The
 * kinds with no existing chip (task, draft, person, company) share one `RefChip`
 * presentation so they are uniform with each other by construction rather than by
 * convention.
 *
 * Clicking opens the referenced object as the selected artifact — the same entry point
 * `MentionChip` uses (`setSelectedArtifact({ kind, id })`), so a draft cell opens the draft's
 * thread and a conversation cell opens the full ConversationView.
 *
 * Resolution is always live (never frozen at write time), pending renders a skeleton, and
 * an unresolvable id renders a dimmed "missing" chip. Nothing here throws: a table is often
 * the audit trail of a fan-out, and a deleted row must not take the grid down with it.
 */

import type { ComponentType } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Building2, FileEdit, ListChecks, User } from 'lucide-react';

import { cn } from '@/lib/utils';
import { ConversationChipContent } from '@/modules/agentCanvas/extensions/ConversationChip';
import type { ContextKind } from '@/modules/cedar-os/src/store/messages/MessageTypes';
import { REF_CHIP_CLASS, REF_CHIP_MISSING_CLASS } from '@/modules/documents/chip-styles';
import { FileLinkChipContent } from '@/modules/documents/file-link/FileLinkChip';
import { EventChipContent } from '@/modules/documents/mention/EventChip';
import { useCedarStore } from '@/modules/store';
import { useTRPC } from '@/providers/query-provider';
import { linkifyText } from './cell-links';
import type { CellChipRef, CellSegment } from './cell-refs';

export interface CellRefChipProps {
  /** Named `cellRef` rather than `ref`, which React reserves as the ref prop. */
  cellRef: CellChipRef;
  className?: string;
}

export function CellRefChip({ cellRef, className }: CellRefChipProps) {
  const { refType, refId } = cellRef;

  if (refType === 'conversation') {
    return (
      <ConversationChipContent
        conversationId={refId}
        className={className}
        onOpen={(id) => openArtifact('conversation', id)}
      />
    );
  }
  if (refType === 'doc') {
    return <FileLinkChipContent documentId={refId} className={className} />;
  }
  if (refType === 'event') {
    // No live surface takes a bare event id, so an event cell is a label, not a link.
    // `EventChipContent` renders a non-interactive span by default, which is exactly right
    // — a chip that looks clickable and does nothing is worse than a plain chip.
    //
    // `title` is deliberately NOT the refId. Passing it rendered a truncated UUID styled as a
    // name, which is precisely the "leak an internal id as if it were content" that
    // `TableSharePreview` calls out. Omitted, the chip falls back to "Event" — the honest
    // rendering, and the dimmed missing-reference treatment this module promises.
    return <EventChipContent className={className} />;
  }
  if (refType === 'task') return <TaskRefChip taskId={refId} className={className} />;
  if (refType === 'draft') return <DraftRefChip draftId={refId} className={className} />;
  if (refType === 'person') return <PersonRefChip contactId={refId} className={className} />;
  return <CompanyRefChip companyId={refId} className={className} />;
}

/**
 * A cell value's text runs interleaved with its live chips.
 *
 * Shared by the editable display and by `BoundCell` so the two cannot drift on the one thing
 * that is easy to get wrong: a chip is keyed by POSITION, because the same reference may
 * legitimately appear twice in one cell.
 */
export function CellSegments({
  segments,
  wrap = false,
}: {
  segments: CellSegment[];
  /** Let the text between chips break onto further lines instead of being clipped. */
  wrap?: boolean;
}) {
  return (
    <>
      {segments.map((segment, position) =>
        segment.kind === 'text' ? (
          <span
            key=[redacted]
            className={wrap ? 'whitespace-pre-wrap break-words' : 'truncate'}
          >
            <CellText text={segment.text} />
          </span>
        ) : (
          <CellRefChip key=[redacted] cellRef={segment.ref} />
        ),
      )}
    </>
  );
}

/**
 * One text run, with any link in it rendered as a link.
 *
 * ── Why the pointer events stop here ──
 *
 * In the grid a mousedown means "select this cell" and a double click means "open the editor".
 * Neither is what someone aiming at a URL asked for, so all three are stopped before they reach
 * the cell shell — the same three `OutputTargetPill` stops for the same reason. The click itself
 * is NOT prevented: the anchor is a real anchor, so middle-click, cmd-click and "copy link
 * address" all work, which is most of the point of having one.
 *
 * Styling matches `CardFieldValue`'s url/email fields, so an address looks the same in a kanban
 * card as it does in the grid the card was made from.
 */
function CellText({ text }: { text: string }) {
  const runs = linkifyText(text);
  if (runs.length === 1 && runs[0].kind === 'plain') return <>{text}</>;
  return (
    <>
      {runs.map((run, position) =>
        run.kind === 'plain' ? (
          <span key=[redacted]
        ) : (
          <a
            key=[redacted]
            href={run.href}
            // An address opens the mail client in place; a page opens beside the grid rather
            // than navigating away from a table someone is in the middle of reading.
            target={run.href?.startsWith('mailto:') ? undefined : '_blank'}
            rel="noreferrer noopener"
            title={run.text}
            onMouseDown={(event) => event.stopPropagation()}
            onDoubleClick={(event) => event.stopPropagation()}
            onClick={(event) => event.stopPropagation()}
            className="text-primary cursor-pointer underline-offset-2 hover:underline"
          >
            {run.text}
          </a>
        ),
      )}
    </>
  );
}

/** `setSelectedArtifact` outside a render path — the same call `MentionChip` makes on click. */
function openArtifact(kind: ContextKind, id: string): void {
  useCedarStore.getState().setSelectedArtifact({ kind, id });
}

type RefChipState = 'pending' | 'resolved' | 'missing';

interface RefChipProps {
  icon: ComponentType<{ className?: string }>;
  label: string;
  state: RefChipState;
  onOpen?: () => void;
  className?: string;
}

/**
 * The shared presentation for every chip kind that has no editor counterpart. One
 * component so `task`, `draft`, `person` and `company` cannot diverge in padding, size or
 * missing-state treatment — a uniform render path, not four near-copies.
 *
 * A `<span role="button">` rather than a `<button>`, mirroring `MentionChip`: a chip is an
 * inline run inside a cell (and, for the shared chips, inside a ProseMirror node view),
 * where a nested interactive element is the thing to avoid.
 */
function RefChip({ icon: Icon, label, state, onOpen, className }: RefChipProps) {
  return (
    <span
      {...(onOpen ? { role: 'button', tabIndex: 0 } : {})}
      title={label}
      onClick={
        onOpen &&
        ((event: React.MouseEvent) => {
          event.preventDefault();
          event.stopPropagation();
          onOpen();
        })
      }
      onKeyDown={
        onOpen &&
        ((event: React.KeyboardEvent) => {
          if (event.key !== 'Enter' && event.key !== ' ') return;
          event.preventDefault();
          onOpen();
        })
      }
      className={cn(
        REF_CHIP_CLASS,
        onOpen && 'cursor-pointer',
        state === 'missing' && REF_CHIP_MISSING_CLASS,
        className,
      )}
    >
      <Icon className="text-muted-foreground h-3 w-3 shrink-0" />
      {state === 'pending' ? (
        <span className="h-3 w-16 animate-pulse rounded bg-muted-foreground/20" />
      ) : (
        <span className="truncate">{label}</span>
      )}
    </span>
  );
}

/** `isPending` is only meaningful while the query is enabled; `isError` covers the 404. */
function resolutionState(isPending: boolean, isError: boolean, resolved: boolean): RefChipState {
  if (resolved) return 'resolved';
  if (isError) return 'missing';
  return isPending ? 'pending' : 'missing';
}

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

function TaskRefChip({ taskId, className }: { taskId: string; className?: string }) {
  const trpc = useTRPC();
  const isUuid = UUID_RE.test(taskId);
  const { data, isPending, isError } = useQuery({
    ...trpc.userTasks.getTaskById.queryOptions({ taskId }),
    enabled: isUuid,
    retry: false,
  });
  const description = (data as { task?: { description?: string | null } } | undefined)?.task
    ?.description;
  return (
    <RefChip
      icon={ListChecks}
      label={description?.trim() || (isUuid ? 'Missing task' : taskId)}
      state={isUuid ? resolutionState(isPending, isError, !!description) : 'missing'}
      onOpen={isUuid ? () => openArtifact('task', taskId) : undefined}
      className={className}
    />
  );
}

function DraftRefChip({ draftId, className }: { draftId: string; className?: string }) {
  const trpc = useTRPC();
  const { data, isPending, isError } = useQuery({
    ...trpc.drafts.get.queryOptions({ id: draftId }),
    enabled: !!draftId,
    retry: false,
  });
  const draft = data as { subject?: string; threadId?: string } | undefined;
  const subject = draft?.subject?.trim();
  // A draft has no ContextKind of its own — it opens through the email thread it belongs
  // to, which is what every other draft-review surface already does.
  const threadId = draft?.threadId;
  return (
    <RefChip
      icon={FileEdit}
      label={subject || (isError ? 'Missing draft' : 'Draft')}
      state={resolutionState(isPending, isError, !!draft)}
      onOpen={threadId ? () => openArtifact('email_thread', threadId) : undefined}
      className={className}
    />
  );
}

function PersonRefChip({ contactId, className }: { contactId: string; className?: string }) {
  const trpc = useTRPC();
  const isUuid = UUID_RE.test(contactId);
  const { data, isPending, isError } = useQuery({
    ...trpc.crm.getContact.queryOptions({ id: contactId }),
    enabled: isUuid,
    retry: false,
  });
  const contact = data as
    | { personEmail?: string | null; person?: { name?: string | null } | null }
    | undefined;
  const label = contact?.person?.name?.trim() || contact?.personEmail?.trim();
  return (
    <RefChip
      icon={User}
      label={label || (isUuid ? 'Missing person' : contactId)}
      state={isUuid ? resolutionState(isPending, isError, !!label) : 'missing'}
      className={className}
    />
  );
}

function CompanyRefChip({ companyId, className }: { companyId: string; className?: string }) {
  const trpc = useTRPC();
  const isUuid = UUID_RE.test(companyId);
  const { data, isPending, isError } = useQuery({
    ...trpc.crm.getCompany.queryOptions({ id: companyId }),
    enabled: isUuid,
    retry: false,
  });
  const relationship = data as
    | { companyDomain?: string | null; company?: { name?: string | null } | null }
    | undefined;
  const label = relationship?.company?.name?.trim() || relationship?.companyDomain?.trim();
  return (
    <RefChip
      icon={Building2}
      label={label || (isUuid ? 'Missing company' : companyId)}
      state={isUuid ? resolutionState(isPending, isError, !!label) : 'missing'}
      className={className}
    />
  );
}