Files
Socrates/apps/web/components/diagram-canvas/DiagramCanvas.tsx
dtoro b55425cc68 Pivot to text-first column-stack workspace + merge-with-review across AI artifacts
Workspace
- Pivot from "set of open panes" to a Finder-style miller column stack:
  TopBar / LeftSidebar / [section → entity → entity ...] / pinned text editor.
  openPanesStore is now an ordered Column[] with pushFrom / closeFrom /
  setStack; only one top-level section is rooted at a time.
- New entity column panes: Term, Block, Association, Constraint,
  Requirement, Finding. Click-through navigation truncates deeper
  columns automatically.
- LeftSidebar surfaces a pending-count chip per section (single-glance
  navigation cue) and spins its analyze ↻ via SVG Spinner whenever the
  LLM is working — including server-initiated runs caught by the runs
  poll, not just user-triggered ones.

Analyze pipeline + persistence
- Unified `concepts` pass (taxonomy + glossary in one LLM call) replaces
  the two-pass setup. Server still accepts ?section=taxonomy|glossary
  and normalizes them for back-compat.
- model / requirements / detection (assumptions, risks, inconsistencies)
  + cross-layer validation rules (X1–X4: stale term link, unlinked
  formalism, undefined linked term, prose-only term).
- Persistence: NarrativeDocument, ModelSnapshot, ChangelogEntry,
  TaxonomyTerm, RequirementEntry, Finding, AnalysisRun. Re-runs MERGE
  instead of replace: gentle update on existing items, suggested on new,
  deprecated on missing — same idiom for every artifact kind. User pins
  preserve "kept" decisions across re-analyses.
- Migrations: pivot_text_first, add_requirement_linked_term,
  term_review_state, review_state_for_reqs_and_findings,
  add_term_definition_pinned.

Concept ↔ ontology integration
- linkedTermId on Block / Association / Constraint / Requirement.
  PromoteToolbar lets the user formalize a concept inline: + Block /
  + Association / + Constraint / + Requirement, all routed through
  applyOps so undo/redo and SSE work for free.
- decideElement op for in-canvas keep/discard on review-pending model
  elements.

User-authored definitions
- TermColumn definition is click-to-edit. Save (Cmd-Enter / blur),
  Cancel (Esc), Reset to AI suggestion when pinned.
- definitionPinned flag on TaxonomyTerm: future Analyze runs leave the
  user's text alone. setTermDefinition repo function + POST
  /api/projects/[id]/terms/[termId]/definition endpoint.
- mergeTaxonomySuggestion + applyGlossaryDefinitions both pin-aware.

UX/UI
- StatusChip: single component for all state idioms (suggested,
  deprecated, accepted, dismissed, resolved, severity, validation code,
  confidence, warn). Replaces 5+ ad-hoc badge classes.
- PaneControls (PaneViewTabs + PaneFilterChip): separates view-mode
  toggles from filter chips so toggling Pending no longer flips you off
  the current view.
- PaneEmpty: unified empty-state with title + hint + action.
- PaneDrawer: collapsible groups for Pending / Discarded review; cards
  group as Kept (top) → Pending (bottom drawer) → Discarded (Findings
  only, hidden when empty). Restore action recovers dismissed/resolved
  findings.
- ConceptCard unifies Tree and A–Z views in Concepts; only Tree parents
  carry the chevron (no empty placeholder offset).
- Type + spacing tokens (--text-xs..xl, --space-1..6, --lh-tight/ui/
  prose, --radius-*) replace every ad-hoc value.
- Buttons standardized to body sans 500 (was a mishmash of mono / display).
- Card shells unified across Concepts / Requirements / Findings.

Cleanup
- Removed: LeftRail, FindingsPanel, IssuesPanel, SocratesDock,
  ProposalCard, SlashMenu, SlashExtension, slashSuggestion,
  CanvasHeader, TaxonomyPane, GlossaryPane, TermDetail (popover; now
  TermColumn).
- Section ids in openPanesStore: dropped taxonomy/glossary, added
  concepts. localStorage migration runs on hydrate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 00:12:06 +02:00

654 lines
22 KiB
TypeScript

// React Flow-backed diagram canvas (M5).
//
// State separation:
// - SysMLModel → ModelStore (canonical, semantic). The narrative reads here too.
// - React Flow → owns positions, selection, drag-in-progress (UI ephemeral).
//
// Sync rules:
// - On model change from outside (chip rename, etc.), we apply targeted
// `setNodes` updates (label, kind, properties) WITHOUT touching positions.
// Newly-added blocks/constraints get added; removed ones get removed.
// - On user actions in RF (connect, drop-from-palette, node delete, edge
// delete, inspector edit), we emit ModelOps via useApply().
//
// This avoids the "re-derive nodes on every drag tick" trap that made
// dragging feel laggy in the first cut.
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ReactFlow,
ReactFlowProvider,
Background,
BackgroundVariant,
Controls,
MarkerType,
useNodesState,
useEdgesState,
useReactFlow,
type Edge,
type Node,
type NodeChange,
type EdgeChange,
type Connection,
type NodeMouseHandler,
} from "@xyflow/react";
import { BlockNode, type BlockNodeData } from "./nodes/BlockNode";
import { SysmlEdge, type SysmlEdgeData } from "./edges/SysmlEdge";
import { Palette } from "./Palette";
import { NodeInspector } from "./NodeInspector";
import { useModel, useApply } from "../../lib/sync/ModelStore";
import {
addBlock as addBlockOp,
addConstraint as addConstraintOp,
removeBlock as removeBlockOp,
removeConstraint as removeConstraintOp,
removeAssociation as removeAssociationOp,
updateBlock as updateBlockOp,
updateConstraint as updateConstraintOp,
addAssociation as addAssociationOp,
addProperty as addPropertyOp,
updateProperty as updatePropertyOp,
removeProperty as removePropertyOp,
newProperty,
tempId,
type ModelOp,
} from "../../lib/sync/ops";
import type { ValidationIssue } from "../../lib/sysml/validate";
import type { Density } from "../../lib/workspace/types";
import type { FixtureData, BlockKind } from "../../lib/fixtures/aristotle";
import type { Block, Property, PropertyType, SysMLModel } from "../../lib/sysml/model";
export type DiagramVariant = "softened" | "formal" | "graph";
interface DiagramCanvasProps {
data?: FixtureData;
density?: Density;
variant?: DiagramVariant;
focusBlockId: string | null;
onSelect?: (id: string | null) => void;
issuesByElement?: Map<string, ValidationIssue[]>;
/** When set, term-drop on the canvas links the chosen term server-side. */
projectId?: string;
}
const nodeTypes = { sysmlBlock: BlockNode };
const edgeTypes = { sysml: SysmlEdge };
const BOARD_W = 720;
const BOARD_H = 460;
function pickWorst(issues: ValidationIssue[] | undefined): "error" | "warning" | "soft" | undefined {
if (!issues) return undefined;
if (issues.some(i => i.severity === "error")) return "error";
if (issues.some(i => i.severity === "warning")) return "warning";
if (issues.some(i => i.severity === "soft")) return "soft";
return undefined;
}
export function DiagramCanvas(props: DiagramCanvasProps) {
return (
<ReactFlowProvider>
<DiagramInner {...props} />
</ReactFlowProvider>
);
}
function DiagramInner({ data, focusBlockId, onSelect, issuesByElement, projectId }: DiagramCanvasProps) {
const model = useModel();
const apply = useApply();
const wrapperRef = useRef<HTMLDivElement | null>(null);
const { screenToFlowPosition } = useReactFlow();
// Initialize React Flow state from the model + fixture positions on first render.
// Lazy `useState`-style init to avoid recomputing on every render.
const [initialN] = useState(() => initialNodes(model, data));
const [initialE] = useState(() => initialEdges(model));
const [nodes, setNodes, onNodesChangeRaw] = useNodesState<Node<BlockNodeData>>(initialN);
const [edges, setEdges, onEdgesChangeRaw] = useEdgesState<Edge<SysmlEdgeData>>(initialE);
// Track the model's element ids so we can detect adds/removes between renders.
const elementIdsRef = useRef<{ blocks: Set<string>; assocs: Set<string>; constraints: Set<string> }>(snapshotIds(model));
// ─── Model → React Flow (targeted sync) ──────────────────────────────────
useEffect(() => {
const prev = elementIdsRef.current;
const curr = snapshotIds(model);
setNodes(currentNodes => {
const byId = new Map(currentNodes.map(n => [n.id, n]));
const next: Node<BlockNodeData>[] = [];
// For new elements added by the model (e.g. accepted proposals), drop
// them at a sensible spot near the existing centroid so they're
// immediately visible — not at (0,0) offscreen.
const placeNew = makePlaceNewPosition(currentNodes);
for (const b of model.blocks) {
const existing = byId.get(b.id);
if (existing) {
const propNames = b.properties.map(p => p.name);
const dataChanged =
existing.data?.label !== b.label ||
existing.data?.kind !== b.kind ||
!sameStringArray(existing.data?.properties ?? [], propNames) ||
existing.data?.reviewStatus !== b.reviewStatus;
if (dataChanged) {
next.push({
...existing,
data: {
...existing.data,
label: b.label,
kind: b.kind,
properties: propNames,
reviewStatus: b.reviewStatus,
},
});
} else {
next.push(existing);
}
} else {
const created = makeNodeForBlock(b);
next.push({
...created,
position: placeNew(),
data: { ...created.data, reviewStatus: b.reviewStatus },
});
}
}
for (const c of model.constraints) {
const existing = byId.get(c.id);
if (existing) {
const expr = c.expression || "{ }";
const dataChanged =
existing.data?.label !== c.label ||
existing.data?.expression !== expr ||
existing.data?.reviewStatus !== c.reviewStatus;
if (dataChanged) {
next.push({
...existing,
data: {
...existing.data,
label: c.label,
kind: "constraint",
properties: [],
expression: expr,
reviewStatus: c.reviewStatus,
},
});
} else {
next.push(existing);
}
} else {
const created = makeNodeForConstraint(c);
next.push({
...created,
position: placeNew(),
data: { ...created.data, reviewStatus: c.reviewStatus },
});
}
}
return next;
});
setEdges(currentEdges => {
const byId = new Map(currentEdges.map(e => [e.id, e]));
const next: Edge<SysmlEdgeData>[] = [];
for (const a of model.associations) {
const existing = byId.get(a.id);
if (existing && existing.data?.label === a.label && existing.data?.kind === a.kind) {
next.push(existing);
} else {
next.push(makeEdgeForAssociation(a));
}
}
// Synthesized constraint→block edges
for (const c of model.constraints) {
for (const target of c.appliesTo) {
const id = `${c.id}__applies__${target}`;
const existing = byId.get(id);
next.push(existing ?? makeEdgeForApplies(c.id, target));
}
}
return next;
});
elementIdsRef.current = curr;
void prev;
}, [model, setNodes, setEdges]);
// ─── Selection sync — narrative chip hover / rail entry hover → here ────
useEffect(() => {
setNodes(curr => {
let changed = false;
const next = curr.map(n => {
const shouldSelect = n.id === focusBlockId;
if (n.selected === shouldSelect) return n;
changed = true;
return { ...n, selected: shouldSelect };
});
return changed ? next : curr;
});
}, [focusBlockId, setNodes]);
// ─── Issue-severity sync — validation results → node ring color ─────────
useEffect(() => {
setNodes(curr => {
let changed = false;
const next = curr.map(n => {
const next = pickWorst(issuesByElement?.get(n.id));
if (n.data?.issueSeverity === next) return n;
changed = true;
return { ...n, data: { ...n.data, issueSeverity: next } };
});
return changed ? next : curr;
});
}, [issuesByElement, setNodes]);
// ─── React Flow → ModelStore handlers ───────────────────────────────────
const onNodesChange = useCallback((changes: NodeChange[]) => {
// 1. Let React Flow update its own state (positions, drags, selections)
onNodesChangeRaw(changes as NodeChange<Node<BlockNodeData>>[]);
// 2. For removals, emit the corresponding remove ops
const removalOps: ModelOp[] = [];
for (const c of changes) {
if (c.type !== "remove") continue;
const isConstraint = !!model.constraints.find(x => x.id === c.id);
if (isConstraint) {
removalOps.push(removeConstraintOp(c.id));
} else if (model.blocks.find(x => x.id === c.id)) {
removalOps.push(removeBlockOp(c.id));
}
}
if (removalOps.length > 0) apply(removalOps);
}, [apply, model.blocks, model.constraints, onNodesChangeRaw]);
const onEdgesChange = useCallback((changes: EdgeChange[]) => {
// 1. Let React Flow update its own state
onEdgesChangeRaw(changes as EdgeChange<Edge<SysmlEdgeData>>[]);
// 2. For removals, emit ops — except for synthesized constraint-applies
// edges which decompose into a constraint update.
const removalOps: ModelOp[] = [];
for (const c of changes) {
if (c.type !== "remove") continue;
if (c.id.includes("__applies__")) {
const [cid, tid] = c.id.split("__applies__");
const target = model.constraints.find(x => x.id === cid);
if (target) {
removalOps.push(updateConstraintOp(cid!, { appliesTo: target.appliesTo.filter(x => x !== tid) }));
}
} else if (model.associations.find(a => a.id === c.id)) {
removalOps.push(removeAssociationOp(c.id));
}
}
if (removalOps.length > 0) apply(removalOps);
}, [apply, model.associations, model.constraints, onEdgesChangeRaw]);
const onConnect = useCallback((connection: Connection) => {
if (!connection.source || !connection.target) return;
const newId = tempId("a");
apply([
addAssociationOp({
id: newId,
fromBlockId: connection.source,
toBlockId: connection.target,
label: "relates_to",
kind: "association",
}, newId),
]);
}, [apply]);
const onNodeClick: NodeMouseHandler = useCallback(
(_event, node) => onSelect?.(node.id),
[onSelect]
);
const onPaneClick = useCallback(() => onSelect?.(null), [onSelect]);
const selectedBlock = useMemo(
() => model.blocks.find(b => b.id === focusBlockId) ?? null,
[model.blocks, focusBlockId]
);
const selectedConstraint = useMemo(
() => model.constraints.find(c => c.id === focusBlockId) ?? null,
[model.constraints, focusBlockId]
);
// Drag-and-drop from the palette
const onDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}, []);
const onDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
// Taxonomy/glossary term drop → block stamped from the term.
const termRaw = event.dataTransfer.getData("application/x-socrata-term");
if (termRaw) {
try {
const t = JSON.parse(termRaw) as { termId?: string; label?: string; definition?: string };
if (!t.label || !t.termId) return;
const bid = tempId("b");
const block: Block = {
id: bid,
label: t.label,
kind: "block",
stereotypes: ["block"],
properties: [],
description: t.definition ?? undefined,
linkedTermId: t.termId,
};
const result = apply([addBlockOp(block, bid)]);
if (result.applied) {
const final = result.idMapping[bid] ?? bid;
setNodes(curr => curr.map(n => (n.id === final ? { ...n, position } : n)));
onSelect?.(final);
// Persist the term → block link server-side so it survives reload
// and so other UI can show the "linked" indicator immediately.
if (projectId) {
void fetch(`/api/projects/${encodeURIComponent(projectId)}/term-link`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ termId: t.termId, blockId: final }),
}).catch(() => {});
}
}
} catch (err) {
console.error("[DiagramCanvas] term drop parse:", err);
}
return;
}
const kindRaw = event.dataTransfer.getData("application/sysml-kind");
if (!kindRaw) return;
if (kindRaw === "constraint") {
const cid = tempId("c");
const result = apply([
addConstraintOp({ id: cid, label: "Constraint", expression: "{ }", appliesTo: [] }, cid),
]);
if (result.applied) {
const final = result.idMapping[cid] ?? cid;
// Position the new node at the drop coordinates
setNodes(curr => curr.map(n => (n.id === final ? { ...n, position } : n)));
onSelect?.(final);
}
return;
}
const kind = kindRaw as BlockKind | "system";
const bid = tempId("b");
const block: Block = {
id: bid,
label:
kind === "system" ? "System" :
kind === "actor" ? "Actor" :
"Block",
kind: kind === "system" ? "system" : kind,
stereotypes: [kind],
properties: [newProperty("new_property")],
};
const result = apply([addBlockOp(block, bid)]);
if (result.applied) {
const final = result.idMapping[bid] ?? bid;
setNodes(curr => curr.map(n => (n.id === final ? { ...n, position } : n)));
onSelect?.(final);
}
},
[apply, onSelect, screenToFlowPosition, setNodes, projectId]
);
function patchSelected(patch: Partial<BlockNodeData>) {
if (selectedBlock) {
const ops: ModelOp[] = [];
if (patch.label !== undefined && patch.label !== selectedBlock.label) {
ops.push(updateBlockOp(selectedBlock.id, { label: patch.label }));
}
if (patch.kind !== undefined && patch.kind !== selectedBlock.kind) {
ops.push(updateBlockOp(selectedBlock.id, { kind: patch.kind, stereotypes: [patch.kind] }));
}
if (patch.properties !== undefined) {
ops.push(...diffProperties(selectedBlock, patch.properties));
}
if (ops.length > 0) apply(ops);
} else if (selectedConstraint) {
const ops: ModelOp[] = [];
if (patch.label !== undefined && patch.label !== selectedConstraint.label) {
ops.push(updateConstraintOp(selectedConstraint.id, { label: patch.label }));
}
if (patch.expression !== undefined && patch.expression !== selectedConstraint.expression) {
ops.push(updateConstraintOp(selectedConstraint.id, { expression: patch.expression }));
}
if (ops.length > 0) apply(ops);
}
}
function deleteSelected() {
if (selectedBlock) {
apply([removeBlockOp(selectedBlock.id)]);
onSelect?.(null);
} else if (selectedConstraint) {
apply([removeConstraintOp(selectedConstraint.id)]);
onSelect?.(null);
}
}
const inspectorData: BlockNodeData | null = selectedBlock
? {
label: selectedBlock.label,
kind: selectedBlock.kind,
properties: selectedBlock.properties.map(p => p.name),
}
: selectedConstraint
? {
label: selectedConstraint.label,
kind: "constraint",
properties: [],
expression: selectedConstraint.expression,
}
: null;
return (
<div ref={wrapperRef} className="diagram-flow" onDragOver={onDragOver} onDrop={onDrop}>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
deleteKeyCode={["Backspace", "Delete"]}
fitView
fitViewOptions={{ padding: 0.15, includeHiddenNodes: false, maxZoom: 1.2 }}
proOptions={{ hideAttribution: true }}
defaultEdgeOptions={{
type: "sysml",
markerEnd: { type: MarkerType.Arrow, color: "var(--edge)", width: 18, height: 18 },
}}
>
<Background variant={BackgroundVariant.Dots} gap={18} size={1} color="var(--grid-dot)" />
<Controls showInteractive={false} position="bottom-right" />
</ReactFlow>
<Palette />
{inspectorData && focusBlockId && (
<NodeInspector
nodeId={focusBlockId}
data={inspectorData}
onChange={patchSelected}
onDelete={deleteSelected}
onClose={() => onSelect?.(null)}
/>
)}
</div>
);
}
// ─── Initial state builders ──────────────────────────────────────────────
function initialNodes(model: SysMLModel, data?: FixtureData): Node<BlockNodeData>[] {
// Bootstrap positions from the fixture for the original Aristotle blocks.
const fixturePositions: Record<string, { x: number; y: number }> = {};
if (data) {
for (const b of data.blocks) {
fixturePositions[b.id] = { x: b.x * BOARD_W, y: b.y * BOARD_H };
}
}
const nodes: Node<BlockNodeData>[] = [];
let autoCol = 0;
let autoRow = 0;
function autoPosition() {
const pos = { x: 60 + autoCol * 220, y: 60 + autoRow * 160 };
autoCol++;
if (autoCol >= 4) { autoCol = 0; autoRow++; }
return pos;
}
for (const b of model.blocks) {
nodes.push({
...makeNodeForBlock(b),
position: fixturePositions[b.id] ?? autoPosition(),
});
}
for (const c of model.constraints) {
nodes.push({
...makeNodeForConstraint(c),
position: fixturePositions[c.id] ?? autoPosition(),
});
}
return nodes;
}
function initialEdges(model: SysMLModel): Edge<SysmlEdgeData>[] {
const edges: Edge<SysmlEdgeData>[] = [];
for (const a of model.associations) edges.push(makeEdgeForAssociation(a));
for (const c of model.constraints) {
for (const target of c.appliesTo) {
edges.push(makeEdgeForApplies(c.id, target));
}
}
return edges;
}
function makeNodeForBlock(b: Block): Node<BlockNodeData> {
return {
id: b.id,
type: "sysmlBlock",
position: { x: 0, y: 0 },
data: {
label: b.label,
kind: b.kind,
properties: b.properties.map(p => p.name),
},
};
}
function makeNodeForConstraint(c: import("../../lib/sysml/model").Constraint): Node<BlockNodeData> {
return {
id: c.id,
type: "sysmlBlock",
position: { x: 0, y: 0 },
data: {
label: c.label,
kind: "constraint",
properties: [],
expression: c.expression || "{ }",
},
};
}
function makeEdgeForAssociation(a: import("../../lib/sysml/model").Association): Edge<SysmlEdgeData> {
return {
id: a.id,
source: a.fromBlockId,
target: a.toBlockId,
type: "sysml",
data: { label: a.label, kind: a.kind },
markerEnd: a.kind === "composition"
? { type: MarkerType.ArrowClosed, color: "var(--edge)", width: 18, height: 18 }
: a.kind === "constraintApplies"
? undefined
: { type: MarkerType.Arrow, color: "var(--edge)", width: 18, height: 18 },
};
}
function makeEdgeForApplies(constraintId: string, targetBlockId: string): Edge<SysmlEdgeData> {
return {
id: `${constraintId}__applies__${targetBlockId}`,
source: constraintId,
target: targetBlockId,
type: "sysml",
data: { label: "applies_to", kind: "constraintApplies" },
};
}
/**
* Position factory for newly-arrived nodes — places each at the right edge of
* the existing layout, stacked vertically, so they're visible without panning.
* Falls back to a fixed offset when there are no existing nodes.
*/
function makePlaceNewPosition(existing: { position: { x: number; y: number } }[]): () => { x: number; y: number } {
let baseX = 100;
let baseY = 100;
if (existing.length > 0) {
const maxX = Math.max(...existing.map(n => n.position.x));
const minY = Math.min(...existing.map(n => n.position.y));
baseX = maxX + 240;
baseY = minY;
}
let i = 0;
return () => {
const pos = { x: baseX, y: baseY + i * 140 };
i++;
return pos;
};
}
function snapshotIds(model: SysMLModel): { blocks: Set<string>; assocs: Set<string>; constraints: Set<string> } {
return {
blocks: new Set(model.blocks.map(b => b.id)),
assocs: new Set(model.associations.map(a => a.id)),
constraints: new Set(model.constraints.map(c => c.id)),
};
}
function sameStringArray(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
/** Diff property-name lists into add/update/remove ops. */
function diffProperties(block: Block, nextNames: string[]): ModelOp[] {
const ops: ModelOp[] = [];
const cur = block.properties;
for (let i = 0; i < cur.length; i++) {
const c = cur[i]!;
const n = nextNames[i];
if (n === undefined) {
ops.push(removePropertyOp(block.id, c.id));
} else if (n !== c.name) {
ops.push(updatePropertyOp(block.id, c.id, { name: n }));
}
}
for (let i = cur.length; i < nextNames.length; i++) {
const n = nextNames[i]!;
const pid = tempId("p");
const p: Property = { id: pid, name: n, type: { kind: "string" } as PropertyType, multiplicity: "0..1" };
ops.push(addPropertyOp(block.id, p, pid));
}
return ops;
}