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,320 @@
// Concepts pane — unified view of taxonomy + glossary. The two layers share a
// single TaxonomyTerm table, so what looked like two panes was always one
// dataset rendered two ways. This pane gives the user a Tree/Alphabetical
// toggle and a single Analyze action that refreshes both layers.
//
// Re-running Analyze does NOT replace; it produces a *review*. Each row
// carries one of three statuses:
// accepted — confirmed, surfaces normally
// suggested — analyzer added in last run, awaits keep/discard
// deprecated — analyzer didn't see in last run, awaits keep/discard
// Pending statuses show a NEW / DEPRECATED badge plus inline Keep / Discard
// buttons. A Pending filter narrows the list to just the changes awaiting
// review when there are many.
//
// Each row is also draggable onto the Model canvas (MIME
// `application/x-socrata-term`), and clicking a row opens TermDetail.
"use client";
import { useMemo, useState } from "react";
import { PaneFrame } from "../PaneFrame";
import { PaneViewTabs } from "../PaneControls";
import { PaneEmpty } from "../PaneEmpty";
import { PaneDrawer } from "../PaneDrawer";
import { StatusChip } from "../StatusChip";
import { useAnalysis, type ClientTerm } from "../../../lib/workspace/analysisStore";
import { useOpenPanes } from "../../../lib/workspace/openPanesStore";
type ViewMode = "tree" | "alpha";
interface ConceptsPaneProps {
/** Position of this pane in the column stack (0 = root). */
index: number;
onClose: () => void;
}
export function ConceptsPane({ index, onClose }: ConceptsPaneProps) {
const { terms, analyze, inFlight } = useAnalysis();
const [mode, setMode] = useState<ViewMode>("tree");
const running = inFlight.has("concepts") || inFlight.has("all");
const kept = useMemo(() => terms.filter(t => t.status === "accepted"), [terms]);
const pending = useMemo(() => terms.filter(t => t.status !== "accepted"), [terms]);
const definedCount = kept.filter(t => t.definition && t.definition.length > 0).length;
const subtitle =
pending.length > 0
? `${kept.length} kept · ${definedCount} defined · ${pending.length} pending`
: `${kept.length} terms · ${definedCount} defined`;
const right = (
<div className="pane-controls">
<PaneViewTabs<ViewMode>
value={mode}
onChange={setMode}
ariaLabel="Concepts view mode"
tabs={[
{ value: "tree", label: "Tree", title: "Hierarchical view" },
{ value: "alpha", label: "AZ", title: "Alphabetical view with definitions" },
]}
/>
<button
type="button"
className="pane-action"
onClick={() => void analyze("concepts")}
disabled={running}
>
{running ? "Analyzing…" : "Analyze"}
</button>
</div>
);
return (
<PaneFrame title="Concepts" subtitle={subtitle} right={right} onClose={onClose}>
<div className="pane-drawer-stack">
<div className="pane-drawer-main">
{kept.length === 0 && pending.length === 0 ? (
<PaneEmpty
title="No concepts yet"
hint={
<>
Concepts are extracted from your prose. Write your idea in the editor on the right,
then run <strong>Analyze</strong>.
</>
}
action={{
label: running ? "Analyzing…" : "Run Analyze →",
onClick: () => void analyze("concepts"),
disabled: running,
}}
/>
) : kept.length === 0 ? (
<PaneEmpty
title="Nothing kept yet"
hint="Review the pending suggestions below to start building your concept list."
/>
) : mode === "tree" ? (
<TreeView terms={kept} parentIndex={index} />
) : (
<AlphaView terms={kept} parentIndex={index} />
)}
</div>
{pending.length > 0 ? (
<PaneDrawer
title="Pending review"
count={pending.length}
tone="pending"
defaultOpen
>
<AlphaView terms={pending} parentIndex={index} />
</PaneDrawer>
) : null}
</div>
</PaneFrame>
);
}
interface TreeNode {
term: ClientTerm;
children: TreeNode[];
}
function buildTree(terms: ClientTerm[]): TreeNode[] {
const byId = new Map<string, TreeNode>(terms.map(t => [t.id, { term: t, children: [] }]));
const roots: TreeNode[] = [];
for (const t of terms) {
const node = byId.get(t.id)!;
if (t.parentId && byId.has(t.parentId)) {
byId.get(t.parentId)!.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
function TreeView({ terms, parentIndex }: { terms: ClientTerm[]; parentIndex: number }) {
const tree = useMemo(() => buildTree(terms), [terms]);
return (
<ul className="term-list">
{tree.map(node => (
<TermNode key={node.term.id} node={node} depth={0} parentIndex={parentIndex} />
))}
</ul>
);
}
function AlphaView({ terms, parentIndex }: { terms: ClientTerm[]; parentIndex: number }) {
const sorted = useMemo(() => [...terms].sort((a, b) => a.label.localeCompare(b.label)), [terms]);
const { pushFrom, columns } = useOpenPanes();
const activeId =
columns[parentIndex + 1]?.kind === "term" ? columns[parentIndex + 1]!.id : null;
return (
<ul className="term-list">
{sorted.map(t => (
<li key={t.id}>
<ConceptCard
term={t}
active={activeId === t.id}
onOpen={() => pushFrom(parentIndex, { kind: "term", id: t.id })}
/>
</li>
))}
</ul>
);
}
/** The shared visual unit for both Tree and AZ views in Concepts. The
* leading column is ALWAYS the chevron slot — Tree passes a real toggle
* button, AZ passes nothing and we render an empty placeholder of the
* same width. That keeps the cards visually identical at every depth and
* across views; only the chevron's behavior differs. */
function ConceptCard({
term,
active,
onOpen,
chevron,
}: {
term: ClientTerm;
active: boolean;
onOpen: () => void;
chevron?: React.ReactNode;
}) {
return (
<div
className={`term-tile term-status-${term.status} ${active ? "row-active" : ""}`}
draggable
onDragStart={e => dragTerm(e, term)}
onClick={onOpen}
role="button"
tabIndex={0}
onKeyDown={e => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen();
}
}}
>
{chevron}
<div className="term-tile-content">
<div className="term-tile-head">
<span className="term-label">{term.label}</span>
<StatusBadge term={term} />
</div>
{term.definition ? (
<div className="term-def">{term.definition}</div>
) : (
<div className="term-def term-def-empty">(no definition yet)</div>
)}
<DecisionRow term={term} />
</div>
</div>
);
}
function TermNode({
node,
depth,
parentIndex,
}: {
node: TreeNode;
depth: number;
parentIndex: number;
}) {
const { pushFrom, columns } = useOpenPanes();
const [expanded, setExpanded] = useState(true);
const t = node.term;
const hasChildren = node.children.length > 0;
const activeId =
columns[parentIndex + 1]?.kind === "term" ? columns[parentIndex + 1]!.id : null;
const open = activeId === t.id;
// Only parents get a chevron; leaves render flush-left (matches AZ view).
const chevron = hasChildren ? (
<button
type="button"
className="term-tile-chevron"
onClick={e => {
e.stopPropagation();
setExpanded(v => !v);
}}
aria-label={expanded ? "Collapse" : "Expand"}
>
{expanded ? "▾" : "▸"}
</button>
) : undefined;
return (
<li className="term-tree-node" style={{ "--tree-depth": depth } as React.CSSProperties}>
<ConceptCard
term={t}
active={open}
onOpen={() => pushFrom(parentIndex, { kind: "term", id: t.id })}
chevron={chevron}
/>
{hasChildren && expanded ? (
<ul className="term-list term-list-children">
{node.children.map(c => (
<TermNode key={c.term.id} node={c} depth={depth + 1} parentIndex={parentIndex} />
))}
</ul>
) : null}
</li>
);
}
function StatusBadge({ term }: { term: ClientTerm }) {
if (term.status === "suggested") {
return <StatusChip variant="suggested" title="Added by latest analyze" />;
}
if (term.status === "deprecated") {
return <StatusChip variant="deprecated" title="Analyzer didn't see this in the latest run" />;
}
if (term.linkedBlockId) {
return <span className="term-linked-dot" title="Has a linked block"></span>;
}
return <span className="term-drag-hint">drag to canvas</span>;
}
function DecisionRow({ term }: { term: ClientTerm }) {
const { decideTerm } = useAnalysis();
if (term.status === "accepted") return null;
const isSuggested = term.status === "suggested";
return (
<div className="term-decision-row" onClick={e => e.stopPropagation()}>
<button
type="button"
className="term-decide term-decide-keep"
onClick={e => {
e.stopPropagation();
void decideTerm(term.id, "keep");
}}
title={isSuggested ? "Accept this suggestion" : "Pin this term despite the analyzer dropping it"}
>
Keep
</button>
<button
type="button"
className="term-decide term-decide-discard"
onClick={e => {
e.stopPropagation();
void decideTerm(term.id, "discard");
}}
title={isSuggested ? "Reject this suggestion" : "Remove this term"}
>
Discard
</button>
</div>
);
}
function dragTerm(e: React.DragEvent, t: ClientTerm) {
e.dataTransfer.setData(
"application/x-socrata-term",
JSON.stringify({ termId: t.id, label: t.label, definition: t.definition })
);
e.dataTransfer.effectAllowed = "move";
}

View File

@@ -0,0 +1,231 @@
// Findings pane — three groups of rows for one kind (assumption / risk /
// inconsistency):
//
// 1. Kept — status="accepted". Top of the pane, scrollable.
// 2. Pending — status in {suggested, deprecated}. Drawer below kept.
// 3. Discarded — status in {dismissed, resolved}. Collapsed drawer at the
// bottom, hidden when empty.
//
// Clicking a row pushes a FindingColumn to the right (full detail + Socrates
// thread + decision actions). Decisions also work inline from the row.
"use client";
import { useMemo } from "react";
import { PaneFrame } from "../PaneFrame";
import { PaneEmpty } from "../PaneEmpty";
import { PaneDrawer } from "../PaneDrawer";
import { StatusChip } from "../StatusChip";
import { useAnalysis, type ClientFinding } from "../../../lib/workspace/analysisStore";
import { useOpenPanes } from "../../../lib/workspace/openPanesStore";
interface FindingsPaneProps {
kind: "assumption" | "risk" | "inconsistency";
index: number;
projectId: string;
onClose: () => void;
}
const TITLES: Record<FindingsPaneProps["kind"], string> = {
assumption: "Assumptions",
risk: "Risks",
inconsistency: "Inconsistencies",
};
const SECTION_TO_ANALYZE = {
assumption: "assumptions",
risk: "risks",
inconsistency: "inconsistencies",
} as const;
const PENDING_STATUSES = new Set(["suggested", "deprecated"]);
const DISCARDED_STATUSES = new Set(["dismissed", "resolved"]);
export function FindingsPane({ kind, index, onClose }: FindingsPaneProps) {
const { findings, analyze, inFlight } = useAnalysis();
const { pushFrom, columns } = useOpenPanes();
const items = useMemo(() => findings.filter(f => f.kind === kind), [findings, kind]);
const kept = useMemo(() => items.filter(f => f.status === "accepted"), [items]);
const pending = useMemo(() => items.filter(f => PENDING_STATUSES.has(f.status)), [items]);
const discarded = useMemo(() => items.filter(f => DISCARDED_STATUSES.has(f.status)), [items]);
const section = SECTION_TO_ANALYZE[kind];
const running = inFlight.has(section) || inFlight.has("all");
const activeId =
columns[index + 1]?.kind === "finding" ? columns[index + 1]!.id : null;
const subtitle =
pending.length > 0
? `${kept.length} kept · ${pending.length} pending`
: `${kept.length} kept`;
const right = (
<div className="pane-controls">
<button
type="button"
className="pane-action"
onClick={() => void analyze(section)}
disabled={running}
>
{running ? "Analyzing…" : "Analyze"}
</button>
</div>
);
const renderRow = (f: ClientFinding) => (
<FindingRow
key={f.id}
finding={f}
active={activeId === f.id}
onOpen={() => pushFrom(index, { kind: "finding", id: f.id })}
/>
);
return (
<PaneFrame title={TITLES[kind]} subtitle={subtitle} right={right} onClose={onClose}>
<div className="pane-drawer-stack">
<div className="pane-drawer-main">
{items.length === 0 ? (
<PaneEmpty
title={`No ${TITLES[kind].toLowerCase()} yet`}
hint={`${TITLES[kind]} are detected against the current model. Run Analyze to scan it for issues.`}
action={{
label: running ? "Analyzing…" : "Run Analyze →",
onClick: () => void analyze(section),
disabled: running,
}}
/>
) : kept.length === 0 ? (
<PaneEmpty
title="Nothing kept yet"
hint="Review the pending findings below to start tracking the ones that matter."
/>
) : (
<ul className="finding-list">{kept.map(renderRow)}</ul>
)}
</div>
{pending.length > 0 ? (
<PaneDrawer title="Pending review" count={pending.length} tone="pending" defaultOpen>
<ul className="finding-list">{pending.map(renderRow)}</ul>
</PaneDrawer>
) : null}
<PaneDrawer
title="Discarded"
count={discarded.length}
tone="muted"
hideWhenEmpty
>
<ul className="finding-list">{discarded.map(renderRow)}</ul>
</PaneDrawer>
</div>
</PaneFrame>
);
}
function FindingRow({
finding,
active,
onOpen,
}: {
finding: ClientFinding;
active: boolean;
onOpen: () => void;
}) {
const { decideFinding } = useAnalysis();
const isPending = PENDING_STATUSES.has(finding.status);
const isDiscarded = DISCARDED_STATUSES.has(finding.status);
return (
<li
className={`finding-row finding-status-${finding.status} ${active ? "row-active" : ""}`}
onClick={onOpen}
role="button"
tabIndex={0}
onKeyDown={e => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen();
}
}}
>
<div className="finding-row-head finding-row-head-static">
<span className="finding-text">{finding.text}</span>
<span className="finding-meta">
{finding.status === "suggested" ? <StatusChip variant="suggested" /> : null}
{finding.status === "deprecated" ? <StatusChip variant="deprecated" /> : null}
{finding.status === "dismissed" ? <StatusChip variant="dismissed" /> : null}
{finding.status === "resolved" ? <StatusChip variant="resolved" /> : null}
{finding.validationCode ? (
<StatusChip variant="code" value={finding.validationCode} title="Validation rule" />
) : null}
{finding.severity ? (
<StatusChip
variant="severity"
value={finding.severity as "low" | "medium" | "high"}
title="Severity"
/>
) : null}
<StatusChip
variant="confidence"
value={finding.confidence}
title={`${Math.round(finding.confidence * 100)}% confidence`}
/>
</span>
</div>
{isPending ? (
<div className="term-decision-row" onClick={e => e.stopPropagation()}>
<button
type="button"
className="term-decide term-decide-keep"
onClick={e => {
e.stopPropagation();
void decideFinding(finding.id, "keep");
}}
>
Keep
</button>
<button
type="button"
className="term-decide term-decide-discard"
onClick={e => {
e.stopPropagation();
void decideFinding(finding.id, "discard");
}}
>
Dismiss
</button>
{finding.status === "suggested" ? (
<button
type="button"
className="term-decide term-decide-resolve"
onClick={e => {
e.stopPropagation();
void decideFinding(finding.id, "resolve");
}}
>
Resolve
</button>
) : null}
</div>
) : isDiscarded ? (
<div className="term-decision-row" onClick={e => e.stopPropagation()}>
<button
type="button"
className="term-decide term-decide-keep"
onClick={e => {
e.stopPropagation();
void decideFinding(finding.id, "restore");
}}
title="Move back to Kept"
>
Restore
</button>
</div>
) : null}
</li>
);
}

View File

@@ -0,0 +1,340 @@
// Model pane — fully editable React Flow diagram + a Summary subtab listing
// blocks/associations/constraints/requirements with a click-to-jump UX +
// review controls (Keep / Discard) on analyzer-suggested or analyzer-deprecated
// elements.
"use client";
import { useState } from "react";
import { PaneFrame } from "../PaneFrame";
import { PaneViewTabs, PaneFilterChip } from "../PaneControls";
import { StatusChip } from "../StatusChip";
import { DiagramCanvas } from "../../diagram-canvas/DiagramCanvas";
import { useModelStore } from "../../../lib/sync/ModelStore";
import { useAnalysis } from "../../../lib/workspace/analysisStore";
import { useEditorPaneContext } from "./paneContext";
import { useOpenPanes } from "../../../lib/workspace/openPanesStore";
import {
decideElement,
removeBlock,
removeAssociation,
removeConstraint,
removeRequirement,
type ReviewableElementKind,
} from "../../../lib/sync/ops";
import type { ReviewStatus, SysMLModel } from "../../../lib/sysml/model";
interface ModelPaneProps {
index: number;
onClose: () => void;
}
type Tab = "diagram" | "summary";
export function ModelPane({ index, onClose }: ModelPaneProps) {
const [tab, setTab] = useState<Tab>("diagram");
const [pendingOnly, setPendingOnly] = useState(false);
const { model, issuesByElement } = useModelStore();
const { analyze, inFlight } = useAnalysis();
const { focusBlockId, setFocusBlockId, projectId } = useEditorPaneContext();
const running = inFlight.has("model") || inFlight.has("all");
const pendingCount = countPending(model);
const right = (
<div className="pane-controls">
<PaneViewTabs<Tab>
value={tab}
onChange={setTab}
ariaLabel="Model view mode"
tabs={[
{ value: "diagram", label: "Diagram" },
{ value: "summary", label: "Summary" },
]}
/>
<PaneFilterChip
active={pendingOnly}
onToggle={() => {
// Pending now overlays the current view mode rather than replacing
// it. If we're on Diagram and the user wants to triage pending
// items, switching to Summary makes far more sense than rendering
// a filtered diagram, so we still nudge to Summary on toggle-on.
setPendingOnly(p => {
if (!p && tab === "diagram") setTab("summary");
return !p;
});
}}
label="Pending"
count={pendingCount}
title="Show only suggestions and deprecated model elements"
/>
<button
type="button"
className="pane-action"
onClick={() => void analyze("model")}
disabled={running}
title="Re-derive model from prose"
>
{running ? "Analyzing…" : "Analyze"}
</button>
</div>
);
const subtitle =
pendingCount > 0
? `${model.blocks.length} blocks · ${model.associations.length} assocs · ${model.constraints.length} constraints · ${pendingCount} pending`
: `${model.blocks.length} blocks · ${model.associations.length} assocs · ${model.constraints.length} constraints`;
return (
<PaneFrame title="Model" subtitle={subtitle} right={right} onClose={onClose}>
{tab === "diagram" && !pendingOnly ? (
<div className="canvas-scroll canvas-scroll-diagram">
<DiagramCanvas
focusBlockId={focusBlockId}
onSelect={setFocusBlockId}
issuesByElement={issuesByElement}
projectId={projectId}
/>
</div>
) : (
<ModelSummary pendingOnly={pendingOnly} parentIndex={index} />
)}
</PaneFrame>
);
}
function countPending(model: SysMLModel): number {
const isPending = (rs?: ReviewStatus) => rs === "suggested" || rs === "deprecated";
return (
model.blocks.filter(b => isPending(b.reviewStatus)).length +
model.associations.filter(a => isPending(a.reviewStatus)).length +
model.constraints.filter(c => isPending(c.reviewStatus)).length +
model.requirements.filter(r => isPending(r.reviewStatus)).length
);
}
function ModelSummary({ pendingOnly, parentIndex }: { pendingOnly: boolean; parentIndex: number }) {
const { model, apply } = useModelStore();
const { pushFrom } = useOpenPanes();
const filterPending = <T extends { reviewStatus?: ReviewStatus }>(xs: T[]): T[] =>
pendingOnly ? xs.filter(x => x.reviewStatus === "suggested" || x.reviewStatus === "deprecated") : xs;
const blocks = filterPending(model.blocks);
const associations = filterPending(model.associations);
const constraints = filterPending(model.constraints);
const requirements = filterPending(model.requirements);
const totalShown = blocks.length + associations.length + constraints.length + requirements.length;
if (totalShown === 0) {
return (
<div className="pane-empty">
{pendingOnly ? (
<>No pending changes. Everything is up to date.</>
) : (
<>
No model yet. Click <strong>Analyze</strong> to derive one from your prose, or open the
diagram and start dragging.
</>
)}
</div>
);
}
const decideKeep = (kind: ReviewableElementKind, id: string) =>
apply([decideElement({ kind, id })]);
const decideDiscard = (kind: ReviewableElementKind, id: string) => {
if (kind === "block") apply([removeBlock(id)]);
else if (kind === "association") apply([removeAssociation(id)]);
else if (kind === "constraint") apply([removeConstraint(id)]);
else if (kind === "requirement") apply([removeRequirement(id)]);
};
return (
<div className="model-summary">
{blocks.length > 0 && (
<>
<h3 className="model-summary-h">Blocks ({blocks.length})</h3>
<ul className="model-summary-list">
{blocks.map(b => (
<li key={b.id}>
<ReviewRow status={b.reviewStatus}>
<button
type="button"
className="model-summary-row"
onClick={() => pushFrom(parentIndex, { kind: "block", id: b.id })}
>
<span className="model-summary-kind">{b.kind}</span>
<span className="model-summary-label">{b.label}</span>
</button>
{b.linkedTermId ? (
<button
type="button"
className="model-summary-link"
title="Open concept detail"
onClick={() =>
pushFrom(parentIndex, { kind: "term", id: b.linkedTermId! })
}
>
concept
</button>
) : null}
<DecideButtons
status={b.reviewStatus}
onKeep={() => decideKeep("block", b.id)}
onDiscard={() => decideDiscard("block", b.id)}
/>
</ReviewRow>
</li>
))}
</ul>
</>
)}
{associations.length > 0 && (
<>
<h3 className="model-summary-h">Associations ({associations.length})</h3>
<ul className="model-summary-list">
{associations.map(a => (
<li key={a.id}>
<ReviewRow status={a.reviewStatus}>
<button
type="button"
className="model-summary-row"
onClick={() => pushFrom(parentIndex, { kind: "association", id: a.id })}
>
<span className="model-summary-kind">{a.kind}</span>
<span className="model-summary-label">
{labelOf(model, a.fromBlockId)} <em>{a.label || "→"}</em>{" "}
{labelOf(model, a.toBlockId)}
</span>
</button>
<DecideButtons
status={a.reviewStatus}
onKeep={() => decideKeep("association", a.id)}
onDiscard={() => decideDiscard("association", a.id)}
/>
</ReviewRow>
</li>
))}
</ul>
</>
)}
{constraints.length > 0 && (
<>
<h3 className="model-summary-h">Constraints ({constraints.length})</h3>
<ul className="model-summary-list">
{constraints.map(c => (
<li key={c.id}>
<ReviewRow status={c.reviewStatus}>
<button
type="button"
className="model-summary-row"
onClick={() => pushFrom(parentIndex, { kind: "constraint", id: c.id })}
>
<span className="model-summary-kind">constraint</span>
<span className="model-summary-label">
{c.label}
{c.expression ? <> <code>{c.expression}</code></> : null}
</span>
</button>
<DecideButtons
status={c.reviewStatus}
onKeep={() => decideKeep("constraint", c.id)}
onDiscard={() => decideDiscard("constraint", c.id)}
/>
</ReviewRow>
</li>
))}
</ul>
</>
)}
{requirements.length > 0 && (
<>
<h3 className="model-summary-h">Requirements ({requirements.length})</h3>
<ul className="model-summary-list">
{requirements.map(r => (
<li key={r.id}>
<ReviewRow status={r.reviewStatus}>
<button
type="button"
className="model-summary-row"
onClick={() => pushFrom(parentIndex, { kind: "requirement", id: r.id })}
>
<span className="model-summary-kind">{r.tag}</span>
<span className="model-summary-label">{r.text}</span>
</button>
<DecideButtons
status={r.reviewStatus}
onKeep={() => decideKeep("requirement", r.id)}
onDiscard={() => decideDiscard("requirement", r.id)}
/>
</ReviewRow>
</li>
))}
</ul>
</>
)}
</div>
);
}
function ReviewRow({
status,
children,
}: {
status?: ReviewStatus;
children: React.ReactNode;
}) {
return (
<div className={`model-summary-row-wrap model-status-${status ?? "accepted"}`}>
{status === "suggested" ? <StatusChip variant="suggested" /> : null}
{status === "deprecated" ? <StatusChip variant="deprecated" /> : null}
{children}
</div>
);
}
function DecideButtons({
status,
onKeep,
onDiscard,
}: {
status?: ReviewStatus;
onKeep: () => void;
onDiscard: () => void;
}) {
if (status !== "suggested" && status !== "deprecated") return null;
return (
<span className="model-decide-row">
<button
type="button"
className="term-decide term-decide-keep"
onClick={e => {
e.stopPropagation();
onKeep();
}}
title={
status === "suggested" ? "Accept this analyzer suggestion" : "Pin this element despite the analyzer dropping it"
}
>
Keep
</button>
<button
type="button"
className="term-decide term-decide-discard"
onClick={e => {
e.stopPropagation();
onDiscard();
}}
title={status === "suggested" ? "Reject this suggestion" : "Remove this element"}
>
Discard
</button>
</span>
);
}
function labelOf(model: SysMLModel, id: string): string {
return model.blocks.find(b => b.id === id)?.label ?? id;
}

View File

@@ -0,0 +1,212 @@
// Requirements pane — list of extracted requirements with traceability +
// unsupported flags. Click a requirement's traced block → focus that block
// in Model. Re-running Analyze MERGES; new and deprecated requirements
// surface for the user to keep or discard.
"use client";
import { useMemo } from "react";
import { PaneFrame } from "../PaneFrame";
import { PaneEmpty } from "../PaneEmpty";
import { PaneDrawer } from "../PaneDrawer";
import { StatusChip } from "../StatusChip";
import { useAnalysis, type ClientRequirement } from "../../../lib/workspace/analysisStore";
import { useOpenPanes } from "../../../lib/workspace/openPanesStore";
import { useModelStore } from "../../../lib/sync/ModelStore";
interface RequirementsPaneProps {
index: number;
onClose: () => void;
}
export function RequirementsPane({ index, onClose }: RequirementsPaneProps) {
const { requirements, analyze, inFlight } = useAnalysis();
const { pushFrom } = useOpenPanes();
const { model } = useModelStore();
const running = inFlight.has("requirements") || inFlight.has("all");
const kept = useMemo(
() => requirements.filter(r => r.status === "accepted"),
[requirements]
);
const pending = useMemo(
() => requirements.filter(r => r.status !== "accepted"),
[requirements]
);
const unsupportedCount = kept.filter(r => r.unsupported).length;
const labelOf = (id: string) => model.blocks.find(b => b.id === id)?.label ?? id;
const subtitle =
requirements.length === 0
? "no requirements yet"
: pending.length > 0
? `${kept.length} kept · ${pending.length} pending`
: unsupportedCount > 0
? `${kept.length} total · ${unsupportedCount} unsupported`
: `${kept.length} total · all traced`;
const right = (
<div className="pane-controls">
<button
type="button"
className="pane-action"
onClick={() => void analyze("requirements")}
disabled={running}
>
{running ? "Analyzing…" : "Analyze"}
</button>
</div>
);
const renderRow = (r: ClientRequirement) => (
<RequirementItem
key={r.id}
req={r}
labelOf={labelOf}
onOpenSelf={() => pushFrom(index, { kind: "requirement", id: r.id })}
onJumpToBlock={id => pushFrom(index, { kind: "block", id })}
onJumpToTerm={id => pushFrom(index, { kind: "term", id })}
/>
);
return (
<PaneFrame title="Requirements" subtitle={subtitle} right={right} onClose={onClose}>
<div className="pane-drawer-stack">
<div className="pane-drawer-main">
{kept.length === 0 && pending.length === 0 ? (
<PaneEmpty
title="No requirements yet"
hint={
<>
Requirements are extracted from sentences in your prose that say the system
<em> must / should / needs to</em>. Run <strong>Analyze</strong> to scan.
</>
}
action={{
label: running ? "Analyzing…" : "Run Analyze →",
onClick: () => void analyze("requirements"),
disabled: running,
}}
/>
) : kept.length === 0 ? (
<PaneEmpty
title="Nothing kept yet"
hint="Review the pending requirements below."
/>
) : (
<ul className="req-list">{kept.map(renderRow)}</ul>
)}
</div>
{pending.length > 0 ? (
<PaneDrawer
title="Pending review"
count={pending.length}
tone="pending"
defaultOpen
>
<ul className="req-list">{pending.map(renderRow)}</ul>
</PaneDrawer>
) : null}
</div>
</PaneFrame>
);
}
interface RequirementItemProps {
req: ClientRequirement;
labelOf: (id: string) => string;
onOpenSelf: () => void;
onJumpToBlock: (id: string) => void;
onJumpToTerm: (id: string) => void;
}
function RequirementItem({ req, labelOf, onOpenSelf, onJumpToBlock, onJumpToTerm }: RequirementItemProps) {
const { decideRequirement } = useAnalysis();
return (
<li
className={`req-row req-status-${req.status} ${req.unsupported ? "req-row-unsupported" : ""}`}
onClick={onOpenSelf}
role="button"
tabIndex={0}
onKeyDown={e => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenSelf();
}
}}
>
<div className="req-row-head">
<span className="req-tag">{req.tag}</span>
<span className="req-row-meta">
<ReqStatusBadge status={req.status} />
{req.unsupported ? <StatusChip variant="warn" label="unsupported" /> : null}
</span>
</div>
<div className="req-text">{req.text}</div>
{req.linkedTermId ? (
<button
type="button"
className="req-term-link"
onClick={e => {
e.stopPropagation();
onJumpToTerm(req.linkedTermId!);
}}
title="Open concept detail"
>
concept
</button>
) : null}
{req.tracedToIds.length > 0 ? (
<div className="req-traced" onClick={e => e.stopPropagation()}>
Traced to:{" "}
{req.tracedToIds.map((id, i) => (
<button
key={id}
type="button"
className="req-traced-link"
onClick={e => {
e.stopPropagation();
onJumpToBlock(id);
}}
>
{labelOf(id)}
{i < req.tracedToIds.length - 1 ? ", " : ""}
</button>
))}
</div>
) : null}
{req.status !== "accepted" ? (
<div className="term-decision-row" onClick={e => e.stopPropagation()}>
<button
type="button"
className="term-decide term-decide-keep"
onClick={e => {
e.stopPropagation();
void decideRequirement(req.id, "keep");
}}
>
Keep
</button>
<button
type="button"
className="term-decide term-decide-discard"
onClick={e => {
e.stopPropagation();
void decideRequirement(req.id, "discard");
}}
>
Discard
</button>
</div>
) : null}
</li>
);
}
function ReqStatusBadge({ status }: { status: ClientRequirement["status"] }) {
if (status === "suggested") return <StatusChip variant="suggested" />;
if (status === "deprecated") return <StatusChip variant="deprecated" />;
return null;
}

View File

@@ -0,0 +1,22 @@
// Pinned text-editor pane. Always present in the workspace; not closable.
"use client";
import { TextCanvas } from "../../text-canvas/TextCanvas";
import { PaneFrame } from "../PaneFrame";
import type { FixtureData } from "../../../lib/fixtures/aristotle";
interface TextCanvasPaneProps {
data: FixtureData;
projectId: string;
}
export function TextCanvasPane({ data, projectId }: TextCanvasPaneProps) {
return (
<PaneFrame title="Narrative" subtitle="Your document" closable={false}>
<div className="canvas-scroll">
<TextCanvas data={data} projectId={projectId} />
</div>
</PaneFrame>
);
}

View File

@@ -0,0 +1,37 @@
// Shared context for cross-pane focus state and projectId plumbing.
//
// `focusBlockId` drives diagram selection / chip hover styling. The richer
// term/finding/etc detail navigation now lives in the column-stack
// (openPanesStore), so this context is intentionally minimal.
"use client";
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
interface PaneContextValue {
projectId: string;
focusBlockId: string | null;
setFocusBlockId: (id: string | null) => void;
}
const Ctx = createContext<PaneContextValue | null>(null);
interface ProviderProps {
projectId: string;
children: ReactNode;
}
export function EditorPaneContextProvider({ projectId, children }: ProviderProps) {
const [focusBlockId, setFocusBlockId] = useState<string | null>(null);
const value = useMemo<PaneContextValue>(
() => ({ projectId, focusBlockId, setFocusBlockId }),
[projectId, focusBlockId]
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useEditorPaneContext(): PaneContextValue {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useEditorPaneContext must be inside <EditorPaneContextProvider>");
return ctx;
}