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