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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
163
apps/web/components/editor/LeftSidebar.tsx
Normal file
163
apps/web/components/editor/LeftSidebar.tsx
Normal 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 "";
|
||||
}
|
||||
|
||||
177
apps/web/components/editor/MainWorkspace.tsx
Normal file
177
apps/web/components/editor/MainWorkspace.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
97
apps/web/components/editor/PaneControls.tsx
Normal file
97
apps/web/components/editor/PaneControls.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
// Shared pane-header controls. Two primitives:
|
||||
//
|
||||
// - <PaneViewTabs/> — segmented control for VIEW MODES that are mutually
|
||||
// exclusive (Tree | A–Z, 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 A–Z.
|
||||
|
||||
"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>
|
||||
);
|
||||
}
|
||||
75
apps/web/components/editor/PaneDrawer.tsx
Normal file
75
apps/web/components/editor/PaneDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
apps/web/components/editor/PaneEmpty.tsx
Normal file
43
apps/web/components/editor/PaneEmpty.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
apps/web/components/editor/PaneFrame.tsx
Normal file
37
apps/web/components/editor/PaneFrame.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
391
apps/web/components/editor/PromoteToolbar.tsx
Normal file
391
apps/web/components/editor/PromoteToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
74
apps/web/components/editor/Spinner.tsx
Normal file
74
apps/web/components/editor/Spinner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
apps/web/components/editor/StatusChip.tsx
Normal file
89
apps/web/components/editor/StatusChip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
994
apps/web/components/editor/columns/EntityColumns.tsx
Normal file
994
apps/web/components/editor/columns/EntityColumns.tsx
Normal file
@@ -0,0 +1,994 @@
|
||||
// Detail-column panes for the Finder-style stack.
|
||||
//
|
||||
// Each column takes its own subject id (termId, blockId, etc.) and renders
|
||||
// in the same `.pane` shell as the section panes, so resize / close / header
|
||||
// behavior is consistent across the stack.
|
||||
//
|
||||
// Click handlers inside these columns call `pushFrom(index, ...)` to drill
|
||||
// further (e.g. clicking a linked block on a term column pushes a block
|
||||
// column to its right). The `index` prop is the column's own position in
|
||||
// the stack — so child pushes can truncate everything to its right.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { PaneFrame } from "../PaneFrame";
|
||||
import { StatusChip } from "../StatusChip";
|
||||
import { useAnalysis, type ClientTerm } from "../../../lib/workspace/analysisStore";
|
||||
import { useModelStore } from "../../../lib/sync/ModelStore";
|
||||
import { useOpenPanes } from "../../../lib/workspace/openPanesStore";
|
||||
import { ContextualSocratesThread } from "../../socrates/ContextualSocratesThread";
|
||||
import { PromoteToolbar } from "../PromoteToolbar";
|
||||
import {
|
||||
decideElement,
|
||||
removeBlock,
|
||||
removeAssociation,
|
||||
removeConstraint,
|
||||
removeRequirement,
|
||||
type ReviewableElementKind,
|
||||
} from "../../../lib/sync/ops";
|
||||
import type { ReviewStatus, SysMLModel } from "../../../lib/sysml/model";
|
||||
|
||||
// ─── Term column ────────────────────────────────────────────────────────
|
||||
|
||||
interface TermColumnProps {
|
||||
termId: string;
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TermColumn({ termId, index, onClose }: TermColumnProps) {
|
||||
const { getTerm, terms, requirements, findings } = useAnalysis();
|
||||
const { model } = useModelStore();
|
||||
const { pushFrom } = useOpenPanes();
|
||||
|
||||
const term = getTerm(termId);
|
||||
if (!term) {
|
||||
return (
|
||||
<PaneFrame title="Concept" subtitle="not found" onClose={onClose}>
|
||||
<div className="pane-empty">This concept no longer exists.</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
const parents = buildParentChain(term, terms);
|
||||
const children = terms.filter(t => t.parentId === term.id);
|
||||
const linkedBlock = term.linkedBlockId
|
||||
? model.blocks.find(b => b.id === term.linkedBlockId) ?? null
|
||||
: null;
|
||||
const linkedAssociations = model.associations.filter(a => a.linkedTermId === term.id);
|
||||
const linkedConstraints = model.constraints.filter(c => c.linkedTermId === term.id);
|
||||
const linkedReqsFromModel = model.requirements.filter(r => r.linkedTermId === term.id);
|
||||
const linkedReqsFromAnalyze = requirements.filter(r => r.linkedTermId === term.id);
|
||||
const relatedFindings = linkedBlock
|
||||
? findings.filter(f => f.linkedElementIds.includes(linkedBlock.id))
|
||||
: findings.filter(f => f.linkedElementIds.includes(`term:${term.id}`));
|
||||
|
||||
const hasFormalism =
|
||||
!!linkedBlock ||
|
||||
linkedAssociations.length > 0 ||
|
||||
linkedConstraints.length > 0 ||
|
||||
linkedReqsFromModel.length > 0 ||
|
||||
linkedReqsFromAnalyze.length > 0;
|
||||
|
||||
return (
|
||||
<PaneFrame
|
||||
title={term.label}
|
||||
subtitle="concept"
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="column-body">
|
||||
<DefinitionSection term={term} />
|
||||
|
||||
{(parents.length > 0 || children.length > 0 || term.synonyms.length > 0) && (
|
||||
<Section label="Hierarchy">
|
||||
{parents.length > 0 && (
|
||||
<Row label="Parents">
|
||||
{parents.map((p, i) => (
|
||||
<span key={p.id}>
|
||||
{i > 0 ? " › " : null}
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-chip"
|
||||
onClick={() => pushFrom(index - 1, { kind: "term", id: p.id })}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
{children.length > 0 && (
|
||||
<Row label="Children">
|
||||
{children.map(c => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
className="termdetail-chip"
|
||||
onClick={() => pushFrom(index, { kind: "term", id: c.id })}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
{term.synonyms.length > 0 && (
|
||||
<Row label="Synonyms">
|
||||
{term.synonyms.map(s => (
|
||||
<span key={s} className="termdetail-synonym">
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section label="Promote to formal element">
|
||||
<PromoteToolbar term={term} />
|
||||
</Section>
|
||||
|
||||
<Section label="Formalisms">
|
||||
{!hasFormalism ? (
|
||||
<p className="termdetail-empty">
|
||||
Not yet formalized. Promote it above to make it a block, association, constraint, or requirement.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="termdetail-formalism-list">
|
||||
{linkedBlock && (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-formalism"
|
||||
onClick={() => pushFrom(index, { kind: "block", id: linkedBlock.id })}
|
||||
>
|
||||
<span className="termdetail-formalism-kind">{linkedBlock.kind}</span>
|
||||
<span className="termdetail-formalism-label">{linkedBlock.label}</span>
|
||||
<span className="termdetail-formalism-jump">→</span>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{linkedAssociations.map(a => (
|
||||
<li key={a.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-formalism"
|
||||
onClick={() => pushFrom(index, { kind: "association", id: a.id })}
|
||||
>
|
||||
<span className="termdetail-formalism-kind">{a.kind}</span>
|
||||
<span className="termdetail-formalism-label">
|
||||
{labelOf(model, a.fromBlockId)} <em>{a.label || "→"}</em>{" "}
|
||||
{labelOf(model, a.toBlockId)}
|
||||
</span>
|
||||
<span className="termdetail-formalism-jump">→</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{linkedConstraints.map(c => (
|
||||
<li key={c.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-formalism"
|
||||
onClick={() => pushFrom(index, { kind: "constraint", id: c.id })}
|
||||
>
|
||||
<span className="termdetail-formalism-kind">constraint</span>
|
||||
<span className="termdetail-formalism-label">
|
||||
{c.label}
|
||||
{c.expression ? <> — <code>{c.expression}</code></> : null}
|
||||
</span>
|
||||
<span className="termdetail-formalism-jump">→</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{linkedReqsFromModel.map(r => (
|
||||
<li key={`m-${r.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-formalism"
|
||||
onClick={() => pushFrom(index, { kind: "requirement", id: r.id })}
|
||||
>
|
||||
<span className="termdetail-formalism-kind">{r.tag}</span>
|
||||
<span className="termdetail-formalism-label">{r.text}</span>
|
||||
<span className="termdetail-formalism-jump">→</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{linkedReqsFromAnalyze
|
||||
.filter(r => !linkedReqsFromModel.some(m => m.tag === r.tag))
|
||||
.map(r => (
|
||||
<li key={`a-${r.id}`}>
|
||||
<div className="termdetail-formalism termdetail-formalism-static">
|
||||
<span className="termdetail-formalism-kind">{r.tag}</span>
|
||||
<span className="termdetail-formalism-label">{r.text}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{relatedFindings.length > 0 && (
|
||||
<Section label={`Findings (${relatedFindings.length})`}>
|
||||
<ul className="termdetail-findings">
|
||||
{relatedFindings.map(f => (
|
||||
<li key={f.id} className={`termdetail-finding termdetail-finding-${f.kind}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-finding-btn"
|
||||
onClick={() => pushFrom(index, { kind: "finding", id: f.id })}
|
||||
>
|
||||
<span className="termdetail-finding-kind">{f.kind}</span>
|
||||
<span className="termdetail-finding-text">{f.text}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Block column ───────────────────────────────────────────────────────
|
||||
|
||||
export function BlockColumn({
|
||||
blockId,
|
||||
index,
|
||||
onClose,
|
||||
}: {
|
||||
blockId: string;
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { model, apply } = useModelStore();
|
||||
const { findings } = useAnalysis();
|
||||
const { pushFrom } = useOpenPanes();
|
||||
const block = model.blocks.find(b => b.id === blockId);
|
||||
|
||||
if (!block) {
|
||||
return (
|
||||
<PaneFrame title="Block" subtitle="not found" onClose={onClose}>
|
||||
<div className="pane-empty">This block no longer exists.</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
const associations = model.associations.filter(
|
||||
a => a.fromBlockId === block.id || a.toBlockId === block.id
|
||||
);
|
||||
const requirements = model.requirements.filter(r =>
|
||||
r.relations.some(rel => rel.kind === "satisfy" && rel.blockId === block.id)
|
||||
);
|
||||
const relatedFindings = findings.filter(f => f.linkedElementIds.includes(block.id));
|
||||
|
||||
return (
|
||||
<PaneFrame
|
||||
title={block.label}
|
||||
subtitle={block.kind === "block" ? "block" : `block · ${block.kind}`}
|
||||
right={
|
||||
<ReviewBadge status={block.reviewStatus}>
|
||||
<DecideButtons
|
||||
kind="block"
|
||||
id={block.id}
|
||||
status={block.reviewStatus}
|
||||
onKeep={() => apply([decideElement({ kind: "block", id: block.id })])}
|
||||
onDiscard={() => apply([removeBlock(block.id)])}
|
||||
/>
|
||||
</ReviewBadge>
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="column-body">
|
||||
{block.linkedTermId && (
|
||||
<Section label="Concept">
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-chip"
|
||||
onClick={() => pushFrom(index, { kind: "term", id: block.linkedTermId! })}
|
||||
>
|
||||
↪ open concept
|
||||
</button>
|
||||
</Section>
|
||||
)}
|
||||
{block.properties.length > 0 && (
|
||||
<Section label="Properties">
|
||||
<ul className="block-prop-list">
|
||||
{block.properties.map(p => (
|
||||
<li key={p.id} className="block-prop">
|
||||
<span className="block-prop-name">{p.name}</span>
|
||||
<span className="block-prop-type">{p.type.kind}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
{associations.length > 0 && (
|
||||
<Section label="Associations">
|
||||
<ul className="termdetail-formalism-list">
|
||||
{associations.map(a => (
|
||||
<li key={a.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-formalism"
|
||||
onClick={() => pushFrom(index, { kind: "association", id: a.id })}
|
||||
>
|
||||
<span className="termdetail-formalism-kind">{a.kind}</span>
|
||||
<span className="termdetail-formalism-label">
|
||||
{labelOf(model, a.fromBlockId)} <em>{a.label || "→"}</em>{" "}
|
||||
{labelOf(model, a.toBlockId)}
|
||||
</span>
|
||||
<span className="termdetail-formalism-jump">→</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
{requirements.length > 0 && (
|
||||
<Section label="Requirements satisfied">
|
||||
<ul className="termdetail-formalism-list">
|
||||
{requirements.map(r => (
|
||||
<li key={r.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-formalism"
|
||||
onClick={() => pushFrom(index, { kind: "requirement", id: r.id })}
|
||||
>
|
||||
<span className="termdetail-formalism-kind">{r.tag}</span>
|
||||
<span className="termdetail-formalism-label">{r.text}</span>
|
||||
<span className="termdetail-formalism-jump">→</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
{relatedFindings.length > 0 && (
|
||||
<Section label={`Findings (${relatedFindings.length})`}>
|
||||
<ul className="termdetail-findings">
|
||||
{relatedFindings.map(f => (
|
||||
<li key={f.id} className={`termdetail-finding termdetail-finding-${f.kind}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-finding-btn"
|
||||
onClick={() => pushFrom(index, { kind: "finding", id: f.id })}
|
||||
>
|
||||
<span className="termdetail-finding-kind">{f.kind}</span>
|
||||
<span className="termdetail-finding-text">{f.text}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Association column ─────────────────────────────────────────────────
|
||||
|
||||
export function AssociationColumn({
|
||||
associationId,
|
||||
index,
|
||||
onClose,
|
||||
}: {
|
||||
associationId: string;
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { model, apply } = useModelStore();
|
||||
const { pushFrom } = useOpenPanes();
|
||||
const a = model.associations.find(x => x.id === associationId);
|
||||
if (!a) {
|
||||
return (
|
||||
<PaneFrame title="Association" subtitle="not found" onClose={onClose}>
|
||||
<div className="pane-empty">This association no longer exists.</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
const from = model.blocks.find(b => b.id === a.fromBlockId);
|
||||
const to = model.blocks.find(b => b.id === a.toBlockId);
|
||||
|
||||
return (
|
||||
<PaneFrame
|
||||
title={a.label || "Association"}
|
||||
subtitle={a.kind === "association" ? "association" : `association · ${a.kind}`}
|
||||
right={
|
||||
<ReviewBadge status={a.reviewStatus}>
|
||||
<DecideButtons
|
||||
kind="association"
|
||||
id={a.id}
|
||||
status={a.reviewStatus}
|
||||
onKeep={() => apply([decideElement({ kind: "association", id: a.id })])}
|
||||
onDiscard={() => apply([removeAssociation(a.id)])}
|
||||
/>
|
||||
</ReviewBadge>
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="column-body">
|
||||
<Section label="Endpoints">
|
||||
<Row label="From">
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-chip"
|
||||
onClick={() => from && pushFrom(index, { kind: "block", id: from.id })}
|
||||
disabled={!from}
|
||||
>
|
||||
{from?.label ?? a.fromBlockId}
|
||||
</button>
|
||||
</Row>
|
||||
<Row label="To">
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-chip"
|
||||
onClick={() => to && pushFrom(index, { kind: "block", id: to.id })}
|
||||
disabled={!to}
|
||||
>
|
||||
{to?.label ?? a.toBlockId}
|
||||
</button>
|
||||
</Row>
|
||||
</Section>
|
||||
{a.linkedTermId && (
|
||||
<Section label="Concept">
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-chip"
|
||||
onClick={() => pushFrom(index, { kind: "term", id: a.linkedTermId! })}
|
||||
>
|
||||
↪ open concept
|
||||
</button>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Constraint column ──────────────────────────────────────────────────
|
||||
|
||||
export function ConstraintColumn({
|
||||
constraintId,
|
||||
index,
|
||||
onClose,
|
||||
}: {
|
||||
constraintId: string;
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { model, apply } = useModelStore();
|
||||
const { pushFrom } = useOpenPanes();
|
||||
const c = model.constraints.find(x => x.id === constraintId);
|
||||
if (!c) {
|
||||
return (
|
||||
<PaneFrame title="Constraint" subtitle="not found" onClose={onClose}>
|
||||
<div className="pane-empty">This constraint no longer exists.</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PaneFrame
|
||||
title={c.label}
|
||||
subtitle="constraint"
|
||||
right={
|
||||
<ReviewBadge status={c.reviewStatus}>
|
||||
<DecideButtons
|
||||
kind="constraint"
|
||||
id={c.id}
|
||||
status={c.reviewStatus}
|
||||
onKeep={() => apply([decideElement({ kind: "constraint", id: c.id })])}
|
||||
onDiscard={() => apply([removeConstraint(c.id)])}
|
||||
/>
|
||||
</ReviewBadge>
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="column-body">
|
||||
{c.expression && (
|
||||
<Section label="Expression">
|
||||
<code className="constraint-expr">{c.expression}</code>
|
||||
</Section>
|
||||
)}
|
||||
{c.appliesTo.length > 0 && (
|
||||
<Section label="Applies to">
|
||||
<ul className="termdetail-formalism-list">
|
||||
{c.appliesTo.map(bid => {
|
||||
const b = model.blocks.find(x => x.id === bid);
|
||||
return (
|
||||
<li key={bid}>
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-formalism"
|
||||
onClick={() => pushFrom(index, { kind: "block", id: bid })}
|
||||
>
|
||||
<span className="termdetail-formalism-kind">block</span>
|
||||
<span className="termdetail-formalism-label">{b?.label ?? bid}</span>
|
||||
<span className="termdetail-formalism-jump">→</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
{c.linkedTermId && (
|
||||
<Section label="Concept">
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-chip"
|
||||
onClick={() => pushFrom(index, { kind: "term", id: c.linkedTermId! })}
|
||||
>
|
||||
↪ open concept
|
||||
</button>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Requirement column ─────────────────────────────────────────────────
|
||||
|
||||
export function RequirementColumn({
|
||||
requirementId,
|
||||
index,
|
||||
onClose,
|
||||
}: {
|
||||
requirementId: string;
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { model, apply } = useModelStore();
|
||||
const { requirements: analyzedReqs, getTerm, decideRequirement } = useAnalysis();
|
||||
const { pushFrom } = useOpenPanes();
|
||||
|
||||
// Match by id in either list.
|
||||
const modelReq = model.requirements.find(r => r.id === requirementId);
|
||||
const analyzedReq = analyzedReqs.find(r => r.id === requirementId);
|
||||
|
||||
if (!modelReq && !analyzedReq) {
|
||||
return (
|
||||
<PaneFrame title="Requirement" subtitle="not found" onClose={onClose}>
|
||||
<div className="pane-empty">This requirement no longer exists.</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
const tag = modelReq?.tag ?? analyzedReq?.tag ?? requirementId;
|
||||
const text = modelReq?.text ?? analyzedReq?.text ?? "";
|
||||
const linkedTermId = modelReq?.linkedTermId ?? analyzedReq?.linkedTermId ?? null;
|
||||
const tracedTo = modelReq?.relations
|
||||
.filter((r): r is { kind: "satisfy"; blockId: string } => r.kind === "satisfy")
|
||||
.map(r => r.blockId) ?? analyzedReq?.tracedToIds ?? [];
|
||||
const reviewStatus = modelReq?.reviewStatus;
|
||||
const analyzedStatus = analyzedReq?.status;
|
||||
|
||||
return (
|
||||
<PaneFrame
|
||||
title={tag}
|
||||
subtitle="requirement"
|
||||
right={
|
||||
modelReq ? (
|
||||
<ReviewBadge status={reviewStatus}>
|
||||
<DecideButtons
|
||||
kind="requirement"
|
||||
id={modelReq.id}
|
||||
status={reviewStatus}
|
||||
onKeep={() => apply([decideElement({ kind: "requirement", id: modelReq.id })])}
|
||||
onDiscard={() => apply([removeRequirement(modelReq.id)])}
|
||||
/>
|
||||
</ReviewBadge>
|
||||
) : analyzedReq && analyzedStatus !== "accepted" ? (
|
||||
<div className="term-decision-row">
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-keep"
|
||||
onClick={() => void decideRequirement(analyzedReq.id, "keep")}
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-discard"
|
||||
onClick={() => void decideRequirement(analyzedReq.id, "discard")}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="column-body">
|
||||
<Section label="Text">
|
||||
<p className="termdetail-def">{text}</p>
|
||||
</Section>
|
||||
{linkedTermId && (
|
||||
<Section label="Concept">
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-chip"
|
||||
onClick={() => pushFrom(index, { kind: "term", id: linkedTermId })}
|
||||
>
|
||||
{getTerm(linkedTermId)?.label ?? "↪ open concept"}
|
||||
</button>
|
||||
</Section>
|
||||
)}
|
||||
{tracedTo.length > 0 && (
|
||||
<Section label="Traced to">
|
||||
<ul className="termdetail-formalism-list">
|
||||
{tracedTo.map(bid => {
|
||||
const b = model.blocks.find(x => x.id === bid);
|
||||
return (
|
||||
<li key={bid}>
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-formalism"
|
||||
onClick={() => pushFrom(index, { kind: "block", id: bid })}
|
||||
>
|
||||
<span className="termdetail-formalism-kind">block</span>
|
||||
<span className="termdetail-formalism-label">{b?.label ?? bid}</span>
|
||||
<span className="termdetail-formalism-jump">→</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Finding column ─────────────────────────────────────────────────────
|
||||
|
||||
export function FindingColumn({
|
||||
findingId,
|
||||
index,
|
||||
projectId,
|
||||
onClose,
|
||||
}: {
|
||||
findingId: string;
|
||||
index: number;
|
||||
projectId: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { findings, decideFinding, refresh } = useAnalysis();
|
||||
const { pushFrom } = useOpenPanes();
|
||||
const f = findings.find(x => x.id === findingId);
|
||||
|
||||
if (!f) {
|
||||
return (
|
||||
<PaneFrame title="Finding" subtitle="not found" onClose={onClose}>
|
||||
<div className="pane-empty">This finding no longer exists.</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
const isPending = f.status === "suggested" || f.status === "deprecated";
|
||||
const termAnchors = f.linkedElementIds
|
||||
.filter(s => s.startsWith("term:"))
|
||||
.map(s => s.slice("term:".length));
|
||||
const elementAnchors = f.linkedElementIds.filter(s => !s.startsWith("term:"));
|
||||
|
||||
return (
|
||||
<PaneFrame
|
||||
title={titleCase(f.kind)}
|
||||
subtitle={
|
||||
f.severity
|
||||
? `${f.kind} · ${f.severity} · conf ${(f.confidence * 100).toFixed(0)}%`
|
||||
: `${f.kind} · conf ${(f.confidence * 100).toFixed(0)}%`
|
||||
}
|
||||
right={
|
||||
isPending ? (
|
||||
<div className="term-decision-row">
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-keep"
|
||||
onClick={() => void decideFinding(f.id, "keep")}
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-discard"
|
||||
onClick={() => void decideFinding(f.id, "discard")}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-resolve"
|
||||
onClick={() => void decideFinding(f.id, "resolve")}
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="column-body">
|
||||
<Section label={f.validationCode ? `${f.validationCode} — finding` : "Finding"}>
|
||||
<p className="termdetail-def">{f.text}</p>
|
||||
</Section>
|
||||
{(termAnchors.length > 0 || elementAnchors.length > 0) && (
|
||||
<Section label="Anchors">
|
||||
{termAnchors.map(tid => (
|
||||
<button
|
||||
key={tid}
|
||||
type="button"
|
||||
className="termdetail-chip"
|
||||
onClick={() => pushFrom(index, { kind: "term", id: tid })}
|
||||
>
|
||||
↪ concept
|
||||
</button>
|
||||
))}
|
||||
{elementAnchors.map(eid => (
|
||||
<button
|
||||
key={eid}
|
||||
type="button"
|
||||
className="termdetail-chip"
|
||||
onClick={() => pushFrom(index, { kind: "block", id: eid })}
|
||||
>
|
||||
↪ {eid}
|
||||
</button>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
<Section label="Discussion">
|
||||
<ContextualSocratesThread
|
||||
projectId={projectId}
|
||||
findingId={f.id}
|
||||
findingText={f.text}
|
||||
onResolved={() => {
|
||||
void refresh();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
</Section>
|
||||
</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shared helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Definition section for TermColumn. Read state by default; click anywhere
|
||||
* on the definition (or the empty placeholder) to enter edit mode. Save
|
||||
* with Cmd/Ctrl-Enter or by clicking Save; cancel with Esc. When the user
|
||||
* has authored the definition (definitionPinned), shows a "pinned" chip
|
||||
* in the header and a "Reset to AI suggestion" link in edit mode that
|
||||
* clears the text + the pin.
|
||||
*/
|
||||
function DefinitionSection({ term }: { term: ClientTerm }) {
|
||||
const { setTermDefinition } = useAnalysis();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(term.definition ?? "");
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
// When the user opens the editor, sync the draft with the latest text and
|
||||
// focus the textarea. Selecting nothing keeps the cursor at the end.
|
||||
useEffect(() => {
|
||||
if (!editing) return;
|
||||
setDraft(term.definition ?? "");
|
||||
requestAnimationFrame(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.setSelectionRange(el.value.length, el.value.length);
|
||||
});
|
||||
// term.id intentionally excluded from deps — re-running on every term
|
||||
// change would clobber an in-flight edit.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editing]);
|
||||
|
||||
const commit = () => {
|
||||
setEditing(false);
|
||||
const next = draft.trim();
|
||||
const cur = (term.definition ?? "").trim();
|
||||
if (next === cur) return;
|
||||
void setTermDefinition(term.id, next.length > 0 ? next : null);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setEditing(false);
|
||||
setDraft(term.definition ?? "");
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setEditing(false);
|
||||
setDraft("");
|
||||
void setTermDefinition(term.id, null);
|
||||
};
|
||||
|
||||
const headerRight = term.definitionPinned ? (
|
||||
<StatusChip variant="muted" label="pinned" title="You authored this definition. Future Analyze runs will leave it alone." />
|
||||
) : null;
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<Section label="Definition" right={headerRight}>
|
||||
<div className="termdetail-def-edit">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="termdetail-def-textarea"
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
}}
|
||||
placeholder="A short noun-phrase definition…"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="termdetail-def-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-def-btn termdetail-def-btn-primary"
|
||||
onMouseDown={e => {
|
||||
// Don't lose focus before commit — onBlur would fire and
|
||||
// commit a possibly-stale value race.
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-def-btn"
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{term.definitionPinned ? (
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-def-link"
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
reset();
|
||||
}}
|
||||
title="Clear the definition and let the next Analyze pass refill it from prose"
|
||||
>
|
||||
Reset to AI suggestion
|
||||
</button>
|
||||
) : null}
|
||||
<span className="termdetail-def-hint">⌘↵ to save · Esc to cancel</span>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Section label="Definition" right={headerRight}>
|
||||
{term.definition ? (
|
||||
<p
|
||||
className="termdetail-def termdetail-def-clickable"
|
||||
onClick={() => setEditing(true)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
setEditing(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{term.definition}
|
||||
</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="termdetail-empty termdetail-def-clickable termdetail-def-empty-btn"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
Click to write a definition
|
||||
</button>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ label, children, right }: { label: string; children: ReactNode; right?: ReactNode }) {
|
||||
return (
|
||||
<section className="termdetail-section">
|
||||
<header className="termdetail-section-head">
|
||||
<h3 className="termdetail-section-h">{label}</h3>
|
||||
{right ? <div className="termdetail-section-right">{right}</div> : null}
|
||||
</header>
|
||||
<div className="termdetail-section-body">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="termdetail-row">
|
||||
<span className="termdetail-row-label">{label}</span>
|
||||
<span className="termdetail-row-body">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewBadge({
|
||||
status,
|
||||
children,
|
||||
}: {
|
||||
status?: ReviewStatus;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
if (status === "suggested" || status === "deprecated") {
|
||||
return (
|
||||
<div className="column-review-row">
|
||||
<StatusChip variant={status} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function DecideButtons({
|
||||
status,
|
||||
onKeep,
|
||||
onDiscard,
|
||||
}: {
|
||||
kind: ReviewableElementKind;
|
||||
id: string;
|
||||
status?: ReviewStatus;
|
||||
onKeep: () => void;
|
||||
onDiscard: () => void;
|
||||
}) {
|
||||
if (status !== "suggested" && status !== "deprecated") return null;
|
||||
return (
|
||||
<span className="term-decision-row">
|
||||
<button type="button" className="term-decide term-decide-keep" onClick={onKeep}>
|
||||
Keep
|
||||
</button>
|
||||
<button type="button" className="term-decide term-decide-discard" onClick={onDiscard}>
|
||||
Discard
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function buildParentChain(
|
||||
term: { id: string; parentId: string | null },
|
||||
terms: Array<{ id: string; parentId: string | null; label: string }>
|
||||
): Array<{ id: string; label: string }> {
|
||||
const byId = new Map(terms.map(t => [t.id, t]));
|
||||
const chain: Array<{ id: string; label: string }> = [];
|
||||
let cur = term.parentId ? byId.get(term.parentId) : null;
|
||||
const seen = new Set<string>();
|
||||
while (cur && !seen.has(cur.id)) {
|
||||
seen.add(cur.id);
|
||||
chain.unshift({ id: cur.id, label: cur.label });
|
||||
cur = cur.parentId ? byId.get(cur.parentId) : null;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
function labelOf(model: SysMLModel, id: string): string {
|
||||
return model.blocks.find(b => b.id === id)?.label ?? id;
|
||||
}
|
||||
|
||||
function titleCase(s: string): string {
|
||||
return s.length ? s[0].toUpperCase() + s.slice(1) : s;
|
||||
}
|
||||
|
||||
320
apps/web/components/editor/sections/ConceptsPane.tsx
Normal file
320
apps/web/components/editor/sections/ConceptsPane.tsx
Normal 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: "A–Z", 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 A–Z views in Concepts. The
|
||||
* leading column is ALWAYS the chevron slot — Tree passes a real toggle
|
||||
* button, A–Z 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 A–Z 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";
|
||||
}
|
||||
231
apps/web/components/editor/sections/FindingsPane.tsx
Normal file
231
apps/web/components/editor/sections/FindingsPane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
340
apps/web/components/editor/sections/ModelPane.tsx
Normal file
340
apps/web/components/editor/sections/ModelPane.tsx
Normal 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;
|
||||
}
|
||||
212
apps/web/components/editor/sections/RequirementsPane.tsx
Normal file
212
apps/web/components/editor/sections/RequirementsPane.tsx
Normal 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;
|
||||
}
|
||||
22
apps/web/components/editor/sections/TextCanvasPane.tsx
Normal file
22
apps/web/components/editor/sections/TextCanvasPane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
apps/web/components/editor/sections/paneContext.tsx
Normal file
37
apps/web/components/editor/sections/paneContext.tsx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user