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>
315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
// 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 InterviewTurn {
|
||
role: "socrates" | "user";
|
||
text: string;
|
||
pending?: boolean;
|
||
}
|
||
|
||
interface SeedDraft {
|
||
title: string;
|
||
problem: string;
|
||
targetUser: string;
|
||
desiredOutcome: string;
|
||
initialHypothesis?: string;
|
||
constraints?: string[];
|
||
}
|
||
|
||
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">
|
||
<div className="seed-top-left">
|
||
<Sigil size={28} />
|
||
<span className="seed-brand">Socrata</span>
|
||
<span className="seed-pip">·</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">
|
||
<a href="/" className="seed-mode-pill" style={{ textDecoration: "none" }}>← back</a>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="seed-body">
|
||
<section className="seed-left">
|
||
<div className="seed-section-label">Emerging seed</div>
|
||
<div className="seed-fields">
|
||
<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>
|
||
)}
|
||
</div>
|
||
|
||
<div className="seed-confidence">
|
||
<div className="seed-confidence-row">
|
||
<span>Draft confidence</span>
|
||
<span>{confidencePct}%</span>
|
||
</div>
|
||
<div className="seed-confidence-bar">
|
||
<div className="seed-confidence-fill" style={{ width: `${confidencePct}%` }} />
|
||
</div>
|
||
<div className="seed-confidence-hint">
|
||
{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>
|
||
|
||
<section className="seed-right">
|
||
<div className="seed-thread">
|
||
{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.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 ref={threadEndRef} />
|
||
</div>
|
||
|
||
<form
|
||
className="seed-input-row"
|
||
onSubmit={e => {
|
||
e.preventDefault();
|
||
void onSubmit();
|
||
}}
|
||
>
|
||
<div className="seed-input">
|
||
<span className="seed-input-prompt">›</span>
|
||
<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
|
||
type="submit"
|
||
className="seed-btn seed-btn-primary"
|
||
disabled={sending || generating || !input.trim()}
|
||
>
|
||
Send · ⌘↵
|
||
</button>
|
||
</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>
|
||
);
|
||
}
|