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>
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
// 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;
|
|
}
|