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>
995 lines
33 KiB
TypeScript
995 lines
33 KiB
TypeScript
// 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;
|
||
}
|
||
|