Pivot to text-first column-stack workspace + merge-with-review across AI artifacts
Workspace - Pivot from "set of open panes" to a Finder-style miller column stack: TopBar / LeftSidebar / [section → entity → entity ...] / pinned text editor. openPanesStore is now an ordered Column[] with pushFrom / closeFrom / setStack; only one top-level section is rooted at a time. - New entity column panes: Term, Block, Association, Constraint, Requirement, Finding. Click-through navigation truncates deeper columns automatically. - LeftSidebar surfaces a pending-count chip per section (single-glance navigation cue) and spins its analyze ↻ via SVG Spinner whenever the LLM is working — including server-initiated runs caught by the runs poll, not just user-triggered ones. Analyze pipeline + persistence - Unified `concepts` pass (taxonomy + glossary in one LLM call) replaces the two-pass setup. Server still accepts ?section=taxonomy|glossary and normalizes them for back-compat. - model / requirements / detection (assumptions, risks, inconsistencies) + cross-layer validation rules (X1–X4: stale term link, unlinked formalism, undefined linked term, prose-only term). - Persistence: NarrativeDocument, ModelSnapshot, ChangelogEntry, TaxonomyTerm, RequirementEntry, Finding, AnalysisRun. Re-runs MERGE instead of replace: gentle update on existing items, suggested on new, deprecated on missing — same idiom for every artifact kind. User pins preserve "kept" decisions across re-analyses. - Migrations: pivot_text_first, add_requirement_linked_term, term_review_state, review_state_for_reqs_and_findings, add_term_definition_pinned. Concept ↔ ontology integration - linkedTermId on Block / Association / Constraint / Requirement. PromoteToolbar lets the user formalize a concept inline: + Block / + Association / + Constraint / + Requirement, all routed through applyOps so undo/redo and SSE work for free. - decideElement op for in-canvas keep/discard on review-pending model elements. User-authored definitions - TermColumn definition is click-to-edit. Save (Cmd-Enter / blur), Cancel (Esc), Reset to AI suggestion when pinned. - definitionPinned flag on TaxonomyTerm: future Analyze runs leave the user's text alone. setTermDefinition repo function + POST /api/projects/[id]/terms/[termId]/definition endpoint. - mergeTaxonomySuggestion + applyGlossaryDefinitions both pin-aware. UX/UI - StatusChip: single component for all state idioms (suggested, deprecated, accepted, dismissed, resolved, severity, validation code, confidence, warn). Replaces 5+ ad-hoc badge classes. - PaneControls (PaneViewTabs + PaneFilterChip): separates view-mode toggles from filter chips so toggling Pending no longer flips you off the current view. - PaneEmpty: unified empty-state with title + hint + action. - PaneDrawer: collapsible groups for Pending / Discarded review; cards group as Kept (top) → Pending (bottom drawer) → Discarded (Findings only, hidden when empty). Restore action recovers dismissed/resolved findings. - ConceptCard unifies Tree and A–Z views in Concepts; only Tree parents carry the chevron (no empty placeholder offset). - Type + spacing tokens (--text-xs..xl, --space-1..6, --lh-tight/ui/ prose, --radius-*) replace every ad-hoc value. - Buttons standardized to body sans 500 (was a mishmash of mono / display). - Card shells unified across Concepts / Requirements / Findings. Cleanup - Removed: LeftRail, FindingsPanel, IssuesPanel, SocratesDock, ProposalCard, SlashMenu, SlashExtension, slashSuggestion, CanvasHeader, TaxonomyPane, GlossaryPane, TermDetail (popover; now TermColumn). - Section ids in openPanesStore: dropped taxonomy/glossary, added concepts. localStorage migration runs on hydrate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,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 3–10 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 3–10. 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 3–10. 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user