FileLinkChip.tsx7.8 KBView on GitHub
'use client';

import { NodeViewWrapper, type NodeViewProps } from '@tiptap/react';
import { useQuery } from '@tanstack/react-query';
import { FileText, Plus } from 'lucide-react';
import type { FileLinkOptions } from './FileLinkNode';
import { useTRPC } from '@/providers/query-provider';
import { useCedarStore } from '@/modules/store';
import { cn } from '@/lib/utils';
import {
  inferScopeTypeFromPath as scopeTypeFromPath,
  inferScopeIdFromPath as scopeIdFromPath,
  relativeSubPath,
} from '@/modules/files/store/documentsSlice';

interface FileLinkChipContentProps {
  documentId: string;
  className?: string;
}

/**
 * Standalone chip — used outside the editor (sidebars, link previews).
 * The in-editor chip is `FileLinkChip` below; both share the live-fetch
 * pattern but the in-editor variant must mount directly inside
 * `NodeViewWrapper` to avoid PM DOM-reconciliation loops (mirror of
 * `ConversationNodeView` in apps/mail/modules/agentCanvas/extensions/ConversationNode.tsx).
 */
export function FileLinkChipContent({ documentId, className }: FileLinkChipContentProps) {
  const trpc = useTRPC();
  const selectDocumentId = useCedarStore((state) => state.selectDocumentId);
  const setActiveFolderContext = useCedarStore((state) => state.setActiveFolderContext);

  const { data: meta } = useQuery(
    trpc.documents.getDoc.queryOptions(
      { documentId },
      { enabled: !!documentId, staleTime: 5 * 60_000 },
    ),
  );

  const breadcrumb = meta ? relativeSubPath(meta.path) : '';
  // Use `||` (not `??`) so an empty-string title falls through to the path
  // basename — many playbook resource docs have no title set.
  const displayLabel =
    (meta?.title?.trim() || '') ||
    breadcrumb.split('/').findLast(Boolean)?.replace(/\.md$/, '') ||
    'file';

  return (
    <button
      type="button"
      title={breadcrumb || displayLabel}
      className={cn(
        'inline-flex max-w-64 items-center gap-1.5 align-middle rounded-md border bg-muted/50 px-2 py-0.5 text-sm font-medium text-foreground transition-colors hover:bg-primary/10 hover:text-primary',
        className,
      )}
      onClick={(event) => {
        event.preventDefault();
        event.stopPropagation();
        selectDocumentId(documentId);
        if (meta) {
          setActiveFolderContext({
            documentId: meta.id,
            scopeType: scopeTypeFromPath(meta.path),
            scopeId: scopeIdFromPath(meta.path),
            path: meta.path,
            label: displayLabel,
          });
        }
      }}
    >
      <FileText className="size-3 shrink-0 text-muted-foreground" />
      <span className="truncate">{displayLabel}</span>
    </button>
  );
}

/**
 * In-editor NodeView. Renders directly inside `NodeViewWrapper` (no
 * intermediate component, no inner interactive element) and mirrors
 * `ConversationNode`'s exact shape:
 *   - `data-file-link=""` on the wrapper so the live DOM matches both
 *     `parseHTML`'s selector and `renderHTML`'s output (otherwise PM's
 *     MutationObserver tries to reconcile and dispatches transactions
 *     in a loop)
 *   - `contentEditable={false}` so PM treats the inner DOM as opaque
 *   - Live title fetched via `documents.getDoc({documentId})` — renames
 *     propagate automatically
 */
/**
 * `getPos` throws rather than returning undefined once a node view is detached, which
 * happens routinely mid-transaction. Callers want "where is this, if anywhere".
 */
function safePos(getPos: NodeViewProps['getPos']): number | undefined {
  try {
    const pos = getPos?.();
    return typeof pos === 'number' ? pos : undefined;
  } catch {
    return undefined;
  }
}

export function FileLinkChip({ node, extension, editor, getPos }: NodeViewProps) {
  const trpc = useTRPC();
  const documentId = String(node.attrs.documentId ?? '');
  const options = extension.options as FileLinkOptions;
  const onOpen = options.onOpen ?? null;
  // Resolved per render, because the answer depends on where the chip currently SITS —
  // drag one into a trigger and it should sprout the segment; drag it out and it should
  // lose it. `getPos` returns undefined once the node view is detached, which the host's
  // resolver has to tolerate rather than throw on.
  const action =
    options.chipAction?.({ editor, documentId, pos: safePos(getPos) }) ?? null;
  const selectDocumentId = useCedarStore((state) => state.selectDocumentId);
  const setActiveFolderContext = useCedarStore((state) => state.setActiveFolderContext);

  const { data: meta } = useQuery(
    trpc.documents.getDoc.queryOptions(
      { documentId },
      { enabled: !!documentId, staleTime: 5 * 60_000 },
    ),
  );

  const breadcrumb = meta ? relativeSubPath(meta.path) : '';
  // Use `||` (not `??`) so an empty-string title falls through to the path
  // basename — many playbook resource docs have no title set.
  const displayLabel =
    (meta?.title?.trim() || '') ||
    breadcrumb.split('/').findLast(Boolean)?.replace(/\.md$/, '') ||
    'file';

  const handleClick = (event: React.MouseEvent) => {
    event.preventDefault();
    event.stopPropagation();
    if (!documentId) return;
    // When a host editor (the playbook) supplies onOpen, delegate to it so the
    // ref opens the document (e.g. navigate to /brain?documentId=).
    if (onOpen) {
      onOpen(documentId);
      return;
    }
    selectDocumentId(documentId);
    if (meta) {
      setActiveFolderContext({
        documentId: meta.id,
        scopeType: scopeTypeFromPath(meta.path),
        scopeId: scopeIdFromPath(meta.path),
        path: meta.path,
        label: displayLabel,
      });
    }
  };

  // With an action the chip becomes a split button: the identity half still opens the
  // document, the second half runs the action, and one shell owns the rounding so the two
  // halves read as one control. Without one, every class below is what it always was.
  if (action) {
    return (
      <NodeViewWrapper
        as="span"
        data-file-link=""
        data-document-id={documentId}
        className="bg-sunken mx-0.5 inline-flex max-w-full select-none items-stretch overflow-hidden rounded-md align-middle text-sm font-medium leading-snug"
        contentEditable={false}
      >
        <button
          type="button"
          title={breadcrumb || displayLabel}
          onClick={handleClick}
          className="text-foreground hover:bg-primary/10 hover:text-primary inline-flex min-w-0 cursor-pointer items-center gap-1 px-1.5 py-0 transition-colors"
        >
          <FileText className="size-3 shrink-0 text-muted-foreground" />
          <span className="truncate">{displayLabel}</span>
        </button>
        <span aria-hidden className="bg-border/70 my-1 w-px shrink-0" />
        <button
          type="button"
          aria-label={action.ariaLabel}
          title={action.ariaLabel}
          onClick={(event) => {
            event.preventDefault();
            event.stopPropagation();
            action.run();
          }}
          className="text-muted-foreground hover:bg-primary/10 hover:text-primary inline-flex shrink-0 cursor-pointer items-center gap-1 whitespace-nowrap px-1.5 py-0 text-xs transition-colors"
        >
          <Plus className="size-3 shrink-0" />
          {action.label}
        </button>
      </NodeViewWrapper>
    );
  }

  return (
    <NodeViewWrapper
      as="span"
      data-file-link=""
      data-document-id={documentId}
      title={breadcrumb || displayLabel}
      onClick={handleClick}
      className="bg-sunken text-foreground hover:bg-primary/10 hover:text-primary mx-0.5 inline-flex max-w-64 cursor-pointer select-none items-center gap-1 rounded-md px-1.5 py-0 align-middle text-sm font-medium leading-snug transition-colors"
      contentEditable={false}
    >
      <FileText className="h-3.5 w-3.5 shrink-0 opacity-70" />
      <span className="truncate">{displayLabel}</span>
    </NodeViewWrapper>
  );
}