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