Files
dtoro b55425cc68 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>
2026-05-01 00:12:06 +02:00

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;
}