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

@@ -1,22 +0,0 @@
// Header above each canvas (Narrative / Model). Title + subtitle on the left, optional actions on the right.
// Ported from docs/design-source/socrata/project/editor-shell.jsx (CanvasHeader).
import type { ReactNode } from "react";
interface CanvasHeaderProps {
title: string;
subtitle: string;
right?: ReactNode;
}
export function CanvasHeader({ title, subtitle, right }: CanvasHeaderProps) {
return (
<div className="canvas-header">
<div>
<div className="canvas-title">{title}</div>
<div className="canvas-sub">{subtitle}</div>
</div>
<div className="canvas-header-right">{right}</div>
</div>
);
}

View File

@@ -1,38 +1,36 @@
// The dual-canvas workspace shell.
// M5: state lives in ModelStoreProvider; both canvases consume the canonical
// SysMLModel and emit ModelOps back through useApply().
// The pivoted workspace shell.
//
// Layout: TopBar → [LeftSidebar | MainWorkspace] → StatusBar.
// LeftSidebar lists all sections (Outline + Structure + Findings).
// MainWorkspace is a flex of panes: a pinned text editor + zero-or-more
// section panes (Model, Taxonomy, Glossary, Requirements, Findings) that
// the user opens from the sidebar.
"use client";
import { useMemo, useState, Suspense } from "react";
import { useMemo, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { TopBar } from "./TopBar";
import { LeftRail } from "./LeftRail";
import { CanvasHeader } from "./CanvasHeader";
import { LeftSidebar } from "./LeftSidebar";
import { StatusBar } from "./StatusBar";
import { IssuesPanel } from "./IssuesPanel";
import { FindingsPanel } from "./FindingsPanel";
import { TextCanvas } from "../text-canvas/TextCanvas";
import { DiagramCanvas, type DiagramVariant } from "../diagram-canvas/DiagramCanvas";
import { SocratesDock, type Density, type SocratesPresence } from "../socrates/SocratesDock";
import type { MarkupStyle } from "../text-canvas/Chip";
import { MainWorkspace } from "./MainWorkspace";
import type { FixtureData } from "../../lib/fixtures/aristotle";
import { fromFixture } from "../../lib/sysml/fromFixture";
import { applyBreaks, BREAKS, type BreakName } from "../../lib/sysml/breaks";
import { ModelStoreProvider, useModelStore } from "../../lib/sync/ModelStore";
import { ModelStoreProvider } from "../../lib/sync/ModelStore";
import { OpenPanesProvider } from "../../lib/workspace/openPanesStore";
import { AnalysisStoreProvider } from "../../lib/workspace/analysisStore";
import { EditorPaneContextProvider } from "./sections/paneContext";
import type { Density } from "../../lib/workspace/types";
interface EditorShellProps {
data: FixtureData;
/** Server-loaded initial model + version (M5.9). When omitted, falls back to
/** Server-loaded initial model + version. When omitted, falls back to
* deriving from `data` (legacy fixture path; useful for tests). */
initialModel?: import("../../lib/sysml/model").SysMLModel;
initialVersion?: number;
/** When set, apply() POSTs to /api/projects/[projectId]/apply. */
projectId?: string;
density?: Density;
markupStyle?: MarkupStyle;
diagramStyle?: DiagramVariant;
presence?: SocratesPresence;
}
export function EditorShell(props: EditorShellProps) {
@@ -43,16 +41,7 @@ export function EditorShell(props: EditorShellProps) {
);
}
function EditorShellInner({
data,
initialModel,
initialVersion,
projectId,
density = "comfortable",
markupStyle = "color",
diagramStyle = "softened",
presence = "default",
}: EditorShellProps) {
function EditorShellInner({ data, initialModel, initialVersion, projectId, density = "comfortable" }: EditorShellProps) {
const searchParams = useSearchParams();
const breaks = useMemo<BreakName[]>(() => {
@@ -61,112 +50,29 @@ function EditorShellInner({
return raw.split(",").map(s => s.trim()).filter((s): s is BreakName => s in BREAKS);
}, [searchParams]);
// Prefer server-loaded model; fall back to fixture derivation for legacy
// callers without DB persistence wiring.
const startingModel = useMemo(() => {
const base = initialModel ?? fromFixture(data);
return breaks.length > 0 ? applyBreaks(base, breaks) : base;
}, [initialModel, data, breaks]);
const id = projectId ?? "aristotle";
return (
<ModelStoreProvider
initialModel={startingModel}
initialVersion={initialVersion ?? 1}
projectId={projectId}
>
<ShellBody
data={data}
projectId={projectId}
density={density}
markupStyle={markupStyle}
diagramStyle={diagramStyle}
presence={presence}
breaks={breaks}
/>
<ModelStoreProvider initialModel={startingModel} initialVersion={initialVersion ?? 1} projectId={id}>
<OpenPanesProvider projectId={id}>
<AnalysisStoreProvider projectId={id}>
<EditorPaneContextProvider projectId={id}>
<div className={`shell shell-density-${density}`}>
<TopBar data={data} />
<div className="shell-body shell-body-pivot">
<LeftSidebar />
<MainWorkspace data={data} projectId={id} />
</div>
<StatusBar data={data} />
</div>
</EditorPaneContextProvider>
</AnalysisStoreProvider>
</OpenPanesProvider>
</ModelStoreProvider>
);
}
interface ShellBodyProps {
data: FixtureData;
projectId?: string;
density: Density;
markupStyle: MarkupStyle;
diagramStyle: DiagramVariant;
presence: SocratesPresence;
breaks: BreakName[];
}
function ShellBody({ data, projectId, density, markupStyle, diagramStyle, presence, breaks }: ShellBodyProps) {
const [focusBlockId, setFocusBlockId] = useState<string | null>(null);
const { model, version, issues, issuesByElement } = useModelStore();
const stats = `SysML · ${model.blocks.length} blocks · ${model.associations.length} associations · ${model.constraints.length} constraints`;
const subtitle = breaks.length > 0 ? `${stats} · breaks active: ${breaks.join(", ")}` : stats;
return (
<div className={`shell shell-density-${density} shell-presence-${presence}`}>
<TopBar data={data} />
<div className="shell-body">
<SocratesDock projectId={projectId ?? "aristotle"} presence={presence} density={density} />
<LeftRail
data={data}
focusBlockId={focusBlockId}
setFocusBlockId={setFocusBlockId}
issuesByElement={issuesByElement}
/>
<main className="canvases">
<section className="canvas canvas-text">
<CanvasHeader title="Narrative" subtitle="Markup-augmented prose · synced to model" />
<div className="canvas-scroll">
<TextCanvas
data={data}
density={density}
markupStyle={markupStyle}
focusBlockId={focusBlockId}
setFocusBlockId={setFocusBlockId}
/>
</div>
</section>
<div className="canvas-divider" />
<section className="canvas canvas-diagram">
<CanvasHeader
title="Model"
subtitle={subtitle}
right={
<div className="canvas-actions">
<span className="canvas-mode-pill">Fit</span>
<span className="canvas-mode-pill canvas-mode-active">100%</span>
<span className="canvas-mode-pill">Layout</span>
</div>
}
/>
<div className="canvas-scroll canvas-scroll-diagram">
<DiagramCanvas
data={data}
density={density}
variant={diagramStyle}
focusBlockId={focusBlockId}
onSelect={setFocusBlockId}
issuesByElement={issuesByElement}
/>
</div>
</section>
</main>
</div>
<StatusBar data={data} />
<IssuesPanel issues={issues} onSelectAnchor={id => setFocusBlockId(id)} />
<FindingsPanel
projectId={projectId ?? "aristotle"}
modelVersion={version}
onSelectAnchor={id => setFocusBlockId(id)}
/>
</div>
);
}

View File

@@ -1,187 +0,0 @@
// Findings panel — lists detected assumptions / risks / inconsistencies.
// Lives in the bottom-right corner above the IssuesPanel; collapsible;
// has a "detect" button that triggers a fresh background pass.
//
// Items are clickable: clicking focuses the first linked element across
// the rail + diagram (using the same setFocusBlockId path as the rail).
"use client";
import { useCallback, useEffect, useState } from "react";
export type FindingKind = "assumption" | "risk" | "inconsistency";
export interface FindingDTO {
id: string;
kind: FindingKind;
text: string;
linkedElementIds: string[];
confidence: number;
severity?: "low" | "medium" | "high" | null;
validationCode?: string | null;
modelVersion: number;
provider?: string | null;
llmModel?: string | null;
}
interface FindingsPanelProps {
projectId: string;
/** Re-fetch whenever this changes. Pass the model version so we know when to refresh. */
modelVersion: number;
onSelectAnchor?: (elementId: string) => void;
}
const KIND_GLYPH: Record<FindingKind, string> = {
assumption: "●",
risk: "▲",
inconsistency: "!",
};
const KIND_LABEL: Record<FindingKind, string> = {
assumption: "asm",
risk: "risk",
inconsistency: "inc",
};
export function FindingsPanel({ projectId, modelVersion, onSelectAnchor }: FindingsPanelProps) {
const [findings, setFindings] = useState<FindingDTO[] | null>(null);
const [collapsed, setCollapsed] = useState(false);
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [meta, setMeta] = useState<{ provider?: string; model?: string; durationMs?: number; modelVersion?: number } | null>(null);
// Initial load
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/findings`);
if (!res.ok) throw new Error(String(res.status));
const data = (await res.json()) as { findings: FindingDTO[] };
if (cancelled) return;
setFindings(data.findings);
if (data.findings.length > 0) {
const first = data.findings[0]!;
setMeta({ provider: first.provider ?? undefined, model: first.llmModel ?? undefined, modelVersion: first.modelVersion });
}
} catch (err) {
if (!cancelled) setError((err as Error).message);
}
})();
return () => { cancelled = true; };
}, [projectId]);
const detect = useCallback(async () => {
if (running) return;
setRunning(true);
setError(null);
try {
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/findings`, {
method: "POST",
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `HTTP ${res.status}`);
}
const data = (await res.json()) as {
findings: FindingDTO[];
meta: { provider: string; model: string; durationMs: number; strippedRefs: number; droppedFindings: number };
};
setFindings(data.findings);
setMeta({
provider: data.meta.provider,
model: data.meta.model,
durationMs: data.meta.durationMs,
modelVersion,
});
} catch (err) {
setError((err as Error).message);
} finally {
setRunning(false);
}
}, [projectId, modelVersion, running]);
const counts = {
assumption: findings?.filter(f => f.kind === "assumption").length ?? 0,
risk: findings?.filter(f => f.kind === "risk").length ?? 0,
inconsistency: findings?.filter(f => f.kind === "inconsistency").length ?? 0,
};
const total = counts.assumption + counts.risk + counts.inconsistency;
const stale = meta?.modelVersion !== undefined && meta.modelVersion !== modelVersion;
return (
<div className={`findings-panel ${collapsed ? "findings-panel-collapsed" : ""}`}>
<button
type="button"
className="findings-panel-head"
onClick={() => setCollapsed(c => !c)}
>
<span className="findings-panel-counts">
{counts.assumption > 0 && <span className="findings-count findings-count-asm"> {counts.assumption}</span>}
{counts.risk > 0 && <span className="findings-count findings-count-risk"> {counts.risk}</span>}
{counts.inconsistency > 0 && <span className="findings-count findings-count-inc">! {counts.inconsistency}</span>}
{total === 0 && findings !== null && <span className="findings-count findings-count-none">no findings</span>}
{findings === null && <span className="findings-count findings-count-none">loading</span>}
</span>
<span className="findings-panel-title">
{findings === null ? "findings" : total === 0 ? "no detected findings" : `${total} detected finding${total === 1 ? "" : "s"}`}
{stale && <span className="findings-panel-stale" title="Model has changed since these were detected">· stale</span>}
</span>
<span className="findings-panel-caret">{collapsed ? "▴" : "▾"}</span>
</button>
{!collapsed && (
<>
<div className="findings-panel-actions">
<button
type="button"
className="findings-panel-btn"
onClick={detect}
disabled={running}
>
{running ? "detecting…" : findings && findings.length > 0 ? "re-detect" : "detect"}
</button>
{meta?.durationMs && (
<span className="findings-panel-meta">
{meta.provider} · {meta.model?.split("/").pop()} · {(meta.durationMs / 1000).toFixed(1)}s
</span>
)}
</div>
{error && <div className="findings-panel-error"> {error}</div>}
{findings && findings.length > 0 && (
<ul className="findings-panel-list">
{(["inconsistency", "risk", "assumption"] as const).flatMap(kind =>
findings.filter(f => f.kind === kind).map(f => (
<li
key={f.id}
className={`findings-item findings-item-${f.kind}`}
onClick={() => {
if (f.linkedElementIds.length > 0 && onSelectAnchor) {
onSelectAnchor(f.linkedElementIds[0]!);
}
}}
style={{ cursor: f.linkedElementIds.length > 0 ? "pointer" : "default" }}
>
<span className={`findings-item-glyph findings-item-glyph-${f.kind}`}>{KIND_GLYPH[f.kind]}</span>
<span className="findings-item-tag">{KIND_LABEL[f.kind]}</span>
{f.severity && <span className={`findings-item-sev findings-item-sev-${f.severity}`}>{f.severity}</span>}
{f.validationCode && <span className="findings-item-code">{f.validationCode}</span>}
<span className="findings-item-text">{f.text}</span>
<span className="findings-item-conf">{f.confidence.toFixed(2)}</span>
{f.linkedElementIds.length > 0 && (
<span className="findings-item-refs" title={f.linkedElementIds.join(", ")}>
[{f.linkedElementIds.slice(0, 3).join(", ")}{f.linkedElementIds.length > 3 ? "…" : ""}]
</span>
)}
</li>
))
)}
</ul>
)}
</>
)}
</div>
);
}

View File

@@ -1,73 +0,0 @@
// Floating panel listing current validation issues — shown next to the
// status bar. Click an issue to focus its anchored element across the rail
// + diagram (via setFocusBlockId). Lets the user verify the validator is
// actually firing on the broken-fixture demos.
"use client";
import { useState } from "react";
import type { ValidationIssue } from "../../lib/sysml/validate";
interface IssuesPanelProps {
issues: ValidationIssue[];
onSelectAnchor?: (id: string) => void;
}
const SEV_GLYPH: Record<ValidationIssue["severity"], string> = {
error: "●",
warning: "▲",
soft: "·",
};
export function IssuesPanel({ issues, onSelectAnchor }: IssuesPanelProps) {
const [collapsed, setCollapsed] = useState(false);
const grouped = {
error: issues.filter(i => i.severity === "error"),
warning: issues.filter(i => i.severity === "warning"),
soft: issues.filter(i => i.severity === "soft"),
};
if (issues.length === 0) {
return (
<div className={`issues-panel issues-panel-clean ${collapsed ? "issues-panel-collapsed" : ""}`}>
<button className="issues-panel-head" onClick={() => setCollapsed(c => !c)} type="button">
<span className="issues-panel-clean-glyph"></span>
<span className="issues-panel-title">Model is clean</span>
</button>
</div>
);
}
return (
<div className={`issues-panel ${collapsed ? "issues-panel-collapsed" : ""}`}>
<button className="issues-panel-head" onClick={() => setCollapsed(c => !c)} type="button">
<span className="issues-panel-counts">
{grouped.error.length > 0 && <span className="issues-count issues-count-error"> {grouped.error.length}</span>}
{grouped.warning.length > 0 && <span className="issues-count issues-count-warning"> {grouped.warning.length}</span>}
{grouped.soft.length > 0 && <span className="issues-count issues-count-soft">· {grouped.soft.length}</span>}
</span>
<span className="issues-panel-title">{issues.length} validation issue{issues.length === 1 ? "" : "s"}</span>
<span className="issues-panel-caret">{collapsed ? "▴" : "▾"}</span>
</button>
{!collapsed && (
<ul className="issues-panel-list">
{(["error", "warning", "soft"] as const).flatMap(sev =>
grouped[sev].map((i, idx) => (
<li
key={`${sev}-${idx}`}
className={`issues-item issues-item-${i.severity}`}
onClick={() => onSelectAnchor?.(i.anchor.kind === "property" ? i.anchor.blockId : (i.anchor as { id: string }).id ?? "")}
>
<span className="issues-item-sev">{SEV_GLYPH[i.severity]}</span>
<span className="issues-item-code">{i.code}</span>
<span className="issues-item-msg">{i.message}</span>
</li>
))
)}
</ul>
)}
</div>
);
}

View File

@@ -1,201 +0,0 @@
// Outline / Model / Requirements sections, each independently collapsible.
// The whole rail can also collapse to a 36px vertical strip.
//
// M5: Model + Requirements sections read from the canonical SysMLModel via
// useModelStore() so renames in either canvas reflect here immediately.
// Outline section is still narrative-derived and uses the fixture (M6 will
// migrate it to the live ProseMirror outline).
"use client";
import { useState } from "react";
import type { FixtureData } from "../../lib/fixtures/aristotle";
import type { ValidationIssue, Severity } from "../../lib/sysml/validate";
import { useModel } from "../../lib/sync/ModelStore";
interface LeftRailProps {
data: FixtureData;
focusBlockId: string | null;
setFocusBlockId: (id: string | null) => void;
/** Map keyed by element key (block id, `req:<id>`, `assoc:<id>`, …) → issues. */
issuesByElement?: Map<string, ValidationIssue[]>;
}
function maxSeverityForKey(map: Map<string, ValidationIssue[]> | undefined, key: string): Severity | null {
const items = map?.get(key);
if (!items || items.length === 0) return null;
if (items.some(i => i.severity === "error")) return "error";
if (items.some(i => i.severity === "warning")) return "warning";
return "soft";
}
function IssueDot({ severity, title }: { severity: Severity | null; title?: string }) {
if (!severity) return null;
return <span className={`rail-issue-dot rail-issue-dot-${severity}`} title={title} />;
}
export function LeftRail({ focusBlockId, setFocusBlockId, issuesByElement }: LeftRailProps) {
const [collapsed, setCollapsed] = useState(false);
const [open, setOpen] = useState({ outline: true, model: true, requirements: true });
const toggle = (k: keyof typeof open) => setOpen(s => ({ ...s, [k]: !s[k] }));
const model = useModel();
if (collapsed) {
return (
<nav className="leftrail leftrail-collapsed">
<button
className="rail-collapse-btn"
onClick={() => setCollapsed(false)}
title="Expand rail"
type="button"
>
</button>
<div className="rail-collapsed-stack">
<span className="rail-collapsed-tag" title="Outline">OUT</span>
<span className="rail-collapsed-tag" title={`Model · ${model.blocks.length + model.constraints.length} elements`}>MOD</span>
<span className="rail-collapsed-tag" title="Requirements">REQ</span>
</div>
</nav>
);
}
// Combine blocks + constraints in the Model section, sorted by kind for a
// predictable order: system → block → actor → constraint.
const kindRank: Record<string, number> = { system: 0, block: 1, actor: 2, constraint: 3 };
const modelEntries: Array<{
id: string;
label: string;
kind: "block" | "actor" | "constraint" | "system";
propertyCount: number;
}> = [
...model.blocks.map(b => ({
id: b.id,
label: b.label,
kind: b.kind,
propertyCount: b.properties.length,
})),
...model.constraints.map(c => ({
id: c.id,
label: c.label,
kind: "constraint" as const,
propertyCount: 0,
})),
].sort((a, b) => {
const r = (kindRank[a.kind] ?? 99) - (kindRank[b.kind] ?? 99);
return r !== 0 ? r : a.label.localeCompare(b.label);
});
return (
<nav className="leftrail">
<div className="rail-section">
<button className="rail-section-head" onClick={() => toggle("outline")} type="button">
<span className={`rail-caret ${open.outline ? "rail-caret-open" : ""}`}></span>
<span className="rail-label">Outline</span>
</button>
{open.outline && (
<ul className="rail-list">
<li className="rail-item rail-item-active">
<span className="rail-item-text">Problem framing</span>
</li>
<li className="rail-item">
<span className="rail-item-text">Constraints</span>
<span className="rail-count rail-count-req">3</span>
</li>
<li className="rail-item">
<span className="rail-item-text">Why now</span>
</li>
<li className="rail-item rail-item-muted">
<span className="rail-item-text">Hypotheses</span>
<span className="rail-count rail-count-asm">3</span>
</li>
<li className="rail-item rail-item-muted">
<span className="rail-item-text">Open questions</span>
<span className="rail-count rail-count-q">5</span>
</li>
<li className="rail-item rail-item-muted">
<span className="rail-item-text">Risks</span>
<span className="rail-count rail-count-risk">2</span>
</li>
</ul>
)}
</div>
<div className="rail-section">
<button className="rail-section-head" onClick={() => toggle("model")} type="button">
<span className={`rail-caret ${open.model ? "rail-caret-open" : ""}`}></span>
<span className="rail-label">Model</span>
</button>
{open.model && (
<ul className="rail-list rail-blocks">
{modelEntries.map(b => {
const sev = maxSeverityForKey(issuesByElement, b.id);
const tooltip = issuesByElement?.get(b.id)?.map(i => `${i.code}: ${i.message}`).join("\n");
return (
<li
key={b.id}
className={`rail-block rail-${b.kind} ${focusBlockId === b.id ? "rail-block-active" : ""}`}
onMouseEnter={() => setFocusBlockId(b.id)}
onMouseLeave={() => setFocusBlockId(null)}
onClick={() => setFocusBlockId(b.id)}
style={{ cursor: "pointer" }}
>
<span className="rail-block-glyph">
{b.kind === "constraint" ? "{}" : b.kind === "actor" ? "◐" : b.kind === "system" ? "◎" : "▢"}
</span>
<span className="rail-block-label">{b.label}</span>
<IssueDot severity={sev} title={tooltip} />
{b.kind !== "constraint" && (
<span
className="rail-block-count"
title={`${b.propertyCount} ${b.propertyCount === 1 ? "property" : "properties"}`}
>
<span className="rail-block-count-glyph">·</span>
{b.propertyCount}
</span>
)}
</li>
);
})}
</ul>
)}
</div>
<div className="rail-section">
<button className="rail-section-head" onClick={() => toggle("requirements")} type="button">
<span className={`rail-caret ${open.requirements ? "rail-caret-open" : ""}`}></span>
<span className="rail-label">Requirements</span>
</button>
{open.requirements && (
<ul className="rail-list">
{model.requirements.map(r => {
const key = `req:${r.id}`;
const sev = maxSeverityForKey(issuesByElement, key);
const tooltip = issuesByElement?.get(key)?.map(i => `${i.code}: ${i.message}`).join("\n");
const traced = r.relations.some(rel => rel.kind === "satisfy");
return (
<li key={r.id} className="rail-req">
<span className="req-tag">{r.tag}</span>
{sev ? (
<IssueDot severity={sev} title={tooltip} />
) : (
<span className={`req-status ${traced ? "req-traced" : "req-untraced"}`} />
)}
</li>
);
})}
</ul>
)}
</div>
<button
className="rail-collapse-btn rail-collapse-btn-bottom"
onClick={() => setCollapsed(true)}
title="Collapse rail"
type="button"
>
</button>
</nav>
);
}

View File

@@ -0,0 +1,163 @@
// Single sidebar for the pivoted shell. Lists every section grouped under
// Structure / Findings. Each row shows: title, count, last-run timestamp,
// per-section [Analyze] button, and is itself clickable to toggle the matching
// pane open in the main workspace.
"use client";
import { useOpenPanes, SECTION_TITLES, type SectionPaneId } from "../../lib/workspace/openPanesStore";
import { useAnalysis } from "../../lib/workspace/analysisStore";
import { Spinner } from "./Spinner";
import { useState } from "react";
const STRUCTURE: SectionPaneId[] = ["concepts", "model", "requirements"];
const FINDINGS: SectionPaneId[] = ["assumptions", "risks", "inconsistencies"];
export function LeftSidebar() {
const [collapsed, setCollapsed] = useState(false);
if (collapsed) {
return (
<aside className="leftsidebar leftsidebar-collapsed" aria-label="Sections">
<button className="leftsidebar-collapse-toggle" type="button" onClick={() => setCollapsed(false)} aria-label="Expand sidebar">
</button>
</aside>
);
}
return (
<aside className="leftsidebar" aria-label="Sections">
<div className="leftsidebar-head">
<span className="leftsidebar-title">Workspace</span>
<button className="leftsidebar-collapse-toggle" type="button" onClick={() => setCollapsed(true)} aria-label="Collapse sidebar">
</button>
</div>
<Group label="Structure" sections={STRUCTURE} />
<Group label="Findings" sections={FINDINGS} />
</aside>
);
}
function Group({ label, sections }: { label: string; sections: SectionPaneId[] }) {
return (
<div className="leftsidebar-group">
<div className="leftsidebar-group-label">{label}</div>
<ul className="leftsidebar-list">
{sections.map(s => (
<SectionRow key={s} id={s} />
))}
</ul>
</div>
);
}
function SectionRow({ id }: { id: SectionPaneId }) {
const { isSectionOpen, toggleSection } = useOpenPanes();
const { terms, requirements, findings, inFlight, runs, analyze } = useAnalysis();
const open = isSectionOpen(id);
const count = sectionCount(id, { terms, requirements, findings });
const pending = pendingCount(id, { terms, requirements, findings });
// Spinner is on whenever:
// - the user just clicked Analyze on this section (inFlight)
// - the user clicked Analyze All
// - the server has a "running" AnalysisRun for this section (catches
// background runs like seed-finalize and the first-paint auto-analyze
// that the local inFlight set doesn't know about). The analysisStore
// polls `runs` every 3s while anything is running, so this stays
// truthy until the run actually finishes.
const running =
inFlight.has(id) ||
inFlight.has("all") ||
(id === "concepts" && (inFlight.has("taxonomy") || inFlight.has("glossary"))) ||
runs[id]?.status === "running" ||
runs.all?.status === "running" ||
(id === "concepts" &&
(runs.taxonomy?.status === "running" || runs.glossary?.status === "running"));
// ONE number per row: pending if there's review work, otherwise total.
// Runs the user the most useful signal first ("how much attention does
// this section need?") and avoids the duplicate-count effect when
// pending === total because nothing is accepted yet.
const showPending = pending > 0;
return (
<li className={`leftsidebar-row ${open ? "leftsidebar-row-open" : ""}`}>
<button
type="button"
className="leftsidebar-row-main"
onClick={() => toggleSection(id)}
aria-expanded={open}
aria-label={
showPending
? `${SECTION_TITLES[id]}${pending} pending review`
: `${SECTION_TITLES[id]} (${count})`
}
>
<span className="leftsidebar-row-title">{SECTION_TITLES[id]}</span>
{showPending ? (
<span className="leftsidebar-pending-chip" title="Pending review">
{pending}
</span>
) : (
<span className="leftsidebar-row-count">{count}</span>
)}
</button>
<button
type="button"
className={`leftsidebar-analyze ${running ? "leftsidebar-analyze-running" : ""}`}
title={running ? "Analyzing…" : `Re-run ${SECTION_TITLES[id]} analysis`}
onClick={e => {
e.stopPropagation();
void analyze(id);
}}
disabled={running}
aria-label={running ? "Analyzing" : `Re-run ${SECTION_TITLES[id]} analysis`}
>
{running ? <Spinner /> : <span aria-hidden="true"></span>}
</button>
</li>
);
}
function sectionCount(
id: SectionPaneId,
data: {
terms: { id: string; status: string }[];
requirements: { id: string; status: string }[];
findings: { kind: string; status: string }[];
}
): number {
if (id === "concepts") return data.terms.length;
if (id === "model") return 0; // Model count is shown inside the pane itself.
if (id === "requirements") return data.requirements.length;
return data.findings.filter(f => f.kind === idToFindingKind(id)).length;
}
/** Count items the user hasn't reviewed yet (suggested + deprecated). The
* most useful navigation cue we can put in the sidebar — tells the user
* where attention is needed at a glance. */
function pendingCount(
id: SectionPaneId,
data: {
terms: { status: string }[];
requirements: { status: string }[];
findings: { kind: string; status: string }[];
}
): number {
const isPending = (s: string) => s === "suggested" || s === "deprecated";
if (id === "concepts") return data.terms.filter(t => isPending(t.status)).length;
if (id === "requirements") return data.requirements.filter(r => isPending(r.status)).length;
if (id === "model") return 0; // Model pending count needs ModelStore — surfaced in the pane header.
return data.findings.filter(f => f.kind === idToFindingKind(id) && isPending(f.status)).length;
}
function idToFindingKind(id: SectionPaneId): string {
if (id === "assumptions") return "assumption";
if (id === "risks") return "risk";
if (id === "inconsistencies") return "inconsistency";
return "";
}

View File

@@ -0,0 +1,177 @@
// Main column-stack workspace (Finder-style).
//
// Layout: [column 0 (section)] [column 1] … [column N] [text editor (pinned)].
// The text-editor pane is always rendered last and absorbs the remaining
// horizontal space; the column stack on its left is horizontally scrollable
// so deep chains stay reachable on narrow viewports.
//
// Each column has a drag-to-resize handle on its right edge. Each column also
// gets `index` as a prop so its inner click handlers can call `pushFrom(index, …)`
// to drill in (truncating any deeper columns first).
"use client";
import { useCallback, useEffect, useRef } from "react";
import { TextCanvasPane } from "./sections/TextCanvasPane";
import { ModelPane } from "./sections/ModelPane";
import { ConceptsPane } from "./sections/ConceptsPane";
import { RequirementsPane } from "./sections/RequirementsPane";
import { FindingsPane } from "./sections/FindingsPane";
import {
TermColumn,
BlockColumn,
AssociationColumn,
ConstraintColumn,
RequirementColumn,
FindingColumn,
} from "./columns/EntityColumns";
import {
useOpenPanes,
PANE_MIN_WIDTH,
PANE_MAX_WIDTH,
columnKey,
type Column,
} from "../../lib/workspace/openPanesStore";
import type { FixtureData } from "../../lib/fixtures/aristotle";
interface MainWorkspaceProps {
data: FixtureData;
projectId: string;
}
export function MainWorkspace({ data, projectId }: MainWorkspaceProps) {
const { columns, closeFrom, widthFor, setWidth } = useOpenPanes();
return (
<main className="main-workspace">
<div className="main-workspace-columns">
{columns.map((col, index) => (
<ResizableColumn
key={columnKey(col)}
column={col}
index={index}
projectId={projectId}
width={widthFor(col)}
onResize={w => setWidth(col, w)}
onClose={() => closeFrom(index)}
/>
))}
</div>
<TextCanvasPane data={data} projectId={projectId} />
</main>
);
}
interface ResizableColumnProps {
column: Column;
index: number;
projectId: string;
width: number;
onResize: (w: number) => void;
onClose: () => void;
}
function ResizableColumn({ column, index, projectId, width, onResize, onClose }: ResizableColumnProps) {
const startXRef = useRef(0);
const startWidthRef = useRef(0);
const draggingRef = useRef(false);
const onPointerMove = useCallback(
(e: PointerEvent) => {
if (!draggingRef.current) return;
const dx = e.clientX - startXRef.current;
const next = Math.max(PANE_MIN_WIDTH, Math.min(PANE_MAX_WIDTH, startWidthRef.current + dx));
onResize(next);
},
[onResize]
);
const onPointerUp = useCallback(() => {
if (!draggingRef.current) return;
draggingRef.current = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
}, []);
useEffect(() => {
const move = (e: PointerEvent) => onPointerMove(e);
const up = () => onPointerUp();
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
return () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
};
}, [onPointerMove, onPointerUp]);
const onHandleDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
draggingRef.current = true;
startXRef.current = e.clientX;
startWidthRef.current = width;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
},
[width]
);
return (
<div className="pane-with-handle" style={{ width, minWidth: width, maxWidth: width }}>
<ColumnRenderer column={column} index={index} projectId={projectId} onClose={onClose} />
<div
className="pane-resize-handle"
onPointerDown={onHandleDown}
role="separator"
aria-orientation="vertical"
aria-label={`Resize column ${index + 1}`}
/>
</div>
);
}
interface ColumnRendererProps {
column: Column;
index: number;
projectId: string;
onClose: () => void;
}
function ColumnRenderer({ column, index, projectId, onClose }: ColumnRendererProps) {
switch (column.kind) {
case "section":
switch (column.id) {
case "concepts":
return <ConceptsPane index={index} onClose={onClose} />;
case "model":
return <ModelPane index={index} onClose={onClose} />;
case "requirements":
return <RequirementsPane index={index} onClose={onClose} />;
case "assumptions":
return <FindingsPane kind="assumption" index={index} projectId={projectId} onClose={onClose} />;
case "risks":
return <FindingsPane kind="risk" index={index} projectId={projectId} onClose={onClose} />;
case "inconsistencies":
return <FindingsPane kind="inconsistency" index={index} projectId={projectId} onClose={onClose} />;
}
case "term":
return <TermColumn termId={column.id} index={index} onClose={onClose} />;
case "block":
return <BlockColumn blockId={column.id} index={index} onClose={onClose} />;
case "association":
return <AssociationColumn associationId={column.id} index={index} onClose={onClose} />;
case "constraint":
return <ConstraintColumn constraintId={column.id} index={index} onClose={onClose} />;
case "requirement":
return <RequirementColumn requirementId={column.id} index={index} onClose={onClose} />;
case "finding":
return (
<FindingColumn
findingId={column.id}
index={index}
projectId={projectId}
onClose={onClose}
/>
);
}
}

View File

@@ -0,0 +1,97 @@
// Shared pane-header controls. Two primitives:
//
// - <PaneViewTabs/> — segmented control for VIEW MODES that are mutually
// exclusive (Tree | AZ, Diagram | Summary). Always exactly one active.
//
// - <PaneFilterChip/> — toggleable filter chip with an optional count pip.
// Use for filters like "Pending" that overlay the current view mode
// rather than replacing it. Disabled when count=0.
//
// The intent is to stop mixing "view mode" and "filter" in the same
// pane-tabs cluster, which currently makes "Pending" feel like a third
// view mode instead of a filter you can stack on Tree or AZ.
"use client";
import type { ReactNode } from "react";
// ─── PaneViewTabs ────────────────────────────────────────────────────────
export interface PaneViewTab<V extends string> {
value: V;
label: ReactNode;
/** Optional tooltip. */
title?: string;
}
interface PaneViewTabsProps<V extends string> {
value: V;
onChange: (v: V) => void;
tabs: PaneViewTab<V>[];
/** ARIA label for the segmented group. */
ariaLabel?: string;
}
export function PaneViewTabs<V extends string>({
value,
onChange,
tabs,
ariaLabel = "View mode",
}: PaneViewTabsProps<V>) {
return (
<div className="pane-view-tabs" role="tablist" aria-label={ariaLabel}>
{tabs.map(t => (
<button
key={t.value}
type="button"
role="tab"
aria-selected={value === t.value}
className={`pane-view-tab ${value === t.value ? "pane-view-tab-active" : ""}`}
onClick={() => onChange(t.value)}
title={t.title}
>
{t.label}
</button>
))}
</div>
);
}
// ─── PaneFilterChip ──────────────────────────────────────────────────────
interface PaneFilterChipProps {
/** Whether the filter is currently applied. */
active: boolean;
onToggle: () => void;
label: string;
/** Optional count to render as a pip (e.g. number of pending items).
* When 0 (or undefined), the chip is disabled. */
count?: number;
title?: string;
}
export function PaneFilterChip({
active,
onToggle,
label,
count,
title,
}: PaneFilterChipProps) {
const hasCount = typeof count === "number" && count > 0;
const disabled = typeof count === "number" && count === 0;
return (
<button
type="button"
className={`pane-filter-chip ${active ? "pane-filter-chip-active" : ""} ${
hasCount ? "pane-filter-chip-attention" : ""
}`}
onClick={onToggle}
title={title}
disabled={disabled}
aria-pressed={active}
>
<span className="pane-filter-chip-label">{label}</span>
{hasCount ? <span className="pane-filter-chip-pip">{count}</span> : null}
</button>
);
}

View File

@@ -0,0 +1,75 @@
// PaneDrawer — collapsible group inside a list pane. Used to group rows by
// review state without losing screen real estate when a group is empty or
// the user doesn't want to see it.
//
// Stack layout: Kept list (always-on, fills) → [Pending drawer] → [Discarded
// drawer]. Drawers always render their header (so the count is visible at a
// glance); their body collapses on toggle.
//
// Tone is one of:
// "default" — neutral, used for kept-only views
// "pending" — accent strip + count chip in accent
// "muted" — dim styling, used for the Discarded drawer
//
// The drawer is non-sticky on purpose: the user can scroll past kept items
// to reach pending, and the kept count never gets crushed by an over-tall
// drawer. CSS lives in styles/base.css under .pane-drawer.
"use client";
import { useState, type ReactNode } from "react";
interface PaneDrawerProps {
title: string;
/** Optional count rendered as a pip in the header. */
count?: number;
/** Visual tone. */
tone?: "default" | "pending" | "muted";
/** Initial expanded state. */
defaultOpen?: boolean;
/** When the drawer would be empty AND `hideWhenEmpty` is true, the entire
* drawer (header included) is omitted. Useful for the Discarded drawer
* where 0 items means "nothing dismissed yet, don't even show me the
* header." */
hideWhenEmpty?: boolean;
/** Override action shown to the right of the title (e.g. "Restore all"). */
rightAction?: ReactNode;
children: ReactNode;
}
export function PaneDrawer({
title,
count,
tone = "default",
defaultOpen = false,
hideWhenEmpty = false,
rightAction,
children,
}: PaneDrawerProps) {
const [open, setOpen] = useState(defaultOpen);
const isEmpty = typeof count === "number" && count === 0;
if (hideWhenEmpty && isEmpty) return null;
return (
<section className={`pane-drawer pane-drawer-${tone} ${open ? "pane-drawer-open" : ""}`}>
<header className="pane-drawer-head">
<button
type="button"
className="pane-drawer-toggle"
onClick={() => setOpen(o => !o)}
aria-expanded={open}
>
<span className="pane-drawer-caret" aria-hidden="true">
{open ? "▾" : "▸"}
</span>
<span className="pane-drawer-title">{title}</span>
{typeof count === "number" ? (
<span className="pane-drawer-count">{count}</span>
) : null}
</button>
{rightAction ? <div className="pane-drawer-action">{rightAction}</div> : null}
</header>
{open ? <div className="pane-drawer-body">{children}</div> : null}
</section>
);
}

View File

@@ -0,0 +1,43 @@
// PaneEmpty — unified empty-state for any list pane or detail column.
//
// Replaces the scatter of `.pane-empty` blocks across panes with a
// consistent structure: title (one short line), an optional descriptive
// hint, and an optional primary action.
//
// The visual treatment is deliberately quiet so empty states don't shout.
"use client";
import type { ReactNode } from "react";
interface PaneEmptyProps {
/** Short headline (≤8 words). */
title: string;
/** Optional secondary copy. Accepts ReactNode so callers can embed a
* link or a kbd tag inline. */
hint?: ReactNode;
/** Optional primary action button (e.g. "Run Analyze →"). */
action?: { label: string; onClick: () => void; disabled?: boolean };
/** Optional decorative icon — kept tiny, no SVG dependency. */
icon?: ReactNode;
}
export function PaneEmpty({ title, hint, action, icon }: PaneEmptyProps) {
return (
<div className="pane-empty-v2" role="status">
{icon ? <div className="pane-empty-icon">{icon}</div> : null}
<div className="pane-empty-title">{title}</div>
{hint ? <div className="pane-empty-hint">{hint}</div> : null}
{action ? (
<button
type="button"
className="pane-empty-action"
onClick={action.onClick}
disabled={action.disabled}
>
{action.label}
</button>
) : null}
</div>
);
}

View File

@@ -0,0 +1,37 @@
// Wrapper for any main-area pane: header (title + subtitle + close) + body.
// The text-editor pane passes closable={false} so it has no close button.
"use client";
import { ReactNode } from "react";
interface PaneFrameProps {
title: string;
subtitle?: string;
right?: ReactNode;
closable?: boolean;
onClose?: () => void;
children: ReactNode;
}
export function PaneFrame({ title, subtitle, right, closable = true, onClose, children }: PaneFrameProps) {
return (
<section className="pane">
<header className="pane-header">
<div className="pane-titles">
<span className="pane-title">{title}</span>
{subtitle ? <span className="pane-subtitle">{subtitle}</span> : null}
</div>
<div className="pane-right">
{right}
{closable ? (
<button className="pane-close" type="button" onClick={onClose} aria-label="Close pane">
×
</button>
) : null}
</div>
</header>
<div className="pane-body">{children}</div>
</section>
);
}

View File

@@ -0,0 +1,391 @@
// PromoteToolbar — converts a taxonomy term into a formal SysML element
// without leaving the document. Lives inside TermDetail.
//
// The four buttons map onto the existing ModelOp alphabet so undo/redo and
// SSE sync work for free; each op carries `linkedTermId = term.id` so the
// new formalism is anchored back to the concept that named it.
//
// All forms are inline + dismissive: an open form replaces the toolbar, and
// the user can cancel back to the toolbar without a destructive action.
"use client";
import { useState } from "react";
import { useModelStore } from "../../lib/sync/ModelStore";
import type { ClientTerm } from "../../lib/workspace/analysisStore";
import { useOpenPanes } from "../../lib/workspace/openPanesStore";
import { useEditorPaneContext } from "./sections/paneContext";
import {
addBlock,
addAssociation,
addConstraint,
addRequirement,
tempId,
} from "../../lib/sync/ops";
interface Props {
term: ClientTerm;
/** Called after a successful promote, so the parent can flash + scroll. */
onPromoted?: (kind: "block" | "association" | "constraint" | "requirement") => void;
}
type FormKind = null | "association" | "constraint" | "requirement";
export function PromoteToolbar({ term, onPromoted }: Props) {
const { model, apply } = useModelStore();
const [open, setOpen] = useState<FormKind>(null);
const { openSection } = useOpenPanes();
const { setFocusBlockId } = useEditorPaneContext();
// Persist the term-to-block link server-side after a successful promote so
// the next analyze pass sees the link.
const persistTermLink = async (blockId: string) => {
try {
const projectId = window.location.pathname.split("/").pop() ?? "";
if (!projectId) return;
await fetch(`/api/projects/${encodeURIComponent(projectId)}/term-link`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ termId: term.id, blockId }),
});
} catch (err) {
console.error("[PromoteToolbar] term-link persistence failed:", err);
}
};
const promoteBlock = () => {
const id = tempId("blk");
apply([
addBlock(
{
id,
label: term.label,
kind: "block",
stereotypes: ["block"],
properties: [],
linkedTermId: term.id,
},
id
),
]);
void persistTermLink(id);
openSection("model");
setFocusBlockId(id);
onPromoted?.("block");
};
if (open === "association") {
return (
<AssociationForm
term={term}
blocks={model.blocks}
onCancel={() => setOpen(null)}
onSubmit={(fromId, toId, label) => {
const id = tempId("a");
apply([
addAssociation(
{ id, fromBlockId: fromId, toBlockId: toId, label, kind: "association", linkedTermId: term.id },
id
),
]);
setOpen(null);
openSection("model");
onPromoted?.("association");
}}
/>
);
}
if (open === "constraint") {
return (
<ConstraintForm
term={term}
blocks={model.blocks}
onCancel={() => setOpen(null)}
onSubmit={(label, expression, appliesTo) => {
const id = tempId("c");
apply([
addConstraint(
{ id, label, expression, appliesTo, linkedTermId: term.id },
id
),
]);
setOpen(null);
openSection("model");
onPromoted?.("constraint");
}}
/>
);
}
if (open === "requirement") {
return (
<RequirementForm
term={term}
blocks={model.blocks}
onCancel={() => setOpen(null)}
onSubmit={(tag, text, satisfiedBy) => {
const id = tempId("r");
apply([
addRequirement(
{
id,
tag,
text,
relations: satisfiedBy.map(blockId => ({ kind: "satisfy" as const, blockId })),
linkedTermId: term.id,
},
id
),
]);
setOpen(null);
openSection("requirements");
onPromoted?.("requirement");
}}
/>
);
}
// No block linked yet → recommend "Make Block" as the primary action.
const hasBlock = !!term.linkedBlockId;
return (
<div className="promote-toolbar">
<button
type="button"
className={`promote-btn ${!hasBlock ? "promote-btn-primary" : ""}`}
onClick={promoteBlock}
title={hasBlock ? "Already a block" : "Create a SysML block from this concept"}
disabled={hasBlock}
>
+ Block
</button>
<button type="button" className="promote-btn" onClick={() => setOpen("association")}>
+ Association
</button>
<button type="button" className="promote-btn" onClick={() => setOpen("constraint")}>
+ Constraint
</button>
<button type="button" className="promote-btn" onClick={() => setOpen("requirement")}>
+ Requirement
</button>
</div>
);
}
// ─── Inline forms ───────────────────────────────────────────────────────
interface AssocFormProps {
term: ClientTerm;
blocks: { id: string; label: string }[];
onCancel: () => void;
onSubmit: (fromId: string, toId: string, label: string) => void;
}
function AssociationForm({ term, blocks, onCancel, onSubmit }: AssocFormProps) {
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [label, setLabel] = useState(term.label);
const canSubmit = from && to && from !== to;
return (
<form
className="promote-form"
onSubmit={e => {
e.preventDefault();
if (!canSubmit) return;
onSubmit(from, to, label.trim() || term.label);
}}
>
<FormHead title="New Association" subtitle={`anchored to "${term.label}"`} onCancel={onCancel} />
<BlockSelect label="From" value={from} onChange={setFrom} blocks={blocks} />
<BlockSelect label="To" value={to} onChange={setTo} blocks={blocks} excludeId={from} />
<FieldRow label="Label">
<input
className="promote-input"
value={label}
onChange={e => setLabel(e.target.value)}
placeholder="verb phrase"
/>
</FieldRow>
<FormActions canSubmit={!!canSubmit} />
</form>
);
}
interface ConstraintFormProps {
term: ClientTerm;
blocks: { id: string; label: string }[];
onCancel: () => void;
onSubmit: (label: string, expression: string, appliesTo: string[]) => void;
}
function ConstraintForm({ term, blocks, onCancel, onSubmit }: ConstraintFormProps) {
const [label, setLabel] = useState(term.label);
const [expression, setExpression] = useState("");
const [appliesTo, setAppliesTo] = useState<string[]>([]);
const canSubmit = !!label.trim();
return (
<form
className="promote-form"
onSubmit={e => {
e.preventDefault();
if (!canSubmit) return;
onSubmit(label.trim(), expression.trim(), appliesTo);
}}
>
<FormHead title="New Constraint" subtitle={`anchored to "${term.label}"`} onCancel={onCancel} />
<FieldRow label="Label">
<input className="promote-input" value={label} onChange={e => setLabel(e.target.value)} />
</FieldRow>
<FieldRow label="Expression">
<input
className="promote-input"
value={expression}
onChange={e => setExpression(e.target.value)}
placeholder="e.g. sessions_per_day <= 3"
/>
</FieldRow>
<BlockMultiSelect label="Applies to" value={appliesTo} onChange={setAppliesTo} blocks={blocks} />
<FormActions canSubmit={canSubmit} />
</form>
);
}
interface RequirementFormProps {
term: ClientTerm;
blocks: { id: string; label: string }[];
onCancel: () => void;
onSubmit: (tag: string, text: string, satisfiedBy: string[]) => void;
}
function RequirementForm({ term, blocks, onCancel, onSubmit }: RequirementFormProps) {
const [tag, setTag] = useState("REQ-001");
const [text, setText] = useState("");
const [satisfiedBy, setSatisfiedBy] = useState<string[]>(
term.linkedBlockId ? [term.linkedBlockId] : []
);
const canSubmit = !!tag.trim() && !!text.trim();
return (
<form
className="promote-form"
onSubmit={e => {
e.preventDefault();
if (!canSubmit) return;
onSubmit(tag.trim(), text.trim(), satisfiedBy);
}}
>
<FormHead title="New Requirement" subtitle={`anchored to "${term.label}"`} onCancel={onCancel} />
<FieldRow label="Tag">
<input className="promote-input" value={tag} onChange={e => setTag(e.target.value)} />
</FieldRow>
<FieldRow label="Text">
<textarea
className="promote-textarea"
value={text}
onChange={e => setText(e.target.value)}
placeholder="The system must …"
rows={2}
/>
</FieldRow>
<BlockMultiSelect label="Satisfied by" value={satisfiedBy} onChange={setSatisfiedBy} blocks={blocks} />
<FormActions canSubmit={canSubmit} />
</form>
);
}
// ─── Form atoms ─────────────────────────────────────────────────────────
function FormHead({ title, subtitle, onCancel }: { title: string; subtitle: string; onCancel: () => void }) {
return (
<div className="promote-form-head">
<div>
<div className="promote-form-title">{title}</div>
<div className="promote-form-subtitle">{subtitle}</div>
</div>
<button type="button" className="promote-form-cancel" onClick={onCancel} aria-label="Cancel">
</button>
</div>
);
}
function FieldRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="promote-field">
<span className="promote-field-label">{label}</span>
{children}
</label>
);
}
function FormActions({ canSubmit }: { canSubmit: boolean }) {
return (
<div className="promote-form-actions">
<button type="submit" className="promote-btn promote-btn-primary" disabled={!canSubmit}>
Create
</button>
</div>
);
}
interface BlockSelectProps {
label: string;
value: string;
onChange: (v: string) => void;
blocks: { id: string; label: string }[];
excludeId?: string;
}
function BlockSelect({ label, value, onChange, blocks, excludeId }: BlockSelectProps) {
const options = blocks.filter(b => b.id !== excludeId);
return (
<FieldRow label={label}>
<select className="promote-input" value={value} onChange={e => onChange(e.target.value)}>
<option value=""> pick block </option>
{options.map(b => (
<option key={b.id} value={b.id}>
{b.label}
</option>
))}
</select>
</FieldRow>
);
}
interface BlockMultiSelectProps {
label: string;
value: string[];
onChange: (v: string[]) => void;
blocks: { id: string; label: string }[];
}
function BlockMultiSelect({ label, value, onChange, blocks }: BlockMultiSelectProps) {
if (blocks.length === 0) {
return (
<FieldRow label={label}>
<span className="promote-empty">No blocks yet promote one first.</span>
</FieldRow>
);
}
return (
<FieldRow label={label}>
<div className="promote-checkbox-list">
{blocks.map(b => {
const checked = value.includes(b.id);
return (
<label key={b.id} className="promote-checkbox">
<input
type="checkbox"
checked={checked}
onChange={() =>
onChange(checked ? value.filter(x => x !== b.id) : [...value, b.id])
}
/>
<span>{b.label}</span>
</label>
);
})}
</div>
</FieldRow>
);
}

View File

@@ -0,0 +1,74 @@
// Spinner — a small, polished SVG ring used everywhere the UI needs to
// signal "working." Two stacked circles: a faint full ring (track) and a
// shorter accent arc on top, with the arc rotating around the center.
// The dasharray + animation give a smooth motion that doesn't depend on
// CSS-only border tricks (which look chunky at small sizes).
"use client";
interface SpinnerProps {
/** Pixel size; defaults to 14 (matches sidebar button glyph metrics). */
size?: number;
/** Accent stroke color; defaults to `currentColor` so the spinner picks
* up the surrounding text color. */
color?: string;
/** Track stroke color; defaults to a faint border tone. */
trackColor?: string;
/** Stroke width in SVG units (viewBox 24×24). 2.5 reads cleanly at 14px. */
strokeWidth?: number;
className?: string;
title?: string;
}
export function Spinner({
size = 14,
color = "currentColor",
trackColor = "rgba(0,0,0,0.14)",
strokeWidth = 2.5,
className,
title,
}: SpinnerProps) {
return (
<svg
className={`spinner ${className ?? ""}`}
width={size}
height={size}
viewBox="0 0 24 24"
role={title ? "img" : "presentation"}
aria-label={title}
aria-hidden={title ? undefined : true}
>
{/* Track */}
<circle
cx="12"
cy="12"
r="9"
fill="none"
stroke={trackColor}
strokeWidth={strokeWidth}
/>
{/* Animated arc — strokeDasharray ~= 25% of the circumference (2π·9 ≈ 56.5);
* we use 14 56.5-14 = 14 / 42.5 to draw a 14-unit arc. */}
<circle
cx="12"
cy="12"
r="9"
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray="14 42.5"
transform="rotate(-90 12 12)"
>
<animateTransform
attributeName="transform"
type="rotate"
from="-90 12 12"
to="270 12 12"
dur="0.85s"
repeatCount="indefinite"
/>
</circle>
</svg>
);
}

View File

@@ -0,0 +1,89 @@
// StatusChip — the single visual idiom for any "this thing has a state"
// signal in the workspace. Replaces the five ad-hoc chips that grew over
// time: term-badge-new / term-badge-deprecated / finding-sev / finding-code /
// req-flag / EntityColumns ReviewBadge.
//
// Variants are picked from a tiny vocabulary so the visual rhythm is
// consistent across surfaces: review state (suggested / accepted /
// deprecated / dismissed / resolved), severity (low / medium / high), or a
// validation code (S1, M2, X3, …) rendered as `code`.
//
// Usage:
// <StatusChip variant="suggested" /> // "NEW"
// <StatusChip variant="deprecated" /> // "DEPRECATED"
// <StatusChip variant="severity" value="high" />
// <StatusChip variant="code" value="X3" />
// <StatusChip variant="confidence" value={0.87} />
// <StatusChip variant="warn" label="unsupported" />
//
// CSS lives in styles/base.css under .status-chip; tone is one of:
// accent — review-pending / NEW / suggestion
// muted — neutral / deprecated / code
// warn — risky / high-severity / unsupported
// ok — confirmed / resolved
// info — informational / confidence
"use client";
export type StatusTone = "accent" | "muted" | "warn" | "ok" | "info";
export type StatusChipProps =
| { variant: "suggested"; title?: string }
| { variant: "accepted"; title?: string }
| { variant: "deprecated"; title?: string }
| { variant: "dismissed"; title?: string }
| { variant: "resolved"; title?: string }
| { variant: "severity"; value: "low" | "medium" | "high"; title?: string }
| { variant: "code"; value: string; title?: string }
| { variant: "confidence"; value: number; title?: string }
| { variant: "warn"; label: string; title?: string }
| { variant: "ok"; label: string; title?: string }
| { variant: "muted"; label: string; title?: string };
/** Single-axis style picker. Every visual decision flows through this fn so
* designers can tweak one place. */
function styleFor(props: StatusChipProps): { tone: StatusTone; label: string } {
switch (props.variant) {
case "suggested":
return { tone: "accent", label: "NEW" };
case "accepted":
return { tone: "ok", label: "KEPT" };
case "deprecated":
return { tone: "muted", label: "DEPRECATED" };
case "dismissed":
return { tone: "muted", label: "DISMISSED" };
case "resolved":
return { tone: "ok", label: "RESOLVED" };
case "severity":
return {
tone: props.value === "high" ? "warn" : props.value === "medium" ? "info" : "muted",
label: props.value.toUpperCase(),
};
case "code":
return { tone: "muted", label: props.value };
case "confidence":
return {
tone: props.value >= 0.75 ? "ok" : props.value >= 0.4 ? "info" : "muted",
label: `${Math.round(props.value * 100)}%`,
};
case "warn":
return { tone: "warn", label: props.label };
case "ok":
return { tone: "ok", label: props.label };
case "muted":
return { tone: "muted", label: props.label };
}
}
export function StatusChip(props: StatusChipProps) {
const { tone, label } = styleFor(props);
return (
<span
className={`status-chip status-chip-${tone}`}
title={props.title}
aria-label={props.title ?? label}
>
{label}
</span>
);
}

View File

@@ -1,33 +1,44 @@
// Top-level brand row: name + breadcrumbs on the left, sync pill + avatar on the right.
// Ported from docs/design-source/socrata/project/editor-shell.jsx (TopBar).
// Top-level brand row + Analyze All action (the pivot's primary global verb).
"use client";
import Link from "next/link";
import type { FixtureData } from "../../lib/fixtures/aristotle";
import { useAnalysis } from "../../lib/workspace/analysisStore";
interface TopBarProps {
data: FixtureData;
}
export function TopBar({ data }: TopBarProps) {
const { analyze, inFlight } = useAnalysis();
const running = inFlight.size > 0;
return (
<header className="topbar">
<div className="topbar-left">
<div className="brand">
<Link href="/" className="brand" title="Back to all projects" style={{ textDecoration: "none", color: "inherit" }}>
<span className="brand-name">Socrata</span>
</div>
</Link>
<div className="breadcrumbs">
<span className="bc-sep">/</span>
<span className="bc-item">{data.project.scope}</span>
<span className="bc-sep">/</span>
<span className="bc-item bc-active">{data.project.name}</span>
<span className="bc-branch">
<span className="bc-branch-glyph"></span> {data.project.branch}
</span>
</div>
</div>
<div className="topbar-right">
<div className="sync-pill">
<span className="sync-dot" /> model in sync · {data.project.lastSync}
</div>
<button
type="button"
className="topbar-analyze-all"
onClick={() => void analyze("all")}
disabled={running}
title="Run all analyses against the current document"
>
{inFlight.has("all") ? "Analyzing all…" : running ? "Analyzing…" : "Analyze All"}
</button>
<div className="topbar-divider" />
<Link href="/seed" className="topbar-new" title="Start a new idea">
+ new idea
</Link>
<div className="topbar-divider" />
<div className="avatar">MC</div>
</div>

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

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