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:
994
apps/web/components/editor/columns/EntityColumns.tsx
Normal file
994
apps/web/components/editor/columns/EntityColumns.tsx
Normal 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user