TriggerRefNode.tsx10.7 KBView on GitHub
'use client';

/**
 * `triggerRef` — the agent panel inside a `<trigger>` block.
 *
 * A ref inside a trigger used to be an inline `fileLink` chip: a 12px file glyph and a
 * title, indistinguishable from a link to a resource doc. It was the smallest thing in
 * the block and the most important thing in the block — and it had nowhere to put the one
 * sentence that says what THIS trigger wants from THAT agent.
 *
 * So a ref inside a trigger is a BLOCK node whose ProseMirror content *is* the `<ref>`
 * element's body, drawn as two stacked rows:
 *
 *   ●  coach-meeting                    ← the real agent avatar + name; clicking opens it
 *      Score against the discovery…     ← the instruction. Always visible, always editable.
 *
 * Four things about it are load-bearing, in the order they are most often got wrong:
 *
 *  1. THE PLACEHOLDER IS CSS, NEVER CONTENT. An empty instruction area shows
 *     {@link TRIGGER_REF_PLACEHOLDER} through a `::before` fed by `attr(data-placeholder)`.
 *     Seeding it as text would round-trip straight into the XML and every ref in every
 *     playbook would ship with the words "Instructions for this trigger…" as its real
 *     instruction. `serialize-playbook-xml.ts` writes `<ref id="…"/>` for an empty body,
 *     so an empty panel costs nothing in the document — but only if the emptiness is real.
 *  2. NO BORDER. The trigger callout is already a bordered surface (TriggerNode.tsx); a
 *     bordered card inside it is two frames around one thing. Panels are separated by a
 *     hairline inset to the row's padding, and the first has none above it.
 *  3. THE AGENT IS RESOLVED, NOT FETCHED. `agent.list` already carries `documentId`,
 *     `avatar` and `name` on every summary, so the join is a lookup in one cached query
 *     and there is no new route. A ref whose document is not an agent (a board, a resource
 *     doc, an id that no longer resolves) falls back to the `FileText` chip look — that
 *     difference is now legible instead of hidden, and it still round-trips.
 *  4. `section` / `when` ARE CARRIED. `compile-playbook.ts` reads both off a `<ref>`, and
 *     before this node existed the editor round-trip dropped them — so any playbook using
 *     them silently lost them the first time somebody opened it in the UI. They are
 *     declared here (and on `fileLink`, for the self-closing shape) so the loss cannot
 *     just relocate.
 */

import { Node, mergeAttributes } from '@tiptap/core';
import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react';
import type { NodeViewProps } from '@tiptap/react';
import { useQuery } from '@tanstack/react-query';
import { FileText } from 'lucide-react';
import { AgentAvatar } from '@/components/icons/agent-avatar';
import { useTRPC } from '@/providers/query-provider';
import { useCedarStore } from '@/modules/store';
import { cn } from '@/lib/utils';
import { relativeSubPath } from '@/modules/files/store/documentsSlice';

/** What an empty instruction area says. CSS only — see the header. */
export const TRIGGER_REF_PLACEHOLDER = 'Instructions for this trigger…';

export interface TriggerRefNodeOptions {
  /**
   * Open a NON-agent target (a board, a resource doc). Agents open as an agent artifact
   * regardless, because that is where an agent's triggers and runs are.
   */
  onOpen: ((documentId: string) => void) | null;
}

/**
 * The seam between two panels.
 *
 * A `::before` inset to the row's padding rather than a `border-t`, for the reason
 * `components/ui/settings.tsx` gives: a border runs the full width and reads as a table
 * rule. It is switched on only when another panel precedes this one, so the first panel
 * in a block has no line above it.
 *
 * The sibling selector names `.node-triggerRef`, not the wrapper's own data attribute,
 * because `ReactNodeViewRenderer` puts the React tree one level down: the node's DOM is
 * `<div class="react-renderer node-triggerRef">` and everything below is inside it. Two
 * panels are therefore siblings at THAT element, not at the one this class lands on.
 */
const PANEL_SEAM = [
  'relative',
  'before:pointer-events-none before:absolute before:top-0 before:inset-x-2',
  '[.node-triggerRef+.node-triggerRef>&]:before:border-t',
  '[.node-triggerRef+.node-triggerRef>&]:before:border-foreground/[0.07]',
].join(' ');

function TriggerRefNodeView({ node, extension }: NodeViewProps) {
  const trpc = useTRPC();
  const documentId = String(node.attrs.documentId ?? '');
  const onOpen = (extension.options as TriggerRefNodeOptions).onOpen;
  const setSelectedArtifact = useCedarStore((state) => state.setSelectedArtifact);
  const selectDocumentId = useCedarStore((state) => state.selectDocumentId);

  const { data: agents } = useQuery(trpc.agent.list.queryOptions());
  const agent = (agents ?? []).find((candidate) => candidate.documentId === documentId) ?? null;

  // Only for the refs `agent.list` does not answer for. `enabled` is deliberately false
  // while the agent list is still loading, so a resolvable agent never flashes a file chip
  // and never costs a second round trip.
  const { data: meta } = useQuery(
    trpc.documents.getDoc.queryOptions(
      { documentId },
      { enabled: !!documentId && !!agents && !agent, staleTime: 5 * 60_000 },
    ),
  );

  const docLabel =
    // `||` not `??`: many playbook resource docs carry an empty-string title.
    (meta?.title?.trim() || '') ||
    (meta ? relativeSubPath(meta.path) : '').split('/').findLast(Boolean)?.replace(/\.md$/, '') ||
    'file';
  const label = agent ? agent.name : docLabel;
  const isEmpty = node.content.size === 0;

  const open = () => {
    if (!documentId) return;
    if (agent) {
      // The agent workspace is a display artifact, not a route — see HomeAgentsWidget.
      setSelectedArtifact({ kind: 'agent', id: agent.agentId });
      return;
    }
    if (onOpen) {
      onOpen(documentId);
      return;
    }
    selectDocumentId(documentId);
  };

  return (
    <NodeViewWrapper
      as="div"
      data-trigger-ref=""
      data-document-id={documentId}
      data-resolves-to={agent ? 'agent' : 'document'}
      className={cn('py-1', PANEL_SEAM)}
    >
      {/*
        contentEditable={false} on the title row, so the agent's name is a control and not
        a piece of prose the user can type into and accidentally serialize.
      */}
      <div contentEditable={false} suppressContentEditableWarning>
        <button
          type="button"
          onClick={open}
          title={agent ? `Open ${agent.name}` : label}
          className="hover:bg-hover flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1 text-left transition-colors"
        >
          {agent ? (
            <AgentAvatar
              agentId={agent.agentId}
              avatar={agent.avatar}
              className="size-5 shrink-0"
            />
          ) : (
            <FileText className="text-muted-foreground size-4 shrink-0" aria-hidden />
          )}
          <span className="truncate text-sm font-medium">{label}</span>
        </button>
      </div>

      <NodeViewContent
        as="div"
        data-trigger-ref-instruction=""
        data-empty={isEmpty ? 'true' : 'false'}
        data-placeholder={TRIGGER_REF_PLACEHOLDER}
        className={cn(
          'text-foreground/80 px-2 text-sm leading-snug',
          // The placeholder, and the whole reason this is a class and not a text node.
          isEmpty &&
            'before:text-muted-foreground/70 before:pointer-events-none before:float-left before:h-0 before:content-[attr(data-placeholder)]',
        )}
      />
    </NodeViewWrapper>
  );
}

export const TriggerRefNode = Node.create<TriggerRefNodeOptions>({
  name: 'triggerRef',
  group: 'block',
  // The instruction IS the node's content — that is the whole grammar. `inline*` rather
  // than `block+` because a `<ref>` body is one run of prose, and a hardBreak in it
  // serializes to a real newline (serialize-playbook-xml.ts:refBodyText).
  content: 'inline*',
  defining: true,
  selectable: true,
  draggable: false,
  // Stops Backspace at the start of an INSTRUCTED panel from lifting its text into the
  // paragraph above, which would take the agent with it — the documentId lives in an
  // attribute the merged text cannot show, so that deletion would be invisible. An EMPTY
  // panel still deletes on Backspace, via the shortcut below.
  isolating: true,

  addOptions() {
    return { onOpen: null };
  },

  addAttributes() {
    return {
      documentId: {
        default: '',
        parseHTML: (element) => element.getAttribute('data-document-id') ?? '',
        renderHTML: (attributes) =>
          attributes.documentId ? { 'data-document-id': attributes.documentId } : {},
      },
      // Read by compile-playbook.ts off the `<ref>` element. Nullable, never defaulted to
      // a string: `<ref id="x" section="">` and `<ref id="x">` must not become the same
      // document, and the serializer only emits an attribute that is actually set.
      section: {
        default: null,
        parseHTML: (element) => element.getAttribute('data-ref-section'),
        renderHTML: (attributes) =>
          attributes.section ? { 'data-ref-section': attributes.section } : {},
      },
      when: {
        default: null,
        parseHTML: (element) => element.getAttribute('data-ref-when'),
        renderHTML: (attributes) => (attributes.when ? { 'data-ref-when': attributes.when } : {}),
      },
    };
  },

  parseHTML() {
    return [{ tag: 'div[data-trigger-ref]' }];
  },

  renderHTML({ HTMLAttributes }) {
    return ['div', mergeAttributes({ 'data-trigger-ref': '' }, HTMLAttributes), 0];
  },

  addKeyboardShortcuts() {
    return {
      // Enter inside an instruction is a LINE BREAK, not a split. Splitting would mint a
      // second panel pointing at the same agent — two `<ref>`s where the author wrote one.
      Enter: () => {
        if (this.editor.state.selection.$from.parent.type.name !== this.name) return false;
        return this.editor.commands.setHardBreak();
      },
      Backspace: () => {
        const { selection } = this.editor.state;
        const { $from, empty } = selection;
        if (!empty) return false;
        const parent = $from.parent;
        if (parent.type.name !== this.name) return false;
        if ($from.parentOffset !== 0 || parent.content.size !== 0) return false;
        // An empty panel: Backspace removes the whole thing, which is what "delete it the
        // way any block is deleted" means when there is no text left to delete.
        const from = $from.before();
        return this.editor
          .chain()
          .focus()
          .deleteRange({ from, to: from + parent.nodeSize })
          .run();
      },
    };
  },

  addNodeView() {
    return ReactNodeViewRenderer(TriggerRefNodeView);
  },
});