Files
Socrates/apps/web/components/seed/SeedScreen.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

315 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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>
);
}