Pivot to text-first column-stack workspace + merge-with-review across AI artifacts

Workspace
- Pivot from "set of open panes" to a Finder-style miller column stack:
  TopBar / LeftSidebar / [section → entity → entity ...] / pinned text editor.
  openPanesStore is now an ordered Column[] with pushFrom / closeFrom /
  setStack; only one top-level section is rooted at a time.
- New entity column panes: Term, Block, Association, Constraint,
  Requirement, Finding. Click-through navigation truncates deeper
  columns automatically.
- LeftSidebar surfaces a pending-count chip per section (single-glance
  navigation cue) and spins its analyze ↻ via SVG Spinner whenever the
  LLM is working — including server-initiated runs caught by the runs
  poll, not just user-triggered ones.

Analyze pipeline + persistence
- Unified `concepts` pass (taxonomy + glossary in one LLM call) replaces
  the two-pass setup. Server still accepts ?section=taxonomy|glossary
  and normalizes them for back-compat.
- model / requirements / detection (assumptions, risks, inconsistencies)
  + cross-layer validation rules (X1–X4: stale term link, unlinked
  formalism, undefined linked term, prose-only term).
- Persistence: NarrativeDocument, ModelSnapshot, ChangelogEntry,
  TaxonomyTerm, RequirementEntry, Finding, AnalysisRun. Re-runs MERGE
  instead of replace: gentle update on existing items, suggested on new,
  deprecated on missing — same idiom for every artifact kind. User pins
  preserve "kept" decisions across re-analyses.
- Migrations: pivot_text_first, add_requirement_linked_term,
  term_review_state, review_state_for_reqs_and_findings,
  add_term_definition_pinned.

Concept ↔ ontology integration
- linkedTermId on Block / Association / Constraint / Requirement.
  PromoteToolbar lets the user formalize a concept inline: + Block /
  + Association / + Constraint / + Requirement, all routed through
  applyOps so undo/redo and SSE work for free.
- decideElement op for in-canvas keep/discard on review-pending model
  elements.

User-authored definitions
- TermColumn definition is click-to-edit. Save (Cmd-Enter / blur),
  Cancel (Esc), Reset to AI suggestion when pinned.
- definitionPinned flag on TaxonomyTerm: future Analyze runs leave the
  user's text alone. setTermDefinition repo function + POST
  /api/projects/[id]/terms/[termId]/definition endpoint.
- mergeTaxonomySuggestion + applyGlossaryDefinitions both pin-aware.

UX/UI
- StatusChip: single component for all state idioms (suggested,
  deprecated, accepted, dismissed, resolved, severity, validation code,
  confidence, warn). Replaces 5+ ad-hoc badge classes.
- PaneControls (PaneViewTabs + PaneFilterChip): separates view-mode
  toggles from filter chips so toggling Pending no longer flips you off
  the current view.
- PaneEmpty: unified empty-state with title + hint + action.
- PaneDrawer: collapsible groups for Pending / Discarded review; cards
  group as Kept (top) → Pending (bottom drawer) → Discarded (Findings
  only, hidden when empty). Restore action recovers dismissed/resolved
  findings.
- ConceptCard unifies Tree and A–Z views in Concepts; only Tree parents
  carry the chevron (no empty placeholder offset).
- Type + spacing tokens (--text-xs..xl, --space-1..6, --lh-tight/ui/
  prose, --radius-*) replace every ad-hoc value.
- Buttons standardized to body sans 500 (was a mishmash of mono / display).
- Card shells unified across Concepts / Requirements / Findings.

Cleanup
- Removed: LeftRail, FindingsPanel, IssuesPanel, SocratesDock,
  ProposalCard, SlashMenu, SlashExtension, slashSuggestion,
  CanvasHeader, TaxonomyPane, GlossaryPane, TermDetail (popover; now
  TermColumn).
- Section ids in openPanesStore: dropped taxonomy/glossary, added
  concepts. localStorage migration runs on hydrate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 00:12:06 +02:00
parent 4e725c0b2b
commit b55425cc68
88 changed files with 10855 additions and 1768 deletions

View File

@@ -56,7 +56,7 @@ import {
type ModelOp,
} from "../../lib/sync/ops";
import type { ValidationIssue } from "../../lib/sysml/validate";
import type { Density } from "../socrates/SocratesDock";
import type { Density } from "../../lib/workspace/types";
import type { FixtureData, BlockKind } from "../../lib/fixtures/aristotle";
import type { Block, Property, PropertyType, SysMLModel } from "../../lib/sysml/model";
@@ -69,6 +69,8 @@ interface DiagramCanvasProps {
focusBlockId: string | null;
onSelect?: (id: string | null) => void;
issuesByElement?: Map<string, ValidationIssue[]>;
/** When set, term-drop on the canvas links the chosen term server-side. */
projectId?: string;
}
const nodeTypes = { sysmlBlock: BlockNode };
@@ -93,7 +95,7 @@ export function DiagramCanvas(props: DiagramCanvasProps) {
);
}
function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: DiagramCanvasProps) {
function DiagramInner({ data, focusBlockId, onSelect, issuesByElement, projectId }: DiagramCanvasProps) {
const model = useModel();
const apply = useApply();
const wrapperRef = useRef<HTMLDivElement | null>(null);
@@ -130,14 +132,29 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
const dataChanged =
existing.data?.label !== b.label ||
existing.data?.kind !== b.kind ||
!sameStringArray(existing.data?.properties ?? [], propNames);
!sameStringArray(existing.data?.properties ?? [], propNames) ||
existing.data?.reviewStatus !== b.reviewStatus;
if (dataChanged) {
next.push({ ...existing, data: { ...existing.data, label: b.label, kind: b.kind, properties: propNames } });
next.push({
...existing,
data: {
...existing.data,
label: b.label,
kind: b.kind,
properties: propNames,
reviewStatus: b.reviewStatus,
},
});
} else {
next.push(existing);
}
} else {
next.push({ ...makeNodeForBlock(b), position: placeNew() });
const created = makeNodeForBlock(b);
next.push({
...created,
position: placeNew(),
data: { ...created.data, reviewStatus: b.reviewStatus },
});
}
}
for (const c of model.constraints) {
@@ -146,14 +163,30 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
const expr = c.expression || "{ }";
const dataChanged =
existing.data?.label !== c.label ||
existing.data?.expression !== expr;
existing.data?.expression !== expr ||
existing.data?.reviewStatus !== c.reviewStatus;
if (dataChanged) {
next.push({ ...existing, data: { ...existing.data, label: c.label, kind: "constraint", properties: [], expression: expr } });
next.push({
...existing,
data: {
...existing.data,
label: c.label,
kind: "constraint",
properties: [],
expression: expr,
reviewStatus: c.reviewStatus,
},
});
} else {
next.push(existing);
}
} else {
next.push({ ...makeNodeForConstraint(c), position: placeNew() });
const created = makeNodeForConstraint(c);
next.push({
...created,
position: placeNew(),
data: { ...created.data, reviewStatus: c.reviewStatus },
});
}
}
return next;
@@ -295,9 +328,47 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
const onDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
// Taxonomy/glossary term drop → block stamped from the term.
const termRaw = event.dataTransfer.getData("application/x-socrata-term");
if (termRaw) {
try {
const t = JSON.parse(termRaw) as { termId?: string; label?: string; definition?: string };
if (!t.label || !t.termId) return;
const bid = tempId("b");
const block: Block = {
id: bid,
label: t.label,
kind: "block",
stereotypes: ["block"],
properties: [],
description: t.definition ?? undefined,
linkedTermId: t.termId,
};
const result = apply([addBlockOp(block, bid)]);
if (result.applied) {
const final = result.idMapping[bid] ?? bid;
setNodes(curr => curr.map(n => (n.id === final ? { ...n, position } : n)));
onSelect?.(final);
// Persist the term → block link server-side so it survives reload
// and so other UI can show the "linked" indicator immediately.
if (projectId) {
void fetch(`/api/projects/${encodeURIComponent(projectId)}/term-link`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ termId: t.termId, blockId: final }),
}).catch(() => {});
}
}
} catch (err) {
console.error("[DiagramCanvas] term drop parse:", err);
}
return;
}
const kindRaw = event.dataTransfer.getData("application/sysml-kind");
if (!kindRaw) return;
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
if (kindRaw === "constraint") {
const cid = tempId("c");
@@ -332,7 +403,7 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
onSelect?.(final);
}
},
[apply, onSelect, screenToFlowPosition, setNodes]
[apply, onSelect, screenToFlowPosition, setNodes, projectId]
);
function patchSelected(patch: Partial<BlockNodeData>) {

View File

@@ -14,6 +14,9 @@ export interface BlockNodeData extends Record<string, unknown> {
expression?: string;
/** Highest severity of any validation issue anchored to this block. */
issueSeverity?: "error" | "warning" | "soft";
/** Review state — drives a small visual distinction so unreviewed analyzer
* suggestions stand out on the canvas. */
reviewStatus?: "suggested" | "accepted" | "deprecated";
}
const STEREO: Record<string, string> = {
@@ -33,6 +36,7 @@ export function BlockNode({ data, selected }: NodeProps) {
`sysml-node-${kind}`,
selected ? "sysml-node-selected" : "",
d.issueSeverity ? `sysml-node-issue-${d.issueSeverity}` : "",
d.reviewStatus && d.reviewStatus !== "accepted" ? `sysml-node-review-${d.reviewStatus}` : "",
]
.filter(Boolean)
.join(" ");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,74 +1,166 @@
// Seed onboarding screen — two-column layout: emerging-seed rail (left) + Socrates conversation (right).
// Ported from docs/design-source/socrata/project/seed-screen.jsx.
// Live seed interview screen.
//
// Layout retained from the M1 port: emerging-seed rail (left) + Socrates
// conversation (right). The rail now reflects the live `draft` extracted
// from the conversation; the right side is a real chat with the LM Studio
// (or Anthropic) gateway via /api/seed/turn. When Socrates flags ready (or
// the user clicks "Generate") we POST /api/seed/finalize, which generates
// the SysMLModel + creates the project, then we router-push to the editor.
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Sigil } from "../socrates/Sigil";
interface SeedField {
key: string;
label: string;
value: string;
inferred?: boolean;
}
interface ThreadTurn {
who: "socrates" | "user";
interface InterviewTurn {
role: "socrates" | "user";
text: string;
pending?: boolean;
}
const fields: SeedField[] = [
{
key: "problem",
label: "Problem",
value: "First-year STEM students disengage in the long tail between lectures and office hours; a self-efficacy gap forms quickly.",
},
{
key: "user",
label: "Target user",
value: "Undergraduates at large public universities, weeks 310 of an intro course.",
},
{
key: "outcome",
label: "Desired outcome",
value: "Students re-engage with material via a low-stakes thinking partner — without producing solutions.",
},
{
key: "hypothesis",
label: "Initial hypothesis",
value: "Students will adopt a tool that explicitly refuses to solve their homework.",
inferred: true,
},
{
key: "constraint",
label: "Constraint",
value: "FERPA tenancy, P50 < 1.2s.",
inferred: true,
},
];
interface SeedDraft {
title: string;
problem: string;
targetUser: string;
desiredOutcome: string;
initialHypothesis?: string;
constraints?: string[];
}
const thread: ThreadTurn[] = [
{
who: "socrates",
text: "Welcome. I'm Socrates. Before we model anything, let me understand what you're really proposing. In one sentence — what is the smallest, most honest version of the problem?",
},
{
who: "user",
text: "Students disengage between lectures because they have nobody to think with at 11pm.",
},
{
who: "socrates",
text: "Good. Two follow-ups. First, who specifically — and why now? Second, when you say 'think with', do you mean a tutor that explains, or a partner that asks? These are quite different products.",
},
{
who: "user",
text: "Public-university undergrads, weeks 310. A partner that asks. The market is saturated with explainers.",
},
{
who: "socrates",
text: "Then the central tension is restraint: a tool that holds its tongue. Most LLM products are rewarded for being helpful. Yours will be rewarded for being patient. Should I draft this as a Constraint on the model — refusal_policy : single-valued — and surface it for your review?",
},
];
const EMPTY_DRAFT: SeedDraft = {
title: "",
problem: "",
targetUser: "",
desiredOutcome: "",
};
export function SeedScreen() {
const router = useRouter();
const [history, setHistory] = useState<InterviewTurn[]>([]);
const [draft, setDraft] = useState<SeedDraft>(EMPTY_DRAFT);
const [confidence, setConfidence] = useState(0);
const [ready, setReady] = useState(false);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [generating, setGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [meta, setMeta] = useState<{ provider?: string; model?: string }>({});
const threadEndRef = useRef<HTMLDivElement | null>(null);
const openedRef = useRef(false);
// On mount, get Socrates' opening question.
useEffect(() => {
if (openedRef.current) return;
openedRef.current = true;
void sendImpl("", true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Auto-scroll
useEffect(() => {
threadEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [history]);
const sendImpl = useCallback(
async (text: string, isOpening = false) => {
setSending(true);
setError(null);
const historyToSend: InterviewTurn[] = [...history];
const userTurn: InterviewTurn | null = isOpening ? null : { role: "user", text };
const pendingTurn: InterviewTurn = {
role: "socrates",
text: "thinking…",
pending: true,
};
setHistory(curr => [...curr, ...(userTurn ? [userTurn] : []), pendingTurn]);
try {
const res = await fetch("/api/seed/turn", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
history: historyToSend,
userText: text,
draft,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error ?? `${res.status}`);
}
const data = (await res.json()) as {
assistant: { text: string };
draft: SeedDraft;
confidence: number;
ready: boolean;
meta?: { provider?: string; model?: string };
};
setHistory(curr =>
curr.map(t =>
t === pendingTurn ? { role: "socrates", text: data.assistant.text } : t
)
);
setDraft(data.draft);
setConfidence(data.confidence);
setReady(data.ready);
if (data.meta) setMeta({ provider: data.meta.provider, model: data.meta.model });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
setHistory(curr =>
curr.map(t =>
t === pendingTurn ? { role: "socrates", text: `${msg}`, pending: false } : t
)
);
} finally {
setSending(false);
}
},
[history, draft]
);
const onSubmit = useCallback(async () => {
const text = input.trim();
if (!text || sending) return;
setInput("");
await sendImpl(text);
}, [input, sending, sendImpl]);
const generate = useCallback(async () => {
if (generating) return;
setGenerating(true);
setError(null);
try {
const res = await fetch("/api/seed/finalize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ draft }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error ?? `${res.status}`);
}
const data = (await res.json()) as { projectId: string };
router.push(`/editor/${data.projectId}`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
setGenerating(false);
}
}, [draft, generating, router]);
const filledFieldsCount =
[draft.problem, draft.targetUser, draft.desiredOutcome].filter(Boolean).length +
(draft.initialHypothesis ? 1 : 0) +
((draft.constraints?.length ?? 0) > 0 ? 1 : 0);
const canGenerate = !!draft.problem && !!draft.targetUser && !!draft.desiredOutcome;
const confidencePct = Math.round(confidence * 100);
return (
<div className="seed-screen">
<header className="seed-top">
@@ -76,110 +168,147 @@ export function SeedScreen() {
<Sigil size={28} />
<span className="seed-brand">Socrata</span>
<span className="seed-pip">·</span>
<span className="seed-step">Seed · forming</span>
<span className="seed-step">Seed · {ready ? "ready" : "forming"}</span>
{meta.model && (
<span className="seed-meta">via {meta.provider} · {meta.model.split("/").pop()}</span>
)}
</div>
<div className="seed-top-right">
<span className="seed-mode-pill seed-mode-active">Interview</span>
<span className="seed-mode-pill">Form</span>
<a href="/" className="seed-mode-pill" style={{ textDecoration: "none" }}> back</a>
</div>
</header>
<div className="seed-body">
{/* Left: emerging seed */}
<section className="seed-left">
<div className="seed-section-label">Emerging seed</div>
<div className="seed-fields">
{fields.map(f => (
<div key={f.key} className={`seed-field ${f.inferred ? "seed-field-inferred" : ""}`}>
<div className="seed-field-label">
{f.label}
{f.inferred && <span className="seed-conf">inferred · 0.74</span>}
<Field label="Title" value={draft.title} />
<Field label="Problem" value={draft.problem} />
<Field label="Target user" value={draft.targetUser} />
<Field label="Desired outcome" value={draft.desiredOutcome} />
{draft.initialHypothesis && <Field label="Initial hypothesis" value={draft.initialHypothesis} inferred />}
{draft.constraints && draft.constraints.length > 0 && (
<div className="seed-field seed-field-inferred">
<div className="seed-field-label">Constraints<span className="seed-conf">{draft.constraints.length}</span></div>
<div className="seed-field-value">
<ul style={{ margin: 0, paddingLeft: 14 }}>
{draft.constraints.map((c, i) => <li key={i}>{c}</li>)}
</ul>
</div>
<div className="seed-field-value">{f.value}</div>
</div>
))}
</div>
<div className="seed-section-label seed-section-label-2">Initial model · drafting</div>
<div className="seed-mini-graph">
<div className="mini-block mini-block-1">
<span className="mini-stereo">«block»</span>
<span className="mini-name">Student</span>
<span className="mini-prop">self_efficacy</span>
</div>
<div className="mini-edge" />
<div className="mini-block mini-block-2 mini-block-focus">
<span className="mini-stereo">«block»</span>
<span className="mini-name">Aristotle</span>
<span className="mini-prop">refusal_policy</span>
<span className="mini-prop">interaction_style</span>
</div>
<div className="mini-edge mini-edge-down" />
<div className="mini-block mini-block-3">
<span className="mini-stereo">«constraint»</span>
<span className="mini-name">FERPA boundary</span>
</div>
)}
</div>
<div className="seed-confidence">
<div className="seed-confidence-row">
<span>Model confidence</span>
<span>0.62</span>
<span>Draft confidence</span>
<span>{confidencePct}%</span>
</div>
<div className="seed-confidence-bar">
<div className="seed-confidence-fill" style={{ width: "62%" }} />
<div className="seed-confidence-fill" style={{ width: `${confidencePct}%` }} />
</div>
<div className="seed-confidence-hint">
Three more clarifying questions should bring this above 0.80.
{ready
? "Socrates says you're ready — click Generate to create the project."
: `${filledFieldsCount} of 5 fields filled · keep answering to firm up the draft.`}
</div>
</div>
<div style={{ marginTop: 18, display: "flex", flexDirection: "column", gap: 8 }}>
<button
className="seed-btn seed-btn-primary"
type="button"
onClick={generate}
disabled={!canGenerate || generating}
style={{ width: "100%" }}
>
{generating ? "Generating model…" : ready ? "Generate model & open editor" : canGenerate ? "Generate (early)" : "Generate (need more answers)"}
</button>
{error && (
<div style={{
padding: "6px 8px",
background: "var(--warn-soft)",
color: "var(--warn-strong)",
borderRadius: 4,
fontFamily: "var(--font-mono)",
fontSize: 11,
}}>
{error}
</div>
)}
</div>
</section>
{/* Right: Socrates conversation */}
<section className="seed-right">
<div className="seed-thread">
{thread.map((m, i) => (
<div key={i} className={`seed-bubble seed-bubble-${m.who}`}>
{m.who === "socrates" && (
{history.map((m, i) => (
<div key={i} className={`seed-bubble seed-bubble-${m.role}`}>
{m.role === "socrates" && (
<div className="seed-bubble-avatar">
<Sigil size={32} />
</div>
)}
<div className="seed-bubble-body">
<div className="seed-bubble-who">{m.who === "socrates" ? "Socrates" : "You"}</div>
<div className="seed-bubble-text">{m.text}</div>
<div className="seed-bubble-who">{m.role === "socrates" ? "Socrates" : "You"}</div>
<div
className="seed-bubble-text"
style={m.pending ? { opacity: 0.55, fontStyle: "italic" } : undefined}
>
{m.text}
</div>
</div>
</div>
))}
<div className="seed-bubble seed-bubble-socrates seed-bubble-typing">
<div className="seed-bubble-avatar">
<Sigil size={32} />
</div>
<div className="seed-bubble-body">
<div className="seed-bubble-who">Socrates</div>
<div className="seed-typing">
<span /><span /><span /> drafting next question
</div>
</div>
</div>
<div ref={threadEndRef} />
</div>
<div className="seed-input-row">
<form
className="seed-input-row"
onSubmit={e => {
e.preventDefault();
void onSubmit();
}}
>
<div className="seed-input">
<span className="seed-input-prompt"></span>
<span className="seed-input-text">
Public-university undergrads, weeks 310. A partner that asks. The market is saturated with explainers.
</span>
<span className="seed-input-caret" />
<input
className="seed-input-field"
value={input}
onChange={e => setInput(e.target.value)}
placeholder={sending ? "Socrates is thinking…" : ready ? "Want to keep refining? Ask again." : "Reply to Socrates…"}
disabled={sending || generating}
autoFocus
/>
</div>
<div className="seed-input-actions">
<button className="seed-btn" type="button">Save draft</button>
<button className="seed-btn seed-btn-primary" type="button">Send · </button>
<button
type="submit"
className="seed-btn seed-btn-primary"
disabled={sending || generating || !input.trim()}
>
Send ·
</button>
</div>
</div>
</form>
</section>
</div>
</div>
);
}
function Field({ label, value, inferred }: { label: string; value: string; inferred?: boolean }) {
if (!value) {
return (
<div className="seed-field" style={{ opacity: 0.45 }}>
<div className="seed-field-label">{label}</div>
<div className="seed-field-value" style={{ fontStyle: "italic", color: "var(--muted)" }}></div>
</div>
);
}
return (
<div className={`seed-field ${inferred ? "seed-field-inferred" : ""}`}>
<div className="seed-field-label">{label}</div>
<div className="seed-field-value">{value}</div>
</div>
);
}

View File

@@ -0,0 +1,195 @@
// Inline mini-Socrates thread anchored to a single finding. Replaces the
// global SocratesDock for the conversational surface.
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Sigil } from "./Sigil";
interface ApiMessage {
id: string;
role: "user" | "assistant" | string;
text: string;
options?: Array<{ n: number; label: string; sub?: string }>;
}
interface ContextualSocratesThreadProps {
projectId: string;
findingId: string;
findingText: string;
onResolved?: () => void;
}
export function ContextualSocratesThread({ projectId, findingId, onResolved }: ContextualSocratesThreadProps) {
const [messages, setMessages] = useState<ApiMessage[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [loaded, setLoaded] = useState(false);
const endRef = useRef<HTMLDivElement | null>(null);
const url = `/api/projects/${encodeURIComponent(projectId)}/findings/${encodeURIComponent(findingId)}/socrates`;
// Initial load + open the conversation if empty.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`load: ${res.status}`);
const body = (await res.json()) as { messages?: ApiMessage[] };
if (cancelled) return;
const initial = body.messages ?? [];
setMessages(initial);
setLoaded(true);
if (initial.length === 0) {
await sendImpl("");
}
} catch (err) {
console.error("[ContextualSocratesThread] load failed:", err);
if (!cancelled) setLoaded(true);
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [messages]);
const sendImpl = useCallback(
async (text: string) => {
setSending(true);
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
if (!res.ok) throw new Error(`send: ${res.status}`);
const body = (await res.json()) as {
assistant: { id: string; turn: { text: string; options?: Array<{ n: number; label: string; sub?: string }> } };
user: { id: string };
};
setMessages(prev => {
const next = [...prev];
if (text.trim().length > 0) {
next.push({ id: body.user.id, role: "user", text });
}
next.push({
id: body.assistant.id,
role: "assistant",
text: body.assistant.turn.text,
options: body.assistant.turn.options,
});
return next;
});
} catch (err) {
console.error("[ContextualSocratesThread] send failed:", err);
} finally {
setSending(false);
}
},
[url]
);
const onSend = useCallback(async () => {
const text = input.trim();
if (!text || sending) return;
setInput("");
await sendImpl(text);
}, [input, sending, sendImpl]);
const onResolve = useCallback(async () => {
try {
await fetch(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "resolved" }),
});
onResolved?.();
} catch (err) {
console.error("[ContextualSocratesThread] resolve failed:", err);
}
}, [url, onResolved]);
const onDismiss = useCallback(async () => {
try {
await fetch(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "dismissed" }),
});
onResolved?.();
} catch (err) {
console.error("[ContextualSocratesThread] dismiss failed:", err);
}
}, [url, onResolved]);
return (
<div className="ctx-thread">
<div className="ctx-thread-messages">
{!loaded ? (
<div className="ctx-thread-loading"></div>
) : (
messages.map(m => (
<div key={m.id} className={`ctx-bubble ctx-bubble-${m.role}`}>
{m.role === "assistant" ? <Sigil size={18} /> : null}
<div className="ctx-bubble-body">
<div className="ctx-bubble-text">{m.text}</div>
{m.options && m.options.length > 0 ? (
<div className="ctx-bubble-options">
{m.options.map(o => (
<button
key={o.n}
type="button"
className="ctx-option"
onClick={() => setInput(prev => (prev ? `${prev} ${o.label}` : o.label))}
>
<span className="ctx-option-n">{o.n}.</span>
<span className="ctx-option-label">{o.label}</span>
{o.sub ? <span className="ctx-option-sub">{o.sub}</span> : null}
</button>
))}
</div>
) : null}
</div>
</div>
))
)}
<div ref={endRef} />
</div>
<div className="ctx-thread-input-row">
<textarea
className="ctx-thread-input"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
void onSend();
}
}}
placeholder="Reply to Socrates…"
rows={2}
/>
<div className="ctx-thread-actions">
<button type="button" className="ctx-send" onClick={onSend} disabled={sending || !input.trim()}>
</button>
</div>
</div>
<div className="ctx-thread-resolve">
<button type="button" className="ctx-resolve-btn" onClick={onResolve}>
Mark resolved
</button>
<button type="button" className="ctx-dismiss-btn" onClick={onDismiss}>
Dismiss
</button>
</div>
</div>
);
}

View File

@@ -1,208 +0,0 @@
// In-dock card showing a Socrates-proposed change.
//
// Displays: reasoning, op summary, impact summary (added/removed/changed +
// validation diff), and Accept / Reject controls. Accept calls into the
// ModelStore which routes through the same applyOps + persistence pipeline
// as user-originated edits.
"use client";
import { useState } from "react";
import type { ImpactSummary } from "../../lib/sysml/impact";
import type { ModelOp } from "../../lib/sync/ops";
export interface ProposalPayload {
reasoning: string;
ops: ModelOp[];
impactSummary: ImpactSummary;
meta?: { provider?: string; model?: string };
}
interface ProposalCardProps {
proposal: ProposalPayload;
onAccept: () => Promise<void> | void;
onReject: () => void;
pending?: boolean;
}
export function ProposalCard({ proposal, onAccept, onReject, pending }: ProposalCardProps) {
const [busy, setBusy] = useState(false);
const { reasoning, ops, impactSummary } = proposal;
async function accept() {
if (busy) return;
setBusy(true);
try {
await onAccept();
} finally {
setBusy(false);
}
}
return (
<div className={`proposal-card ${pending ? "proposal-card-pending" : ""}`}>
<div className="proposal-card-head">
<span className="proposal-card-tag">PROPOSAL</span>
<span className="proposal-card-stats">
{summarizeStats(impactSummary)}
</span>
</div>
<div className="proposal-card-reasoning">{reasoning}</div>
{ops.length > 0 && (
<details className="proposal-card-ops" open>
<summary>Ops · {ops.length}</summary>
<ul>
{ops.map((op, i) => (
<li key={i}>{summarizeOp(op)}</li>
))}
</ul>
</details>
)}
{(impactSummary.added.length > 0 || impactSummary.removed.length > 0 || impactSummary.changed.length > 0) && (
<div className="proposal-card-section">
<div className="proposal-card-section-label">Impact</div>
<div className="proposal-card-impact-grid">
{impactSummary.added.length > 0 && (
<div className="proposal-card-impact-row">
<span className="proposal-card-impact-key">+ added</span>
<span className="proposal-card-impact-val">
{impactSummary.added.map(e => `${e.label} (${e.kind})`).join(", ")}
</span>
</div>
)}
{impactSummary.removed.length > 0 && (
<div className="proposal-card-impact-row">
<span className="proposal-card-impact-key"> removed</span>
<span className="proposal-card-impact-val">
{impactSummary.removed.map(e => `${e.label} (${e.kind})`).join(", ")}
</span>
</div>
)}
{impactSummary.changed.length > 0 && (
<div className="proposal-card-impact-row">
<span className="proposal-card-impact-key">~ changed</span>
<span className="proposal-card-impact-val">
{impactSummary.changed.map(e => `${e.label} (${e.kind})`).join(", ")}
</span>
</div>
)}
</div>
</div>
)}
{(impactSummary.issuesCreated.length > 0 || impactSummary.issuesResolved.length > 0) && (
<div className="proposal-card-section">
<div className="proposal-card-section-label">Validation</div>
{impactSummary.issuesResolved.length > 0 && (
<ul className="proposal-card-issue-list proposal-card-issues-resolved">
{impactSummary.issuesResolved.slice(0, 4).map((i, idx) => (
<li key={idx}> resolves {i.code}: {i.message}</li>
))}
</ul>
)}
{impactSummary.issuesCreated.length > 0 && (
<ul className="proposal-card-issue-list proposal-card-issues-created">
{impactSummary.issuesCreated.slice(0, 4).map((i, idx) => (
<li key={idx}> creates {i.code}: {i.message}</li>
))}
</ul>
)}
</div>
)}
{!impactSummary.ok && (
<div className="proposal-card-section proposal-card-error">
<div className="proposal-card-section-label">Cannot apply</div>
<ul className="proposal-card-issue-list">
{impactSummary.errors.map((e, i) => (
<li key={i}>{e.code}: {e.message}</li>
))}
</ul>
</div>
)}
<div className="proposal-card-actions">
<button
type="button"
className="proposal-card-btn proposal-card-btn-primary"
onClick={accept}
disabled={busy || pending || !impactSummary.ok || ops.length === 0}
>
{busy ? "Applying…" : "Accept"}
</button>
<button
type="button"
className="proposal-card-btn"
onClick={onReject}
disabled={busy || pending}
>
Reject
</button>
</div>
{proposal.meta?.model && (
<div className="proposal-card-meta">
via {proposal.meta.provider} · {proposal.meta.model}
</div>
)}
</div>
);
}
function summarizeStats(impact: ImpactSummary): string {
if (!impact.ok) return "would not apply";
const parts: string[] = [];
const { stats } = impact;
if (stats.blocksDelta) parts.push(`${signed(stats.blocksDelta)} block${Math.abs(stats.blocksDelta) === 1 ? "" : "s"}`);
if (stats.associationsDelta) parts.push(`${signed(stats.associationsDelta)} assoc`);
if (stats.constraintsDelta) parts.push(`${signed(stats.constraintsDelta)} constraint${Math.abs(stats.constraintsDelta) === 1 ? "" : "s"}`);
if (stats.requirementsDelta) parts.push(`${signed(stats.requirementsDelta)} req${Math.abs(stats.requirementsDelta) === 1 ? "" : "s"}`);
if (stats.issuesDelta) parts.push(`${signed(stats.issuesDelta)} issue${Math.abs(stats.issuesDelta) === 1 ? "" : "s"}`);
if (parts.length === 0) parts.push("structural rearrangement");
return parts.join(" · ");
}
function signed(n: number): string {
return n > 0 ? `+${n}` : `${n}`;
}
function summarizeOp(op: ModelOp): string {
switch (op.kind) {
case "add-block":
return `+ block "${op.block.label}" (${op.block.kind})`;
case "remove-block":
return ` block ${op.blockId}`;
case "update-block":
return `~ block ${op.blockId}`;
case "add-association":
return `+ assoc ${op.association.fromBlockId}${op.association.toBlockId} (${op.association.kind})`;
case "remove-association":
return ` assoc ${op.associationId}`;
case "update-association":
return `~ assoc ${op.associationId}`;
case "add-constraint":
return `+ constraint "${op.constraint.label}"`;
case "remove-constraint":
return ` constraint ${op.constraintId}`;
case "update-constraint":
return `~ constraint ${op.constraintId}`;
case "add-requirement":
return `+ req ${op.requirement.tag}`;
case "remove-requirement":
return ` req ${op.requirementId}`;
case "update-requirement":
return `~ req ${op.requirementId}`;
case "add-property":
return `+ property ${op.blockId}.${op.property.name}`;
case "update-property":
return `~ property ${op.blockId}.${op.propertyId}`;
case "remove-property":
return ` property ${op.blockId}.${op.propertyId}`;
case "add-relation":
return `+ relation ${op.requirementId}.${op.relation.kind}`;
case "remove-relation":
return ` relation ${op.requirementId}[${op.relationIndex}]`;
}
}

View File

@@ -1,389 +0,0 @@
// Active-thread dock with Sigil header, conversation bubbles, numbered options.
//
// M6: live thread loaded from /api/projects/[id]/socrates. The reply input
// POSTs each user turn and renders Socrates' response when it arrives.
// Numbered options pre-fill the input when clicked / pressed (13).
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Sigil } from "./Sigil";
import { ProposalCard, type ProposalPayload } from "./ProposalCard";
import { useApply } from "../../lib/sync/ModelStore";
export type SocratesPresence = "subtle" | "default" | "prominent";
export type Density = "comfortable" | "compact";
export interface DockMessage {
id: string;
role: "user" | "assistant" | "proposal" | "system";
text: string;
options?: Array<{ n: number; label: string; sub?: string }>;
pending?: boolean;
proposal?: ProposalPayload;
}
interface SocratesDockProps {
projectId: string;
presence: SocratesPresence;
density: Density;
}
interface ApiMessage {
id: string;
role: "user" | "assistant";
text: string;
options?: Array<{ n: number; label: string; sub?: string }>;
createdAt: string;
}
export function SocratesDock({ projectId, presence }: SocratesDockProps) {
const [messages, setMessages] = useState<DockMessage[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [proposing, setProposing] = useState(false);
const [loaded, setLoaded] = useState(false);
const [meta, setMeta] = useState<{ provider?: string; model?: string }>({});
const threadEndRef = useRef<HTMLDivElement | null>(null);
const apply = useApply();
// Load thread on mount and trigger an opening turn if the thread is empty.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/socrates`);
if (!res.ok) throw new Error(`load thread: ${res.status}`);
const data = (await res.json()) as { messages?: ApiMessage[] };
if (cancelled) return;
const initial: DockMessage[] = (data.messages ?? []).map(m => ({
id: m.id,
role: m.role,
text: m.text,
options: m.options,
}));
setMessages(initial);
setLoaded(true);
if (initial.length === 0 && !cancelled) {
// Trigger Socrates' opening turn
await sendImpl("", true);
}
} catch {
if (!cancelled) setLoaded(true);
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
// Auto-scroll on new turns
useEffect(() => {
threadEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [messages]);
const sendImpl = useCallback(
async (text: string, isOpening = false) => {
setSending(true);
try {
// Add the user bubble locally (optimistic) — unless this is the opening
if (!isOpening) {
setMessages(curr => [
...curr,
{ id: `tmp-u-${Date.now()}`, role: "user", text },
]);
}
// Show a pending Socrates bubble
const pendingId = `tmp-a-${Date.now()}`;
setMessages(curr => [
...curr,
{ id: pendingId, role: "assistant", text: "thinking…", pending: true },
]);
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/socrates`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error ?? `${res.status}`);
}
const data = await res.json();
const a = data.assistant as { id: string; text: string; options?: Array<{ n: number; label: string; sub?: string }> };
if (data.meta) setMeta({ provider: data.meta.provider, model: data.meta.model });
// Replace the pending bubble with the real one
setMessages(curr => curr.map(m =>
m.id === pendingId
? { id: a.id, role: "assistant", text: a.text, options: a.options }
: m
));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setMessages(curr => curr.map(m =>
m.pending ? { ...m, text: `${msg}`, pending: false } : m
));
} finally {
setSending(false);
}
},
[projectId]
);
const onSubmit = useCallback(async () => {
const text = input.trim();
if (!text || sending) return;
setInput("");
await sendImpl(text);
}, [input, sending, sendImpl]);
const pickOption = useCallback(async (option: { n: number; label: string; sub?: string }) => {
if (sending) return;
const text = `[${option.n}] ${option.label}${option.sub ? `${option.sub}` : ""}`;
await sendImpl(text);
}, [sending, sendImpl]);
// Ask Socrates to propose a model change.
const requestProposal = useCallback(async () => {
if (proposing || sending) return;
setProposing(true);
const pendingId = `tmp-p-${Date.now()}`;
setMessages(curr => [
...curr,
{ id: pendingId, role: "proposal", text: "Drafting a proposal…", pending: true },
]);
try {
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/socrates/propose`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error ?? `${res.status}`);
}
const data = (await res.json()) as ProposalPayload;
setMessages(curr => curr.map(m =>
m.id === pendingId
? { id: pendingId, role: "proposal", text: data.reasoning, proposal: data }
: m
));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setMessages(curr => curr.map(m =>
m.id === pendingId
? { ...m, text: `⚠ propose failed: ${msg}`, pending: false }
: m
));
} finally {
setProposing(false);
}
}, [proposing, sending, projectId]);
const acceptProposal = useCallback(async (messageId: string, proposal: ProposalPayload) => {
const result = apply(proposal.ops);
if (!result.applied) {
setMessages(curr => [
...curr,
{
id: `tmp-sys-${Date.now()}`,
role: "system",
text: `⚠ Couldn't apply: ${result.errors.map(e => `${e.code}: ${e.message}`).join("; ")}`,
},
]);
return;
}
// Mark the proposal accepted (drop the live card; keep a summary line)
setMessages(curr => curr.map(m =>
m.id === messageId
? {
id: messageId,
role: "system",
text: `✓ Applied · ${proposal.ops.length} op${proposal.ops.length === 1 ? "" : "s"} · ${truncate(proposal.reasoning, 90)}`,
}
: m
));
}, [apply]);
const rejectProposal = useCallback((messageId: string) => {
setMessages(curr => curr.map(m =>
m.id === messageId
? { id: messageId, role: "system", text: "Proposal dismissed." }
: m
));
}, []);
// Number-key shortcuts on the most recent assistant turn with options
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
// Don't hijack number keys when typing in any input/contenteditable
const target = e.target as HTMLElement | null;
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return;
if (sending) return;
const last = [...messages].reverse().find(m => m.role === "assistant" && m.options?.length);
if (!last?.options) return;
const n = parseInt(e.key, 10);
if (Number.isNaN(n) || n < 1 || n > last.options.length) return;
e.preventDefault();
const opt = last.options.find(o => o.n === n);
if (opt) void pickOption(opt);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [messages, pickOption, sending]);
if (presence === "subtle") {
return (
<div className="dock dock-subtle" title={meta.model ? `Σ via ${meta.model}` : "Σ Socrates"}>
<Sigil size={36} />
{messages.filter(m => m.role === "assistant").length > 0 && (
<div className="dock-subtle-count">{messages.filter(m => m.role === "assistant").length}</div>
)}
</div>
);
}
return (
<aside className={`dock ${presence === "prominent" ? "dock-prominent" : "dock-default"}`}>
<header className="dock-header">
<Sigil size={28} />
<div className="dock-header-text">
<div className="dock-name">Socrates</div>
<div className="dock-status">
<span className="dock-dot" />
{meta.provider ? `${meta.provider}${meta.model ? ` · ${meta.model.split("/").pop()}` : ""}` : "ready"}
</div>
</div>
<button className="dock-header-action" title="New thread (coming soon)" type="button" disabled>
+
</button>
</header>
<section className="dock-thread-wrap">
<div className="dock-section-label dock-section-label-inline">Active thread</div>
<div className="dock-thread">
{!loaded && (
<div className="bubble bubble-assistant">
<span className="bubble-sigil">Σ</span>
<span className="bubble-body">
<span className="bubble-text" style={{ opacity: 0.6 }}>loading</span>
</span>
</div>
)}
{messages.map(m => {
// Proposal card — render in place of a normal bubble
if (m.role === "proposal") {
if (m.proposal) {
return (
<ProposalCard
key={m.id}
proposal={m.proposal}
onAccept={() => acceptProposal(m.id, m.proposal!)}
onReject={() => rejectProposal(m.id)}
/>
);
}
// Pending or errored proposal — show a thin status line
return (
<div key={m.id} className="bubble bubble-socrates">
<span className="bubble-sigil">Σ</span>
<span className="bubble-body">
<span className="bubble-text" style={m.pending ? { opacity: 0.55, fontStyle: "italic" } : undefined}>
{m.text}
</span>
</span>
</div>
);
}
// System notes (apply confirmation, errors)
if (m.role === "system") {
return (
<div key={m.id} className="bubble bubble-system">
<span className="bubble-text">{m.text}</span>
</div>
);
}
// Normal user / assistant bubble
return (
<div key={m.id} className={`bubble bubble-${m.role === "assistant" ? "socrates" : "user"}`}>
{m.role === "assistant" && <span className="bubble-sigil">Σ</span>}
<span className="bubble-body">
<span className="bubble-text" style={m.pending ? { opacity: 0.55, fontStyle: "italic" } : undefined}>
{m.text}
</span>
{m.options && m.options.length > 0 && (
<div className="bubble-options">
{m.options.map(o => (
<button
key={o.n}
className="bubble-option"
type="button"
onClick={() => pickOption(o)}
disabled={sending}
>
<span className="bubble-option-num">{o.n}</span>
<span className="bubble-option-text">
<span className="bubble-option-label">{o.label}</span>
{o.sub && <span className="bubble-option-sub">{o.sub}</span>}
</span>
<span className="bubble-option-key">{o.n}</span>
</button>
))}
<div className="bubble-options-hint">
Press <kbd>1</kbd><kbd>{m.options.length}</kbd>, or type a reply
</div>
</div>
)}
</span>
</div>
);
})}
<div ref={threadEndRef} />
</div>
</section>
<footer className="dock-input-wrap">
<form
className="dock-input"
onSubmit={e => {
e.preventDefault();
void onSubmit();
}}
>
<span className="dock-input-prompt"></span>
<input
className="dock-input-field"
placeholder={sending ? "Socrates is thinking…" : "Reply to Socrates…"}
value={input}
onChange={e => setInput(e.target.value)}
disabled={sending}
/>
<button
type="button"
className="dock-input-propose"
onClick={() => void requestProposal()}
disabled={proposing || sending}
title="Ask Socrates to propose a model change"
>
propose
</button>
<button
type="submit"
className="dock-input-send"
disabled={sending || !input.trim()}
title="Send (⌘↵)"
>
</button>
</form>
</footer>
</aside>
);
}
function truncate(s: string, n: number): string {
return s.length > n ? s.slice(0, n - 1) + "…" : s;
}

View File

@@ -0,0 +1,168 @@
// TipTap/ProseMirror plugin: dotted-underline decoration on text spans whose
// surface form matches a taxonomy term label or synonym. Reads the term list
// from the editor's storage (set by TextCanvas via `editor.storage.terms`).
//
// Click → convert the underlined span into a chip via the editor's
// insertChip command. Hover → tooltip handled by CSS title attribute (we
// stash the definition there so we don't need a portal).
//
// Decorations only apply over plain text, never inside a chip node (atomic).
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
interface TermLite {
id: string;
label: string;
definition: string | null;
synonyms: string[];
linkedBlockId: string | null;
}
const KEY = new PluginKey("chip-suggestion");
export const ChipSuggestionExtension = Extension.create({
name: "chipSuggestion",
addProseMirrorPlugins() {
const editor = this.editor;
return [
new Plugin({
key: KEY,
state: {
init(_, state) {
const terms = readTerms(editor);
return buildDecorations(state.doc, terms);
},
apply(tr, oldSet, _oldState, newState) {
// Always rebuild on doc change OR when the editor signals that the
// term list might have changed (force-update meta, dispatched by
// TextCanvas after analyze runs).
if (tr.docChanged || tr.getMeta("force-update")) {
const terms = readTerms(editor);
return buildDecorations(newState.doc, terms);
}
return oldSet.map(tr.mapping, tr.doc);
},
},
props: {
decorations(state) {
return KEY.getState(state);
},
handleClick(view, _pos, ev) {
const target = ev.target as HTMLElement | null;
if (!target) return false;
const span = target.closest(".chip-suggestion") as HTMLElement | null;
if (!span) return false;
const from = parseInt(span.dataset.from ?? "", 10);
const to = parseInt(span.dataset.to ?? "", 10);
const termId = span.dataset.termId ?? "";
const label = span.dataset.label ?? span.innerText;
if (Number.isNaN(from) || Number.isNaN(to) || !termId) return false;
// Replace the span with a chip node.
const { tr } = view.state;
const chipType = view.state.schema.nodes.chip;
if (!chipType) return false;
tr.replaceWith(
from,
to,
chipType.create({ kind: "block", refId: termId, label })
);
view.dispatch(tr);
return true;
},
},
}),
];
},
});
function readTerms(editor: { storage: unknown }): TermLite[] {
const storage = editor.storage as Record<string, unknown>;
const raw = storage?.terms;
if (!Array.isArray(raw)) return [];
return (raw as TermLite[]).filter(t => t && typeof t.label === "string");
}
interface MatchSpec {
pattern: RegExp;
term: TermLite;
surface: string;
}
function buildPatterns(terms: TermLite[]): MatchSpec[] {
const specs: MatchSpec[] = [];
for (const t of terms) {
const surfaces = dedupe([t.label, ...t.synonyms]).filter(s => s.trim().length >= 2);
for (const s of surfaces) {
specs.push({
pattern: new RegExp(`\\b${escapeRegex(s)}\\b`, "gi"),
term: t,
surface: s,
});
}
}
// Match longer phrases first so "Socratic Tutor" wins over "Tutor".
specs.sort((a, b) => b.surface.length - a.surface.length);
return specs;
}
function buildDecorations(doc: import("@tiptap/pm/model").Node, terms: TermLite[]): DecorationSet {
if (terms.length === 0) return DecorationSet.empty;
const specs = buildPatterns(terms);
const decos: Decoration[] = [];
doc.descendants((node, pos) => {
if (!node.isText || !node.text) return;
// Skip text nodes that are inside a chip — chips are atomic, but be safe.
const text = node.text;
const occupied: Array<[number, number]> = []; // [start, end) within the text node
for (const spec of specs) {
spec.pattern.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = spec.pattern.exec(text)) !== null) {
const start = m.index;
const end = start + m[0].length;
if (overlaps(occupied, start, end)) continue;
occupied.push([start, end]);
const from = pos + start;
const to = pos + end;
decos.push(
Decoration.inline(from, to, {
class: "chip-suggestion",
"data-term-id": spec.term.id,
"data-label": spec.term.label,
"data-from": String(from),
"data-to": String(to),
title: spec.term.definition ?? "",
})
);
}
}
});
return DecorationSet.create(doc, decos);
}
function overlaps(ranges: Array<[number, number]>, a: number, b: number): boolean {
for (const [s, e] of ranges) if (a < e && b > s) return true;
return false;
}
function dedupe(xs: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const x of xs) {
const k = x.trim().toLowerCase();
if (!k || seen.has(k)) continue;
seen.add(k);
out.push(x.trim());
}
return out;
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -14,6 +14,8 @@ import type { ChipKind } from "../../lib/fixtures/aristotle";
import type { MarkupStyle } from "./Chip";
import { useChipFocus } from "./FocusContext";
import { useModelStore } from "../../lib/sync/ModelStore";
import { useAnalysis } from "../../lib/workspace/analysisStore";
import { useOpenPanes } from "../../lib/workspace/openPanesStore";
import { updateBlock, updateRequirement, updateAssociation } from "../../lib/sync/ops";
const KIND_LABEL: Record<ChipKind, string> = {
@@ -42,6 +44,8 @@ export function ChipView({ node, selected, editor }: NodeViewProps) {
const { focusBlockId, setFocusBlockId } = useChipFocus();
const { model, apply } = useModelStore();
const { getTerm } = useAnalysis();
const { setStack } = useOpenPanes();
// Resolve the live label from the model, falling back to the node's stored
// label (e.g. for chips created via slash-menu before they're bound).
@@ -88,7 +92,18 @@ export function ChipView({ node, selected, editor }: NodeViewProps) {
e.preventDefault();
if (refId) setIsEditing(true);
},
onClick: () => setFocusBlockId(refId),
onClick: () => {
// Single-click signals local diagram focus AND, if the chip refers
// to a term, opens that term as a Concepts → term column chain so
// the user lands in a coherent place.
setFocusBlockId(refId);
if (kind === "block" && getTerm(refId)) {
setStack([
{ kind: "section", id: "concepts" },
{ kind: "term", id: refId },
]);
}
},
}
: {};

View File

@@ -1,20 +0,0 @@
// TipTap extension wrapping the slash-menu suggestion plugin.
"use client";
import { Extension } from "@tiptap/core";
import Suggestion from "@tiptap/suggestion";
import { slashSuggestion } from "./slashSuggestion";
export const SlashExtension = Extension.create({
name: "slashMenu",
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...slashSuggestion,
}),
];
},
});

View File

@@ -1,110 +0,0 @@
// Slash menu — appears when the user types `/`. Shows the four chip kinds.
// On selection, inserts a chip with a placeholder label that the user can
// then rename inline.
"use client";
import { useEffect, useImperativeHandle, useState, forwardRef } from "react";
import type { ChipKind } from "../../lib/fixtures/aristotle";
export interface SlashItem {
kind: ChipKind;
label: string;
hint: string;
glyph: string;
}
export const SLASH_ITEMS: SlashItem[] = [
{ kind: "block", label: "Block", hint: "an entity in the system", glyph: "▢" },
{ kind: "property", label: "Property", hint: "an attribute of a block", glyph: "·" },
{ kind: "association", label: "Association", hint: "a relationship", glyph: "→" },
{ kind: "requirement", label: "Requirement", hint: "a stated goal (REQ-NNN)", glyph: "§" },
];
export interface SlashMenuHandle {
onKeyDown: (event: KeyboardEvent) => boolean;
}
interface SlashMenuProps {
query: string;
command: (item: SlashItem) => void;
}
export const SlashMenu = forwardRef<SlashMenuHandle, SlashMenuProps>(function SlashMenu(
{ query, command },
ref
) {
const filtered = SLASH_ITEMS.filter(
item =>
query.length === 0 ||
item.kind.toLowerCase().startsWith(query.toLowerCase()) ||
item.label.toLowerCase().startsWith(query.toLowerCase())
);
const [activeIndex, setActiveIndex] = useState(0);
useEffect(() => {
setActiveIndex(0);
}, [query]);
useImperativeHandle(ref, () => ({
onKeyDown(event: KeyboardEvent) {
if (filtered.length === 0) return false;
if (event.key === "ArrowUp") {
setActiveIndex(prev => (prev - 1 + filtered.length) % filtered.length);
return true;
}
if (event.key === "ArrowDown") {
setActiveIndex(prev => (prev + 1) % filtered.length);
return true;
}
if (event.key === "Enter" || event.key === "Tab") {
const choice = filtered[activeIndex];
if (choice) {
command(choice);
return true;
}
}
// Number-key shortcut: 14 picks the corresponding item
const num = parseInt(event.key, 10);
if (!Number.isNaN(num) && num >= 1 && num <= filtered.length) {
const choice = filtered[num - 1];
if (choice) {
command(choice);
return true;
}
}
return false;
},
}));
if (filtered.length === 0) {
return (
<div className="slash-menu slash-menu-empty">
<span className="slash-menu-empty-text">no matches for {query}</span>
</div>
);
}
return (
<div className="slash-menu">
{filtered.map((item, idx) => (
<button
key={item.kind}
type="button"
className={`slash-menu-item ${idx === activeIndex ? "slash-menu-item-active" : ""}`}
onMouseEnter={() => setActiveIndex(idx)}
onMouseDown={e => {
// mousedown so the click registers before the editor blurs
e.preventDefault();
command(item);
}}
>
<span className={`slash-menu-glyph slash-menu-glyph-${item.kind}`}>{item.glyph}</span>
<span className="slash-menu-label">{item.label}</span>
<span className="slash-menu-hint">{item.hint}</span>
<span className="slash-menu-key">{idx + 1}</span>
</button>
))}
</div>
);
});

View File

@@ -1,78 +1,129 @@
// TipTap-backed narrative editor.
// Renders the same prose surface as the static port, but typing actually works
// and chips can be inserted via the slash menu (`/block`, `/property`, etc.).
//
// After the pivot the text editor is the canonical surface. It loads its doc
// from /api/projects/[id]/document and saves on a 600ms debounce. Chips remain
// the rendered primitive but are no longer inserted via slash menu — the
// ChipSuggestionDecorator (added separately) suggests chip-ifying terms that
// match the project's taxonomy.
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { ChipNode } from "./ChipNode";
import { SlashExtension } from "./SlashExtension";
import { ChipFocusContext } from "./FocusContext";
import { fixtureToDoc } from "./fixtureToDoc";
import type { Density } from "../socrates/SocratesDock";
import { ChipSuggestionExtension } from "./ChipSuggestionExtension";
import type { FixtureData } from "../../lib/fixtures/aristotle";
import type { MarkupStyle } from "./Chip";
import { useAnalysis } from "../../lib/workspace/analysisStore";
export type Density = "comfortable" | "compact";
interface TextCanvasProps {
data: FixtureData;
density: Density;
markupStyle: MarkupStyle;
focusBlockId: string | null;
setFocusBlockId: (id: string | null) => void;
projectId: string;
density?: Density;
markupStyle?: MarkupStyle;
}
export function TextCanvas({ data, density, markupStyle, focusBlockId, setFocusBlockId }: TextCanvasProps) {
export function TextCanvas({ data, projectId, density = "comfortable", markupStyle = "color" }: TextCanvasProps) {
const padY = density === "compact" ? 10 : 18;
const padX = density === "compact" ? 22 : 36;
const focusValue = useMemo(
() => ({ focusBlockId, setFocusBlockId }),
[focusBlockId, setFocusBlockId]
);
const [focusBlockId, setFocusBlockId] = useState<string | null>(null);
const [initialDoc, setInitialDoc] = useState<unknown | null>(null);
const [loaded, setLoaded] = useState(false);
const focusValue = useMemo(() => ({ focusBlockId, setFocusBlockId }), [focusBlockId, setFocusBlockId]);
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2] },
// Drop features we don't need yet
codeBlock: false,
blockquote: false,
horizontalRule: false,
bulletList: false,
orderedList: false,
listItem: false,
strike: false,
code: false,
link: false,
}),
ChipNode,
SlashExtension,
],
content: fixtureToDoc(data),
editorProps: {
attributes: {
class: "text-canvas tiptap",
style: `padding: ${padY}px ${padX}px;`,
const { terms } = useAnalysis();
// Load the saved doc once on mount; fall back to the fixture if there is none.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/document`);
if (!res.ok) throw new Error(`load doc: ${res.status}`);
const body = (await res.json()) as { doc: unknown };
if (cancelled) return;
setInitialDoc(body.doc ?? fixtureToDoc(data));
} catch {
if (!cancelled) setInitialDoc(fixtureToDoc(data));
} finally {
if (!cancelled) setLoaded(true);
}
})();
return () => {
cancelled = true;
};
}, [projectId, data]);
const editor = useEditor(
{
immediatelyRender: false,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2] },
codeBlock: false,
blockquote: false,
horizontalRule: false,
bulletList: false,
orderedList: false,
listItem: false,
strike: false,
code: false,
link: false,
}),
ChipNode,
ChipSuggestionExtension,
],
content: initialDoc ?? null,
editorProps: {
attributes: {
class: "text-canvas tiptap",
style: `padding: ${padY}px ${padX}px;`,
},
},
},
});
[initialDoc]
);
// Push the markup style into the editor so the ChipView NodeView can read it.
// Push markup style + terms into the editor storage so node-views read them.
useEffect(() => {
if (!editor) return;
(editor.storage as unknown as Record<string, unknown>).markupStyle = markupStyle;
// Force a re-render of all chip node views so they pick up the new style.
const storage = editor.storage as unknown as Record<string, unknown>;
storage.markupStyle = markupStyle;
storage.terms = terms;
editor.view.dispatch(editor.state.tr.setMeta("force-update", true));
}, [editor, markupStyle]);
}, [editor, markupStyle, terms]);
if (!editor) {
// Debounced save on document change.
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!editor) return;
const onUpdate = () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
const doc = editor.getJSON();
void fetch(`/api/projects/${encodeURIComponent(projectId)}/document`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ doc }),
}).catch(err => console.error("[TextCanvas] save failed:", err));
}, 600);
};
editor.on("update", onUpdate);
return () => {
editor.off("update", onUpdate);
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, [editor, projectId]);
if (!loaded || !editor) {
return (
<div className="text-canvas" style={{ padding: `${padY}px ${padX}px` }}>
<div style={{ color: "var(--muted)", fontFamily: "var(--font-mono)", fontSize: 12 }}>
loading editor
</div>
<div style={{ color: "var(--muted)", fontFamily: "var(--font-mono)", fontSize: 12 }}>loading editor</div>
</div>
);
}
@@ -80,15 +131,6 @@ export function TextCanvas({ data, density, markupStyle, focusBlockId, setFocusB
return (
<ChipFocusContext.Provider value={focusValue}>
<EditorContent editor={editor} />
<div className="margin-note" style={{ margin: `0 ${padX}px ${padY}px ${padX}px`, maxWidth: 720 }}>
<span className="margin-note-glyph">Σ</span>
<span>
<span className="margin-note-who">Socrates · margin</span>
<span className="margin-note-text">
&ldquo;Refuses to produce solutions&rdquo; is a strong constraint. Have you decided what counts as a &ldquo;solution&rdquo; vs. a &ldquo;scaffold&rdquo;? This boundary will determine whether the refusal policy is enforceable.
</span>
</span>
</div>
</ChipFocusContext.Provider>
);
}

View File

@@ -1,92 +0,0 @@
// Suggestion plugin config that wires the slash menu into TipTap.
// Renders SlashMenu in a fixed-position floating panel near the caret.
"use client";
import type { Editor, Range } from "@tiptap/core";
import type { SuggestionOptions, SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion";
import { createRoot, type Root } from "react-dom/client";
import { createElement, createRef } from "react";
import { SlashMenu, SLASH_ITEMS, type SlashItem, type SlashMenuHandle } from "./SlashMenu";
export const slashSuggestion: Omit<SuggestionOptions<SlashItem, SlashItem>, "editor"> = {
char: "/",
startOfLine: false,
allowSpaces: false,
items: ({ query }) =>
SLASH_ITEMS.filter(
item =>
query.length === 0 ||
item.kind.toLowerCase().startsWith(query.toLowerCase()) ||
item.label.toLowerCase().startsWith(query.toLowerCase())
),
command: ({ editor, range, props }: { editor: Editor; range: Range; props: SlashItem }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertChip({
kind: props.kind,
refId: null,
label: props.kind === "requirement" ? "REQ-001" : "untitled",
})
.run();
},
render: () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
const handleRef = createRef<SlashMenuHandle>();
function position(rect: DOMRect | null) {
if (!container || !rect) return;
container.style.position = "fixed";
container.style.top = `${rect.bottom + 6}px`;
container.style.left = `${rect.left}px`;
container.style.zIndex = "1000";
}
function rerender(props: SuggestionProps<SlashItem>) {
if (!root) return;
root.render(
createElement(SlashMenu, {
ref: handleRef,
query: props.query,
command: (item: SlashItem) => props.command(item),
})
);
}
return {
onStart(props) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
rerender(props);
position(props.clientRect?.() ?? null);
},
onUpdate(props) {
rerender(props);
position(props.clientRect?.() ?? null);
},
onKeyDown(props: SuggestionKeyDownProps) {
if (props.event.key === "Escape") {
props.event.preventDefault();
return true;
}
return handleRef.current?.onKeyDown(props.event) ?? false;
},
onExit() {
if (root) root.unmount();
if (container && container.parentNode) container.parentNode.removeChild(container);
root = null;
container = null;
},
};
},
};