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>
This commit is contained in:
2026-05-01 00:12:06 +02:00
parent 4e725c0b2b
commit b55425cc68
88 changed files with 10855 additions and 1768 deletions

View File

@@ -0,0 +1,994 @@
// 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;
}