Files
Socrates/apps/web/components/editor/LeftRail.tsx
dtoro 384cbb4ae9 MVP M5 (in-memory): bidirectional sync via ModelOp + applyOps + ModelStore
Both canvases now share a canonical SysMLModel through React context.
Renames in the diagram inspector ripple to every chip in the narrative
referencing the same refId, and double-clicking a chip emits an update op
that re-renders the diagram block. Validation re-runs on every successful
apply. State is in-memory; M5.9 adds Postgres persistence.

apps/web/lib/sync (new)
- ops.ts: ModelOp alphabet (18 op kinds across block / property /
  association / constraint / requirement / relation, add/update/remove for
  each). tempId() helper + per-kind constructors.
- applyOps.ts: pure (model, ops) → { model, idMapping, errors, applied }
  reducer. Atomic per batch. Cascades on remove-block (drops incident
  associations + constraint applies-to + requirement satisfiers). tempId
  resolution rewrites to canonical ids on duplicate-id collision.
- ModelStore.tsx: React provider exposing { model, apply, issues,
  issuesByElement }. Validation memoized on every model change.

Editor refactor
- EditorShell wraps in ModelStoreProvider. Initial model derived from
  fixture (+ optional ?break= corruptions). Validation now lives in the
  store, not duplicated here.
- LeftRail consumes useModel(): Model section lists real blocks +
  constraints (sorted by kind), Requirements section lists real
  requirements with traced/untraced status from r.relations.

Diagram refactor (the tricky piece)
- React Flow now owns ephemeral state via useNodesState / useEdgesState.
  Positions, drag-in-progress, selection are all RF-internal.
- Model → RF: a useEffect runs on model change, applies targeted setNodes
  updates only for elements whose semantic data (label, kind, properties)
  changed. Object identity preserved for unchanged nodes — fixes the
  "re-render storm on drag" + RF measurement-cache loss.
- RF → Model: onNodesChange / onEdgesChange / onConnect / onDrop emit
  ops via useApply(). Constraint-applies edges decompose into
  updateConstraint ops. deleteKeyCode={[Backspace, Delete]}.
- onNodesChange now handles type:'remove' too (was missing — that's why
  selecting a block + Del removed only the edges, leaving the block).

Chip refactor
- ChipView resolves displayed label from useModel() via refId lookup
  (block / requirement / association / property). Double-click chip →
  inline rename input → emit update-{block,requirement,association} op.
  All other chips with the same refId update on the next render.
- Slash-menu inserted chips have refId=null and skip the rename
  affordance (until M6 wires real model element resolution).

Removed obsolete components/diagram-canvas/fixtureToFlow.ts; replaced
with modelToFlow.ts. Bumped CSS for the chip-rename input.
2026-04-29 00:42:18 +02:00

202 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Outline / Model / Requirements sections, each independently collapsible.
// The whole rail can also collapse to a 36px vertical strip.
//
// M5: Model + Requirements sections read from the canonical SysMLModel via
// useModelStore() so renames in either canvas reflect here immediately.
// Outline section is still narrative-derived and uses the fixture (M6 will
// migrate it to the live ProseMirror outline).
"use client";
import { useState } from "react";
import type { FixtureData } from "../../lib/fixtures/aristotle";
import type { ValidationIssue, Severity } from "../../lib/sysml/validate";
import { useModel } from "../../lib/sync/ModelStore";
interface LeftRailProps {
data: FixtureData;
focusBlockId: string | null;
setFocusBlockId: (id: string | null) => void;
/** Map keyed by element key (block id, `req:<id>`, `assoc:<id>`, …) → issues. */
issuesByElement?: Map<string, ValidationIssue[]>;
}
function maxSeverityForKey(map: Map<string, ValidationIssue[]> | undefined, key: string): Severity | null {
const items = map?.get(key);
if (!items || items.length === 0) return null;
if (items.some(i => i.severity === "error")) return "error";
if (items.some(i => i.severity === "warning")) return "warning";
return "soft";
}
function IssueDot({ severity, title }: { severity: Severity | null; title?: string }) {
if (!severity) return null;
return <span className={`rail-issue-dot rail-issue-dot-${severity}`} title={title} />;
}
export function LeftRail({ focusBlockId, setFocusBlockId, issuesByElement }: LeftRailProps) {
const [collapsed, setCollapsed] = useState(false);
const [open, setOpen] = useState({ outline: true, model: true, requirements: true });
const toggle = (k: keyof typeof open) => setOpen(s => ({ ...s, [k]: !s[k] }));
const model = useModel();
if (collapsed) {
return (
<nav className="leftrail leftrail-collapsed">
<button
className="rail-collapse-btn"
onClick={() => setCollapsed(false)}
title="Expand rail"
type="button"
>
</button>
<div className="rail-collapsed-stack">
<span className="rail-collapsed-tag" title="Outline">OUT</span>
<span className="rail-collapsed-tag" title={`Model · ${model.blocks.length + model.constraints.length} elements`}>MOD</span>
<span className="rail-collapsed-tag" title="Requirements">REQ</span>
</div>
</nav>
);
}
// Combine blocks + constraints in the Model section, sorted by kind for a
// predictable order: system → block → actor → constraint.
const kindRank: Record<string, number> = { system: 0, block: 1, actor: 2, constraint: 3 };
const modelEntries: Array<{
id: string;
label: string;
kind: "block" | "actor" | "constraint" | "system";
propertyCount: number;
}> = [
...model.blocks.map(b => ({
id: b.id,
label: b.label,
kind: b.kind,
propertyCount: b.properties.length,
})),
...model.constraints.map(c => ({
id: c.id,
label: c.label,
kind: "constraint" as const,
propertyCount: 0,
})),
].sort((a, b) => {
const r = (kindRank[a.kind] ?? 99) - (kindRank[b.kind] ?? 99);
return r !== 0 ? r : a.label.localeCompare(b.label);
});
return (
<nav className="leftrail">
<div className="rail-section">
<button className="rail-section-head" onClick={() => toggle("outline")} type="button">
<span className={`rail-caret ${open.outline ? "rail-caret-open" : ""}`}></span>
<span className="rail-label">Outline</span>
</button>
{open.outline && (
<ul className="rail-list">
<li className="rail-item rail-item-active">
<span className="rail-item-text">Problem framing</span>
</li>
<li className="rail-item">
<span className="rail-item-text">Constraints</span>
<span className="rail-count rail-count-req">3</span>
</li>
<li className="rail-item">
<span className="rail-item-text">Why now</span>
</li>
<li className="rail-item rail-item-muted">
<span className="rail-item-text">Hypotheses</span>
<span className="rail-count rail-count-asm">3</span>
</li>
<li className="rail-item rail-item-muted">
<span className="rail-item-text">Open questions</span>
<span className="rail-count rail-count-q">5</span>
</li>
<li className="rail-item rail-item-muted">
<span className="rail-item-text">Risks</span>
<span className="rail-count rail-count-risk">2</span>
</li>
</ul>
)}
</div>
<div className="rail-section">
<button className="rail-section-head" onClick={() => toggle("model")} type="button">
<span className={`rail-caret ${open.model ? "rail-caret-open" : ""}`}></span>
<span className="rail-label">Model</span>
</button>
{open.model && (
<ul className="rail-list rail-blocks">
{modelEntries.map(b => {
const sev = maxSeverityForKey(issuesByElement, b.id);
const tooltip = issuesByElement?.get(b.id)?.map(i => `${i.code}: ${i.message}`).join("\n");
return (
<li
key={b.id}
className={`rail-block rail-${b.kind} ${focusBlockId === b.id ? "rail-block-active" : ""}`}
onMouseEnter={() => setFocusBlockId(b.id)}
onMouseLeave={() => setFocusBlockId(null)}
onClick={() => setFocusBlockId(b.id)}
style={{ cursor: "pointer" }}
>
<span className="rail-block-glyph">
{b.kind === "constraint" ? "{}" : b.kind === "actor" ? "◐" : b.kind === "system" ? "◎" : "▢"}
</span>
<span className="rail-block-label">{b.label}</span>
<IssueDot severity={sev} title={tooltip} />
{b.kind !== "constraint" && (
<span
className="rail-block-count"
title={`${b.propertyCount} ${b.propertyCount === 1 ? "property" : "properties"}`}
>
<span className="rail-block-count-glyph">·</span>
{b.propertyCount}
</span>
)}
</li>
);
})}
</ul>
)}
</div>
<div className="rail-section">
<button className="rail-section-head" onClick={() => toggle("requirements")} type="button">
<span className={`rail-caret ${open.requirements ? "rail-caret-open" : ""}`}></span>
<span className="rail-label">Requirements</span>
</button>
{open.requirements && (
<ul className="rail-list">
{model.requirements.map(r => {
const key = `req:${r.id}`;
const sev = maxSeverityForKey(issuesByElement, key);
const tooltip = issuesByElement?.get(key)?.map(i => `${i.code}: ${i.message}`).join("\n");
const traced = r.relations.some(rel => rel.kind === "satisfy");
return (
<li key={r.id} className="rail-req">
<span className="req-tag">{r.tag}</span>
{sev ? (
<IssueDot severity={sev} title={tooltip} />
) : (
<span className={`req-status ${traced ? "req-traced" : "req-untraced"}`} />
)}
</li>
);
})}
</ul>
)}
</div>
<button
className="rail-collapse-btn rail-collapse-btn-bottom"
onClick={() => setCollapsed(true)}
title="Collapse rail"
type="button"
>
</button>
</nav>
);
}