GraphInspector.tsx14.4 KBView on GitHub 'use client';
/**
* The canvas's top-right surface — whatever is selected, shown in one place.
*
* ── Why ONE inspector and not two panels ──
*
* A node and an edge are both "the thing you just clicked", and giving each its own floating
* card would put two surfaces in one corner, competing for the same space and the same
* attention. One surface per object, and here the OBJECT is the selection.
*
* ── What a node shows, and why the document body is on it ──
*
* A node is a POINTER at a document the graph does not own. Reading the canvas therefore
* constantly raises a question the canvas cannot answer — *who is this person, actually* — and
* the only way to answer it used to be to navigate away, losing the graph. So the card carries
* the document ITSELF: its title, its own properties, and its body, as the document reads.
*
* It is the SAME rail a document page uses (`DocumentProperties`), with its graph context filled
* in — which is the first real consumer of the three-group ordering phase 4 built: the
* document's own properties, then "In {graph name}", then the fields that resolved from the
* contact layer. Opening the same profile from two graphs differs in the middle group and
* nowhere else, and you can see that without leaving either graph.
*
* The body is the REAL document, editable. `<Document />` binds the same Y.Doc the document
* page binds, through the same provider registry — so this is not a copy of the document that
* could drift from it, it is the document, and typing here and typing there are the same
* keystrokes. That is also why there is no merge to worry about: two `<Document />`s on one id
* are two views of one CRDT, which is the case Y.js exists for.
*
* ── Properties on top, then the document ──
*
* Properties at the head of the scroll area, prose under them: a document page's shape rotated
* into one column. As one undifferentiated run, a long profile buries the handful of values the
* graph is actually asserting about this person.
*
* ── The width is the reader's, and it persists ──
*
* A profile is sometimes the thing you came for and sometimes an aside, and no single width is
* right for both. So the left edge is a drag handle and the chosen width is remembered per
* viewer (`use-inspector-width`), which is the cheapest possible version of "make it bigger
* when I am reading".
*/
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ExternalLink, FileQuestion, X } from 'lucide-react';
import type { GraphEdge, GraphNode, GraphSchema } from '@zero/server/graph';
import { useTRPC } from '@/providers/query-provider';
import { Document } from '@/modules/documents/document';
import { DocumentProperties } from '@/modules/documents/properties';
import type { BoundPropertyField } from '@/modules/documents/properties';
import { GraphNodeProfile } from './GraphNodeProfile';
import { profileSlotOf, readProfileValues } from './profile-bindings';
import { useInspectorWidth } from './use-inspector-width';
import { cn } from '@/lib/utils';
/** What the canvas has selected. `null` renders nothing at all. */
export type GraphSelection =
| { kind: 'node'; nodeId: string }
| { kind: 'edge'; edgeKey=[redacted] }
| null;
export interface GraphInspectorProps {
selection: GraphSelection;
graphName: string;
schema: GraphSchema;
nodes: Record<string, GraphNode>;
edges: Record<string, GraphEdge>;
/** Bound values from `hydrateNodes`, keyed by node id. Absent until they arrive. */
hydrated?: Record<string, Record<string, unknown>>;
/** Node ids whose pointed-at document no longer resolves. */
missingDocumentIds?: ReadonlySet<string>;
onClose: () => void;
/** Opens the real document full-screen. Absent = no ↗ button. */
onOpenDocument?: (documentId: string) => void;
onChangeNodeField?: (nodeId: string, key=[redacted], value: string) => void;
onChangeEdgeField?: (edgeKey=[redacted], key=[redacted], value: string) => void;
}
export function GraphInspector({
selection,
graphName,
schema,
nodes,
edges,
hydrated,
missingDocumentIds,
onClose,
onOpenDocument,
onChangeNodeField,
onChangeEdgeField,
}: GraphInspectorProps) {
if (!selection) return null;
if (selection.kind === 'edge') {
const edge = edges[selection.edgeKey];
if (!edge) return null;
const kind = schema.edgeKinds.find((k) => k.key === edge.kind);
return (
<InspectorShell
title={edge.label ?? kind?.label ?? edge.kind}
subtitle={`${nodes[edge.source]?.title ?? edge.source} → ${nodes[edge.target]?.title ?? edge.target}`}
onClose={onClose}
>
{edge.description && (
<p className="px-2 pb-3 text-sm text-muted-foreground">{edge.description}</p>
)}
<div className="px-1 pb-1">
<DocumentProperties
subject={{
label: kind?.label ?? edge.kind,
...(kind?.fields ? { schema: kind.fields } : {}),
...(edge.fields ? { fields: edge.fields } : {}),
...(onChangeEdgeField
? {
onChange: (key=[redacted], value: string) =>
onChangeEdgeField(selection.edgeKey, key, value),
}
: {}),
}}
/>
</div>
</InspectorShell>
);
}
const node = nodes[selection.nodeId];
if (!node) return null;
return (
<NodeCard
node={node}
graphName={graphName}
schema={schema}
hydrated={hydrated?.[selection.nodeId]}
missing={missingDocumentIds?.has(selection.nodeId) ?? false}
onClose={onClose}
{...(onOpenDocument ? { onOpenDocument } : {})}
{...(onChangeNodeField
? { onChange: (key=[redacted], value: string) => onChangeNodeField(selection.nodeId, key, value) }
: {})}
/>
);
}
function NodeCard({
node,
graphName,
schema,
hydrated,
missing,
onClose,
onOpenDocument,
onChange,
}: {
node: GraphNode;
graphName: string;
schema: GraphSchema;
hydrated?: Record<string, unknown>;
missing: boolean;
onClose: () => void;
onOpenDocument?: (documentId: string) => void;
onChange?: (key=[redacted], value: string) => void;
}) {
const trpc = useTRPC();
// The document the node POINTS AT — fetched here rather than threaded down from the canvas,
// because the canvas reads a graph and this reads one document, and only one of them changes
// when the selection does.
const docQuery = useQuery({
...trpc.documents.getDoc.queryOptions({ documentId: node.documentId }),
// A node whose document is gone has nothing to fetch; asking anyway turns a known-missing
// document into three retries and a spinner that resolves to the same thing.
enabled: !missing,
});
/**
* What the HEADER draws, and what is left over for the rail.
*
* A field whose binding fills a profile slot — the photo, the title, the company, the
* LinkedIn URL — is drawn in the header and then SUBTRACTED from the rows. Drawing it in
* both places is the same fact twice, and the second copy is the one that makes the panel
* read as a dump of everything we happen to know rather than as a person.
*
* `identityField` goes too: `person` holds an address or a `[[person: id]]` token, which is
* plumbing. It is how the node knows WHO this is, not something to tell a reader.
*/
const { profile, authored, bound, hiddenKeys } = useMemo(() => {
// `hydrated ?? derived` — the live value once it lands, the cache until then. The same
// order the node face resolves in, so the card and the panel can never disagree.
const valueOf = (key=[redacted] => hydrated?.[key] ?? node.derived?.[key] ?? node.fields[key];
const identityKey=[redacted];
const rowFields = schema.nodeSchema.fields.filter(
(field) => !profileSlotOf(field) && field.key !== identityKey,
);
const boundRows: BoundPropertyField[] = rowFields
.filter((field) => !!field.from)
.map((field) => ({ field, value: valueOf(field.key) }));
return {
profile: readProfileValues(schema.nodeSchema.fields, valueOf),
authored: rowFields.filter((field) => !field.from),
bound: boundRows,
// The node's BAG can still carry these keys — `person` always does — and the rail's
// open-by-default rule would otherwise bring them back as "not declared" rows directly
// under the header that replaced them.
hiddenKeys: schema.nodeSchema.fields
.filter((field) => profileSlotOf(field))
.map((field) => field.key)
.concat(identityKey ? [identityKey] : []),
};
}, [schema, node.derived, node.fields, hydrated]);
const doc = docQuery.data;
const title = doc?.title?.trim() || node.title?.trim() || node.documentId;
const ownFields = readOwnFields(doc?.metadata);
return (
<InspectorShell
title={title}
profile={<GraphNodeProfile name={title} profile={profile} />}
onClose={onClose}
{...(onOpenDocument ? { onOpen: () => onOpenDocument(node.documentId) } : {})}
>
{profile.headline && (
<p className="text-muted-foreground px-2 pb-3 text-sm leading-relaxed">
{profile.headline}
</p>
)}
{missing && (
<p className="flex items-start gap-1.5 px-2 pb-3 text-sm text-muted-foreground">
<FileQuestion aria-hidden className="mt-0.5 size-3.5 shrink-0" />
{/* There is no foreign key behind a node, so this message is the guard that replaces
one — and it says the values are cached so nobody retypes them somewhere that will
be overwritten. */}
Its document no longer resolves. The values below are the last ones we cached.
</p>
)}
<div className="px-1">
<DocumentProperties
subject={ownFields ? { fields: ownFields } : {}}
graphContext={{
graphName,
schema: authored,
fields: node.fields,
...(onChange ? { onChange } : {}),
boundFields: bound,
hiddenKeys,
}}
/>
</div>
{/* The document itself, writable. Not gated on `missing`: a node whose document no
longer resolves has nothing to bind, and `<Document />` handles that by mounting
empty rather than by throwing. */}
{!missing && (
<div className="pt-2">
<Document
documentId={node.documentId}
placeholder="Write about this person"
// No border, no fill, no padding of its own: the panel is the surface, and a
// framed editor inside it would be a second box drawn around one thing.
className="min-h-40 border-0 bg-transparent p-0 text-sm"
/>
</div>
)}
</InspectorShell>
);
}
/**
* The floating surface itself.
*
* A RAISED surface, so it carries a border — unlike the inline rail on a document page, which
* is part of the page it sits on. The width is the reader's and persists; the height is capped
* with an inner scroll, because a profile an agent has been accumulating for months is longer
* than a canvas is tall and a panel that grew to fit would cover the thing it describes.
*/
function InspectorShell({
title,
subtitle,
profile,
onClose,
onOpen,
children,
}: {
title: string;
subtitle?: string | null;
/**
* The person header, for a node. Replaces the plain title outright rather than sitting under
* it — the name is IN the header, and drawing it twice is how a panel ends up with two
* headings for one thing. An edge passes nothing and keeps the plain title.
*/
profile?: React.ReactNode;
onClose: () => void;
onOpen?: () => void;
children: React.ReactNode;
}) {
const { width, dragging, onPointerDown } = useInspectorWidth();
return (
<div
style={{ width }}
className="border-surface-border bg-raised relative flex max-h-[82vh] flex-col overflow-hidden rounded-lg border shadow-lg"
>
{/* The drag handle: a 6px strip down the LEFT edge, invisible until you reach for it.
`col-resize` rather than `ew-resize` because this resizes a column, and the cursor is
the only thing that says the strip is grabbable at all. */}
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize panel"
onPointerDown={onPointerDown}
className={cn(
'absolute inset-y-0 left-0 z-10 w-1.5 cursor-col-resize',
'hover:bg-primary/20 transition-colors',
dragging && 'bg-primary/30',
)}
/>
<div className="border-seam flex items-start gap-1 border-b py-2 pr-1.5 pl-3">
<div className="min-w-0 flex-1 py-0.5">
{profile ?? (
// The document's own heading, at document size. It was `text-sm font-medium` — the
// same type as the rows beneath it — which made the panel read as a list whose
// first line happened to be a name rather than as a document about a person.
<h2 className="truncate text-base leading-6 font-semibold">{title}</h2>
)}
{subtitle && <p className="text-muted-foreground truncate text-xs">{subtitle}</p>}
</div>
{onOpen && (
<button
type="button"
onClick={onOpen}
aria-label="Open document"
title="Open the full document"
className="text-muted-foreground hover:bg-hover hover:text-foreground mt-0.5 shrink-0 cursor-pointer rounded-lg p-1.5"
>
<ExternalLink aria-hidden className="size-3.5" />
</button>
)}
<button
type="button"
onClick={onClose}
aria-label="Close"
className="text-muted-foreground hover:bg-hover hover:text-foreground mt-0.5 shrink-0 cursor-pointer rounded-lg p-1.5"
>
<X aria-hidden className="size-3.5" />
</button>
</div>
{/* The one scroll container. */}
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-2.5">
{children}
</div>
</div>
);
}
/** `documents.metadata.fields` — the document's OWN typed bag, when it has one. */
function readOwnFields(metadata: unknown): Record<string, unknown> | undefined {
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) return undefined;
const fields = (metadata as { fields?: unknown }).fields;
if (typeof fields !== 'object' || fields === null || Array.isArray(fields)) return undefined;
return fields as Record<string, unknown>;
}