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