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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 00:12:06 +02:00

392 lines
12 KiB
TypeScript

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