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

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

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

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

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

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

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

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

View File

@@ -1,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>
);
}