Files
Socrates/apps/web/components/editor/columns/EntityColumns.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

995 lines
33 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.
// Detail-column panes for the Finder-style stack.
//
// Each column takes its own subject id (termId, blockId, etc.) and renders
// in the same `.pane` shell as the section panes, so resize / close / header
// behavior is consistent across the stack.
//
// Click handlers inside these columns call `pushFrom(index, ...)` to drill
// further (e.g. clicking a linked block on a term column pushes a block
// column to its right). The `index` prop is the column's own position in
// the stack — so child pushes can truncate everything to its right.
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { PaneFrame } from "../PaneFrame";
import { StatusChip } from "../StatusChip";
import { useAnalysis, type ClientTerm } from "../../../lib/workspace/analysisStore";
import { useModelStore } from "../../../lib/sync/ModelStore";
import { useOpenPanes } from "../../../lib/workspace/openPanesStore";
import { ContextualSocratesThread } from "../../socrates/ContextualSocratesThread";
import { PromoteToolbar } from "../PromoteToolbar";
import {
decideElement,
removeBlock,
removeAssociation,
removeConstraint,
removeRequirement,
type ReviewableElementKind,
} from "../../../lib/sync/ops";
import type { ReviewStatus, SysMLModel } from "../../../lib/sysml/model";
// ─── Term column ────────────────────────────────────────────────────────
interface TermColumnProps {
termId: string;
index: number;
onClose: () => void;
}
export function TermColumn({ termId, index, onClose }: TermColumnProps) {
const { getTerm, terms, requirements, findings } = useAnalysis();
const { model } = useModelStore();
const { pushFrom } = useOpenPanes();
const term = getTerm(termId);
if (!term) {
return (
<PaneFrame title="Concept" subtitle="not found" onClose={onClose}>
<div className="pane-empty">This concept no longer exists.</div>
</PaneFrame>
);
}
const parents = buildParentChain(term, terms);
const children = terms.filter(t => t.parentId === term.id);
const linkedBlock = term.linkedBlockId
? model.blocks.find(b => b.id === term.linkedBlockId) ?? null
: null;
const linkedAssociations = model.associations.filter(a => a.linkedTermId === term.id);
const linkedConstraints = model.constraints.filter(c => c.linkedTermId === term.id);
const linkedReqsFromModel = model.requirements.filter(r => r.linkedTermId === term.id);
const linkedReqsFromAnalyze = requirements.filter(r => r.linkedTermId === term.id);
const relatedFindings = linkedBlock
? findings.filter(f => f.linkedElementIds.includes(linkedBlock.id))
: findings.filter(f => f.linkedElementIds.includes(`term:${term.id}`));
const hasFormalism =
!!linkedBlock ||
linkedAssociations.length > 0 ||
linkedConstraints.length > 0 ||
linkedReqsFromModel.length > 0 ||
linkedReqsFromAnalyze.length > 0;
return (
<PaneFrame
title={term.label}
subtitle="concept"
onClose={onClose}
>
<div className="column-body">
<DefinitionSection term={term} />
{(parents.length > 0 || children.length > 0 || term.synonyms.length > 0) && (
<Section label="Hierarchy">
{parents.length > 0 && (
<Row label="Parents">
{parents.map((p, i) => (
<span key={p.id}>
{i > 0 ? " " : null}
<button
type="button"
className="termdetail-chip"
onClick={() => pushFrom(index - 1, { kind: "term", id: p.id })}
>
{p.label}
</button>
</span>
))}
</Row>
)}
{children.length > 0 && (
<Row label="Children">
{children.map(c => (
<button
key={c.id}
type="button"
className="termdetail-chip"
onClick={() => pushFrom(index, { kind: "term", id: c.id })}
>
{c.label}
</button>
))}
</Row>
)}
{term.synonyms.length > 0 && (
<Row label="Synonyms">
{term.synonyms.map(s => (
<span key={s} className="termdetail-synonym">
{s}
</span>
))}
</Row>
)}
</Section>
)}
<Section label="Promote to formal element">
<PromoteToolbar term={term} />
</Section>
<Section label="Formalisms">
{!hasFormalism ? (
<p className="termdetail-empty">
Not yet formalized. Promote it above to make it a block, association, constraint, or requirement.
</p>
) : (
<ul className="termdetail-formalism-list">
{linkedBlock && (
<li>
<button
type="button"
className="termdetail-formalism"
onClick={() => pushFrom(index, { kind: "block", id: linkedBlock.id })}
>
<span className="termdetail-formalism-kind">{linkedBlock.kind}</span>
<span className="termdetail-formalism-label">{linkedBlock.label}</span>
<span className="termdetail-formalism-jump"></span>
</button>
</li>
)}
{linkedAssociations.map(a => (
<li key={a.id}>
<button
type="button"
className="termdetail-formalism"
onClick={() => pushFrom(index, { kind: "association", id: a.id })}
>
<span className="termdetail-formalism-kind">{a.kind}</span>
<span className="termdetail-formalism-label">
{labelOf(model, a.fromBlockId)} <em>{a.label || "→"}</em>{" "}
{labelOf(model, a.toBlockId)}
</span>
<span className="termdetail-formalism-jump"></span>
</button>
</li>
))}
{linkedConstraints.map(c => (
<li key={c.id}>
<button
type="button"
className="termdetail-formalism"
onClick={() => pushFrom(index, { kind: "constraint", id: c.id })}
>
<span className="termdetail-formalism-kind">constraint</span>
<span className="termdetail-formalism-label">
{c.label}
{c.expression ? <> <code>{c.expression}</code></> : null}
</span>
<span className="termdetail-formalism-jump"></span>
</button>
</li>
))}
{linkedReqsFromModel.map(r => (
<li key={`m-${r.id}`}>
<button
type="button"
className="termdetail-formalism"
onClick={() => pushFrom(index, { kind: "requirement", id: r.id })}
>
<span className="termdetail-formalism-kind">{r.tag}</span>
<span className="termdetail-formalism-label">{r.text}</span>
<span className="termdetail-formalism-jump"></span>
</button>
</li>
))}
{linkedReqsFromAnalyze
.filter(r => !linkedReqsFromModel.some(m => m.tag === r.tag))
.map(r => (
<li key={`a-${r.id}`}>
<div className="termdetail-formalism termdetail-formalism-static">
<span className="termdetail-formalism-kind">{r.tag}</span>
<span className="termdetail-formalism-label">{r.text}</span>
</div>
</li>
))}
</ul>
)}
</Section>
{relatedFindings.length > 0 && (
<Section label={`Findings (${relatedFindings.length})`}>
<ul className="termdetail-findings">
{relatedFindings.map(f => (
<li key={f.id} className={`termdetail-finding termdetail-finding-${f.kind}`}>
<button
type="button"
className="termdetail-finding-btn"
onClick={() => pushFrom(index, { kind: "finding", id: f.id })}
>
<span className="termdetail-finding-kind">{f.kind}</span>
<span className="termdetail-finding-text">{f.text}</span>
</button>
</li>
))}
</ul>
</Section>
)}
</div>
</PaneFrame>
);
}
// ─── Block column ───────────────────────────────────────────────────────
export function BlockColumn({
blockId,
index,
onClose,
}: {
blockId: string;
index: number;
onClose: () => void;
}) {
const { model, apply } = useModelStore();
const { findings } = useAnalysis();
const { pushFrom } = useOpenPanes();
const block = model.blocks.find(b => b.id === blockId);
if (!block) {
return (
<PaneFrame title="Block" subtitle="not found" onClose={onClose}>
<div className="pane-empty">This block no longer exists.</div>
</PaneFrame>
);
}
const associations = model.associations.filter(
a => a.fromBlockId === block.id || a.toBlockId === block.id
);
const requirements = model.requirements.filter(r =>
r.relations.some(rel => rel.kind === "satisfy" && rel.blockId === block.id)
);
const relatedFindings = findings.filter(f => f.linkedElementIds.includes(block.id));
return (
<PaneFrame
title={block.label}
subtitle={block.kind === "block" ? "block" : `block · ${block.kind}`}
right={
<ReviewBadge status={block.reviewStatus}>
<DecideButtons
kind="block"
id={block.id}
status={block.reviewStatus}
onKeep={() => apply([decideElement({ kind: "block", id: block.id })])}
onDiscard={() => apply([removeBlock(block.id)])}
/>
</ReviewBadge>
}
onClose={onClose}
>
<div className="column-body">
{block.linkedTermId && (
<Section label="Concept">
<button
type="button"
className="termdetail-chip"
onClick={() => pushFrom(index, { kind: "term", id: block.linkedTermId! })}
>
open concept
</button>
</Section>
)}
{block.properties.length > 0 && (
<Section label="Properties">
<ul className="block-prop-list">
{block.properties.map(p => (
<li key={p.id} className="block-prop">
<span className="block-prop-name">{p.name}</span>
<span className="block-prop-type">{p.type.kind}</span>
</li>
))}
</ul>
</Section>
)}
{associations.length > 0 && (
<Section label="Associations">
<ul className="termdetail-formalism-list">
{associations.map(a => (
<li key={a.id}>
<button
type="button"
className="termdetail-formalism"
onClick={() => pushFrom(index, { kind: "association", id: a.id })}
>
<span className="termdetail-formalism-kind">{a.kind}</span>
<span className="termdetail-formalism-label">
{labelOf(model, a.fromBlockId)} <em>{a.label || "→"}</em>{" "}
{labelOf(model, a.toBlockId)}
</span>
<span className="termdetail-formalism-jump"></span>
</button>
</li>
))}
</ul>
</Section>
)}
{requirements.length > 0 && (
<Section label="Requirements satisfied">
<ul className="termdetail-formalism-list">
{requirements.map(r => (
<li key={r.id}>
<button
type="button"
className="termdetail-formalism"
onClick={() => pushFrom(index, { kind: "requirement", id: r.id })}
>
<span className="termdetail-formalism-kind">{r.tag}</span>
<span className="termdetail-formalism-label">{r.text}</span>
<span className="termdetail-formalism-jump"></span>
</button>
</li>
))}
</ul>
</Section>
)}
{relatedFindings.length > 0 && (
<Section label={`Findings (${relatedFindings.length})`}>
<ul className="termdetail-findings">
{relatedFindings.map(f => (
<li key={f.id} className={`termdetail-finding termdetail-finding-${f.kind}`}>
<button
type="button"
className="termdetail-finding-btn"
onClick={() => pushFrom(index, { kind: "finding", id: f.id })}
>
<span className="termdetail-finding-kind">{f.kind}</span>
<span className="termdetail-finding-text">{f.text}</span>
</button>
</li>
))}
</ul>
</Section>
)}
</div>
</PaneFrame>
);
}
// ─── Association column ─────────────────────────────────────────────────
export function AssociationColumn({
associationId,
index,
onClose,
}: {
associationId: string;
index: number;
onClose: () => void;
}) {
const { model, apply } = useModelStore();
const { pushFrom } = useOpenPanes();
const a = model.associations.find(x => x.id === associationId);
if (!a) {
return (
<PaneFrame title="Association" subtitle="not found" onClose={onClose}>
<div className="pane-empty">This association no longer exists.</div>
</PaneFrame>
);
}
const from = model.blocks.find(b => b.id === a.fromBlockId);
const to = model.blocks.find(b => b.id === a.toBlockId);
return (
<PaneFrame
title={a.label || "Association"}
subtitle={a.kind === "association" ? "association" : `association · ${a.kind}`}
right={
<ReviewBadge status={a.reviewStatus}>
<DecideButtons
kind="association"
id={a.id}
status={a.reviewStatus}
onKeep={() => apply([decideElement({ kind: "association", id: a.id })])}
onDiscard={() => apply([removeAssociation(a.id)])}
/>
</ReviewBadge>
}
onClose={onClose}
>
<div className="column-body">
<Section label="Endpoints">
<Row label="From">
<button
type="button"
className="termdetail-chip"
onClick={() => from && pushFrom(index, { kind: "block", id: from.id })}
disabled={!from}
>
{from?.label ?? a.fromBlockId}
</button>
</Row>
<Row label="To">
<button
type="button"
className="termdetail-chip"
onClick={() => to && pushFrom(index, { kind: "block", id: to.id })}
disabled={!to}
>
{to?.label ?? a.toBlockId}
</button>
</Row>
</Section>
{a.linkedTermId && (
<Section label="Concept">
<button
type="button"
className="termdetail-chip"
onClick={() => pushFrom(index, { kind: "term", id: a.linkedTermId! })}
>
open concept
</button>
</Section>
)}
</div>
</PaneFrame>
);
}
// ─── Constraint column ──────────────────────────────────────────────────
export function ConstraintColumn({
constraintId,
index,
onClose,
}: {
constraintId: string;
index: number;
onClose: () => void;
}) {
const { model, apply } = useModelStore();
const { pushFrom } = useOpenPanes();
const c = model.constraints.find(x => x.id === constraintId);
if (!c) {
return (
<PaneFrame title="Constraint" subtitle="not found" onClose={onClose}>
<div className="pane-empty">This constraint no longer exists.</div>
</PaneFrame>
);
}
return (
<PaneFrame
title={c.label}
subtitle="constraint"
right={
<ReviewBadge status={c.reviewStatus}>
<DecideButtons
kind="constraint"
id={c.id}
status={c.reviewStatus}
onKeep={() => apply([decideElement({ kind: "constraint", id: c.id })])}
onDiscard={() => apply([removeConstraint(c.id)])}
/>
</ReviewBadge>
}
onClose={onClose}
>
<div className="column-body">
{c.expression && (
<Section label="Expression">
<code className="constraint-expr">{c.expression}</code>
</Section>
)}
{c.appliesTo.length > 0 && (
<Section label="Applies to">
<ul className="termdetail-formalism-list">
{c.appliesTo.map(bid => {
const b = model.blocks.find(x => x.id === bid);
return (
<li key={bid}>
<button
type="button"
className="termdetail-formalism"
onClick={() => pushFrom(index, { kind: "block", id: bid })}
>
<span className="termdetail-formalism-kind">block</span>
<span className="termdetail-formalism-label">{b?.label ?? bid}</span>
<span className="termdetail-formalism-jump"></span>
</button>
</li>
);
})}
</ul>
</Section>
)}
{c.linkedTermId && (
<Section label="Concept">
<button
type="button"
className="termdetail-chip"
onClick={() => pushFrom(index, { kind: "term", id: c.linkedTermId! })}
>
open concept
</button>
</Section>
)}
</div>
</PaneFrame>
);
}
// ─── Requirement column ─────────────────────────────────────────────────
export function RequirementColumn({
requirementId,
index,
onClose,
}: {
requirementId: string;
index: number;
onClose: () => void;
}) {
const { model, apply } = useModelStore();
const { requirements: analyzedReqs, getTerm, decideRequirement } = useAnalysis();
const { pushFrom } = useOpenPanes();
// Match by id in either list.
const modelReq = model.requirements.find(r => r.id === requirementId);
const analyzedReq = analyzedReqs.find(r => r.id === requirementId);
if (!modelReq && !analyzedReq) {
return (
<PaneFrame title="Requirement" subtitle="not found" onClose={onClose}>
<div className="pane-empty">This requirement no longer exists.</div>
</PaneFrame>
);
}
const tag = modelReq?.tag ?? analyzedReq?.tag ?? requirementId;
const text = modelReq?.text ?? analyzedReq?.text ?? "";
const linkedTermId = modelReq?.linkedTermId ?? analyzedReq?.linkedTermId ?? null;
const tracedTo = modelReq?.relations
.filter((r): r is { kind: "satisfy"; blockId: string } => r.kind === "satisfy")
.map(r => r.blockId) ?? analyzedReq?.tracedToIds ?? [];
const reviewStatus = modelReq?.reviewStatus;
const analyzedStatus = analyzedReq?.status;
return (
<PaneFrame
title={tag}
subtitle="requirement"
right={
modelReq ? (
<ReviewBadge status={reviewStatus}>
<DecideButtons
kind="requirement"
id={modelReq.id}
status={reviewStatus}
onKeep={() => apply([decideElement({ kind: "requirement", id: modelReq.id })])}
onDiscard={() => apply([removeRequirement(modelReq.id)])}
/>
</ReviewBadge>
) : analyzedReq && analyzedStatus !== "accepted" ? (
<div className="term-decision-row">
<button
type="button"
className="term-decide term-decide-keep"
onClick={() => void decideRequirement(analyzedReq.id, "keep")}
>
Keep
</button>
<button
type="button"
className="term-decide term-decide-discard"
onClick={() => void decideRequirement(analyzedReq.id, "discard")}
>
Discard
</button>
</div>
) : null
}
onClose={onClose}
>
<div className="column-body">
<Section label="Text">
<p className="termdetail-def">{text}</p>
</Section>
{linkedTermId && (
<Section label="Concept">
<button
type="button"
className="termdetail-chip"
onClick={() => pushFrom(index, { kind: "term", id: linkedTermId })}
>
{getTerm(linkedTermId)?.label ?? "↪ open concept"}
</button>
</Section>
)}
{tracedTo.length > 0 && (
<Section label="Traced to">
<ul className="termdetail-formalism-list">
{tracedTo.map(bid => {
const b = model.blocks.find(x => x.id === bid);
return (
<li key={bid}>
<button
type="button"
className="termdetail-formalism"
onClick={() => pushFrom(index, { kind: "block", id: bid })}
>
<span className="termdetail-formalism-kind">block</span>
<span className="termdetail-formalism-label">{b?.label ?? bid}</span>
<span className="termdetail-formalism-jump"></span>
</button>
</li>
);
})}
</ul>
</Section>
)}
</div>
</PaneFrame>
);
}
// ─── Finding column ─────────────────────────────────────────────────────
export function FindingColumn({
findingId,
index,
projectId,
onClose,
}: {
findingId: string;
index: number;
projectId: string;
onClose: () => void;
}) {
const { findings, decideFinding, refresh } = useAnalysis();
const { pushFrom } = useOpenPanes();
const f = findings.find(x => x.id === findingId);
if (!f) {
return (
<PaneFrame title="Finding" subtitle="not found" onClose={onClose}>
<div className="pane-empty">This finding no longer exists.</div>
</PaneFrame>
);
}
const isPending = f.status === "suggested" || f.status === "deprecated";
const termAnchors = f.linkedElementIds
.filter(s => s.startsWith("term:"))
.map(s => s.slice("term:".length));
const elementAnchors = f.linkedElementIds.filter(s => !s.startsWith("term:"));
return (
<PaneFrame
title={titleCase(f.kind)}
subtitle={
f.severity
? `${f.kind} · ${f.severity} · conf ${(f.confidence * 100).toFixed(0)}%`
: `${f.kind} · conf ${(f.confidence * 100).toFixed(0)}%`
}
right={
isPending ? (
<div className="term-decision-row">
<button
type="button"
className="term-decide term-decide-keep"
onClick={() => void decideFinding(f.id, "keep")}
>
Keep
</button>
<button
type="button"
className="term-decide term-decide-discard"
onClick={() => void decideFinding(f.id, "discard")}
>
Dismiss
</button>
<button
type="button"
className="term-decide term-decide-resolve"
onClick={() => void decideFinding(f.id, "resolve")}
>
Resolve
</button>
</div>
) : null
}
onClose={onClose}
>
<div className="column-body">
<Section label={f.validationCode ? `${f.validationCode} — finding` : "Finding"}>
<p className="termdetail-def">{f.text}</p>
</Section>
{(termAnchors.length > 0 || elementAnchors.length > 0) && (
<Section label="Anchors">
{termAnchors.map(tid => (
<button
key={tid}
type="button"
className="termdetail-chip"
onClick={() => pushFrom(index, { kind: "term", id: tid })}
>
concept
</button>
))}
{elementAnchors.map(eid => (
<button
key={eid}
type="button"
className="termdetail-chip"
onClick={() => pushFrom(index, { kind: "block", id: eid })}
>
{eid}
</button>
))}
</Section>
)}
<Section label="Discussion">
<ContextualSocratesThread
projectId={projectId}
findingId={f.id}
findingText={f.text}
onResolved={() => {
void refresh();
onClose();
}}
/>
</Section>
</div>
</PaneFrame>
);
}
// ─── Shared helpers ─────────────────────────────────────────────────────
/**
* Definition section for TermColumn. Read state by default; click anywhere
* on the definition (or the empty placeholder) to enter edit mode. Save
* with Cmd/Ctrl-Enter or by clicking Save; cancel with Esc. When the user
* has authored the definition (definitionPinned), shows a "pinned" chip
* in the header and a "Reset to AI suggestion" link in edit mode that
* clears the text + the pin.
*/
function DefinitionSection({ term }: { term: ClientTerm }) {
const { setTermDefinition } = useAnalysis();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(term.definition ?? "");
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
// When the user opens the editor, sync the draft with the latest text and
// focus the textarea. Selecting nothing keeps the cursor at the end.
useEffect(() => {
if (!editing) return;
setDraft(term.definition ?? "");
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
});
// term.id intentionally excluded from deps — re-running on every term
// change would clobber an in-flight edit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editing]);
const commit = () => {
setEditing(false);
const next = draft.trim();
const cur = (term.definition ?? "").trim();
if (next === cur) return;
void setTermDefinition(term.id, next.length > 0 ? next : null);
};
const cancel = () => {
setEditing(false);
setDraft(term.definition ?? "");
};
const reset = () => {
setEditing(false);
setDraft("");
void setTermDefinition(term.id, null);
};
const headerRight = term.definitionPinned ? (
<StatusChip variant="muted" label="pinned" title="You authored this definition. Future Analyze runs will leave it alone." />
) : null;
if (editing) {
return (
<Section label="Definition" right={headerRight}>
<div className="termdetail-def-edit">
<textarea
ref={textareaRef}
className="termdetail-def-textarea"
value={draft}
onChange={e => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={e => {
if (e.key === "Escape") {
e.preventDefault();
cancel();
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
commit();
}
}}
placeholder="A short noun-phrase definition…"
rows={3}
/>
<div className="termdetail-def-actions">
<button
type="button"
className="termdetail-def-btn termdetail-def-btn-primary"
onMouseDown={e => {
// Don't lose focus before commit — onBlur would fire and
// commit a possibly-stale value race.
e.preventDefault();
commit();
}}
>
Save
</button>
<button
type="button"
className="termdetail-def-btn"
onMouseDown={e => {
e.preventDefault();
cancel();
}}
>
Cancel
</button>
{term.definitionPinned ? (
<button
type="button"
className="termdetail-def-link"
onMouseDown={e => {
e.preventDefault();
reset();
}}
title="Clear the definition and let the next Analyze pass refill it from prose"
>
Reset to AI suggestion
</button>
) : null}
<span className="termdetail-def-hint"> to save · Esc to cancel</span>
</div>
</div>
</Section>
);
}
return (
<Section label="Definition" right={headerRight}>
{term.definition ? (
<p
className="termdetail-def termdetail-def-clickable"
onClick={() => setEditing(true)}
role="button"
tabIndex={0}
onKeyDown={e => {
if (e.key === "Enter") {
e.preventDefault();
setEditing(true);
}
}}
>
{term.definition}
</p>
) : (
<button
type="button"
className="termdetail-empty termdetail-def-clickable termdetail-def-empty-btn"
onClick={() => setEditing(true)}
>
Click to write a definition
</button>
)}
</Section>
);
}
function Section({ label, children, right }: { label: string; children: ReactNode; right?: ReactNode }) {
return (
<section className="termdetail-section">
<header className="termdetail-section-head">
<h3 className="termdetail-section-h">{label}</h3>
{right ? <div className="termdetail-section-right">{right}</div> : null}
</header>
<div className="termdetail-section-body">{children}</div>
</section>
);
}
function Row({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="termdetail-row">
<span className="termdetail-row-label">{label}</span>
<span className="termdetail-row-body">{children}</span>
</div>
);
}
function ReviewBadge({
status,
children,
}: {
status?: ReviewStatus;
children?: ReactNode;
}) {
if (status === "suggested" || status === "deprecated") {
return (
<div className="column-review-row">
<StatusChip variant={status} />
{children}
</div>
);
}
return null;
}
function DecideButtons({
status,
onKeep,
onDiscard,
}: {
kind: ReviewableElementKind;
id: string;
status?: ReviewStatus;
onKeep: () => void;
onDiscard: () => void;
}) {
if (status !== "suggested" && status !== "deprecated") return null;
return (
<span className="term-decision-row">
<button type="button" className="term-decide term-decide-keep" onClick={onKeep}>
Keep
</button>
<button type="button" className="term-decide term-decide-discard" onClick={onDiscard}>
Discard
</button>
</span>
);
}
function buildParentChain(
term: { id: string; parentId: string | null },
terms: Array<{ id: string; parentId: string | null; label: string }>
): Array<{ id: string; label: string }> {
const byId = new Map(terms.map(t => [t.id, t]));
const chain: Array<{ id: string; label: string }> = [];
let cur = term.parentId ? byId.get(term.parentId) : null;
const seen = new Set<string>();
while (cur && !seen.has(cur.id)) {
seen.add(cur.id);
chain.unshift({ id: cur.id, label: cur.label });
cur = cur.parentId ? byId.get(cur.parentId) : null;
}
return chain;
}
function labelOf(model: SysMLModel, id: string): string {
return model.blocks.find(b => b.id === id)?.label ?? id;
}
function titleCase(s: string): string {
return s.length ? s[0].toUpperCase() + s.slice(1) : s;
}