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

@@ -0,0 +1,33 @@
// Analyze endpoint. POST with optional ?section= to run one section, or no
// query param (or ?section=all) to run the full pipeline.
//
// Legacy section names "taxonomy" and "glossary" are accepted and mapped to
// the unified "concepts" pass (T3 of the integration plan). This keeps older
// clients and any external scripts working through one release.
import { NextResponse } from "next/server";
import { runAnalyze, type RunSection } from "../../../../../lib/llm/analyze/runAll";
import { normalizeAnalysisSection } from "../../../../../lib/db/repo";
interface RouteContext {
params: Promise<{ projectId: string }>;
}
export async function POST(req: Request, ctx: RouteContext) {
const { projectId } = await ctx.params;
const url = new URL(req.url);
const sectionRaw = (url.searchParams.get("section") ?? "all").toLowerCase();
let section: RunSection;
try {
section = normalizeAnalysisSection(sectionRaw) as RunSection;
} catch {
return NextResponse.json({ error: `Invalid section: ${sectionRaw}` }, { status: 400 });
}
try {
const result = await runAnalyze(projectId, section);
return NextResponse.json(result);
} catch (err) {
console.error("[analyze] route failed:", err);
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
}
}

View File

@@ -0,0 +1,31 @@
// Narrative document save endpoint. The TipTap editor PUTs the current
// ProseMirror JSON here on debounce. Source of truth after the pivot.
import { NextResponse } from "next/server";
import { loadDocument, saveDocument } from "../../../../../lib/db/repo";
interface RouteContext {
params: Promise<{ projectId: string }>;
}
export async function GET(_req: Request, ctx: RouteContext) {
const { projectId } = await ctx.params;
const doc = await loadDocument(projectId);
if (!doc) return NextResponse.json({ doc: null, version: 0 });
return NextResponse.json({ doc: doc.doc, version: doc.version, updatedAt: doc.updatedAt });
}
export async function PUT(req: Request, ctx: RouteContext) {
const { projectId } = await ctx.params;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body || typeof body !== "object" || !("doc" in body)) {
return NextResponse.json({ error: "Missing `doc` field" }, { status: 400 });
}
const stored = await saveDocument(projectId, (body as { doc: unknown }).doc);
return NextResponse.json({ version: stored.version, updatedAt: stored.updatedAt });
}

View File

@@ -0,0 +1,51 @@
// Per-finding keep/discard/resolve/restore decision.
//
// Body: { decision: "keep" | "discard" | "resolve" | "restore" }
// keep → status="accepted" (visible). If was "deprecated", also pinned.
// discard → status="dismissed". Persists; future detections skip it.
// resolve → status="resolved". Like discard but signals "I fixed it."
// restore → status="accepted", pinned cleared. Recovers a dismissed/
// resolved finding from the Discarded drawer.
import { NextResponse } from "next/server";
import {
setFindingDecision,
restoreFinding,
type FindingDecision,
} from "../../../../../../../lib/db/repo";
interface RouteContext {
params: Promise<{ projectId: string; findingId: string }>;
}
export async function POST(req: Request, ctx: RouteContext) {
const { findingId } = await ctx.params;
let body: { decision?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid json body" }, { status: 400 });
}
const decision = body.decision;
if (
decision !== "keep" &&
decision !== "discard" &&
decision !== "resolve" &&
decision !== "restore"
) {
return NextResponse.json(
{ error: "decision must be 'keep', 'discard', 'resolve', or 'restore'" },
{ status: 400 }
);
}
try {
const finding =
decision === "restore"
? await restoreFinding(findingId)
: await setFindingDecision(findingId, decision as FindingDecision);
return NextResponse.json({ finding });
} catch (err) {
console.error("[finding/decision] failed:", err);
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
}
}

View File

@@ -0,0 +1,81 @@
// Per-finding contextual Socrates thread.
// GET → loads existing messages.
// POST → sends a user turn; if the thread is empty, primes it with the
// finding text so Socrates' opening turn engages with the finding.
import { NextResponse } from "next/server";
import { prisma } from "../../../../../../../lib/db/client";
import { loadProject } from "../../../../../../../lib/db/repo";
import { validate } from "../../../../../../../lib/sysml/validate";
import {
getOrCreateAnchoredThread,
listAnchoredThreadMessages,
sendUserTurn,
} from "../../../../../../../lib/llm/socrates";
interface RouteContext {
params: Promise<{ projectId: string; findingId: string }>;
}
export async function GET(_req: Request, ctx: RouteContext) {
const { projectId, findingId } = await ctx.params;
const finding = await prisma.finding.findUnique({ where: { id: findingId } });
if (!finding || finding.projectId !== projectId) {
return NextResponse.json({ error: "finding not found" }, { status: 404 });
}
const threadId = await getOrCreateAnchoredThread(projectId, `finding:${findingId}`, finding.text);
const messages = await listAnchoredThreadMessages(threadId);
return NextResponse.json({ threadId, finding: { id: finding.id, text: finding.text, kind: finding.kind }, messages });
}
export async function POST(req: Request, ctx: RouteContext) {
const { projectId, findingId } = await ctx.params;
let body: { text?: string } = {};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const finding = await prisma.finding.findUnique({ where: { id: findingId } });
if (!finding || finding.projectId !== projectId) {
return NextResponse.json({ error: "finding not found" }, { status: 404 });
}
const threadId = await getOrCreateAnchoredThread(projectId, `finding:${findingId}`, finding.text);
// If the thread is empty, prime with the finding text as the opening user
// turn so Socrates engages with the right thing.
const existing = await listAnchoredThreadMessages(threadId);
let userText = (body.text ?? "").trim();
if (existing.length === 0 && !userText) {
userText = `Discuss this ${finding.kind}: "${finding.text}"`;
}
const { model } = await loadProject(projectId);
const issues = validate(model);
const result = await sendUserTurn({ threadId, model, issues, userText });
return NextResponse.json({ threadId, ...result });
}
export async function PATCH(req: Request, ctx: RouteContext) {
// Resolve / dismiss the finding from the contextual thread.
const { projectId, findingId } = await ctx.params;
let body: { status?: string } = {};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const status = body.status;
if (status !== "resolved" && status !== "dismissed" && status !== "open") {
return NextResponse.json({ error: "invalid status" }, { status: 400 });
}
const finding = await prisma.finding.findUnique({ where: { id: findingId } });
if (!finding || finding.projectId !== projectId) {
return NextResponse.json({ error: "finding not found" }, { status: 404 });
}
await prisma.finding.update({ where: { id: findingId }, data: { status } });
return NextResponse.json({ ok: true, status });
}

View File

@@ -1,27 +1,51 @@
// GET /api/projects/[id]/findings — returns the project's open findings // GET /api/projects/[id]/findings — returns the project's currently visible
// POST /api/projects/[id]/findings — runs detection, persists, returns the new set // findings (suggested + accepted + deprecated)
// POST /api/projects/[id]/findings — runs detection, MERGES (no wipe), returns
// the new set. Suggestions and deprecations
// await user keep/discard via the
// /findings/[findingId]/decision endpoint.
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { loadProject, listOpenFindings, replaceFindings } from "../../../../../lib/db/repo"; import {
loadProject,
listVisibleFindings,
listDiscardedFindings,
mergeFindingsSuggestion,
} from "../../../../../lib/db/repo";
import { detectFindings } from "../../../../../lib/llm/detect"; import { detectFindings } from "../../../../../lib/llm/detect";
export async function GET(_req: Request, { params }: { params: Promise<{ projectId: string }> }) { function toApi(f: Awaited<ReturnType<typeof listVisibleFindings>>[number]) {
return {
id: f.id,
kind: f.kind,
text: f.text,
linkedElementIds: f.linkedElementIds,
confidence: f.confidence,
severity: f.severity,
validationCode: f.validationCode,
status: f.status,
pinned: f.pinned,
modelVersion: f.modelVersion,
provider: f.provider,
llmModel: f.llmModel,
};
}
export async function GET(req: Request, { params }: { params: Promise<{ projectId: string }> }) {
const { projectId } = await params; const { projectId } = await params;
const findings = await listOpenFindings(projectId); // ?include=all merges in dismissed + resolved so the FindingsPane Discarded
return NextResponse.json({ // drawer can render them. Default stays visible-only for back-compat.
findings: findings.map(f => ({ const url = new URL(req.url);
id: f.id, const include = (url.searchParams.get("include") ?? "").toLowerCase();
kind: f.kind, if (include === "all") {
text: f.text, const [visible, discarded] = await Promise.all([
linkedElementIds: f.linkedElementIds, listVisibleFindings(projectId),
confidence: f.confidence, listDiscardedFindings(projectId),
severity: f.severity, ]);
validationCode: f.validationCode, return NextResponse.json({ findings: [...visible, ...discarded].map(toApi) });
modelVersion: f.modelVersion, }
provider: f.provider, const findings = await listVisibleFindings(projectId);
llmModel: f.llmModel, return NextResponse.json({ findings: findings.map(toApi) });
})),
});
} }
export async function POST(_req: Request, { params }: { params: Promise<{ projectId: string }> }) { export async function POST(_req: Request, { params }: { params: Promise<{ projectId: string }> }) {
@@ -29,21 +53,16 @@ export async function POST(_req: Request, { params }: { params: Promise<{ projec
try { try {
const { model, version } = await loadProject(projectId); const { model, version } = await loadProject(projectId);
const result = await detectFindings(model); const result = await detectFindings(model);
const stored = await replaceFindings(projectId, result.findings, version, result.provider, result.model); const stored = await mergeFindingsSuggestion(
projectId,
result.findings,
version,
result.provider,
result.model
);
return NextResponse.json({ return NextResponse.json({
findings: stored.map(f => ({ findings: stored.map(toApi),
id: f.id,
kind: f.kind,
text: f.text,
linkedElementIds: f.linkedElementIds,
confidence: f.confidence,
severity: f.severity,
validationCode: f.validationCode,
modelVersion: f.modelVersion,
provider: f.provider,
llmModel: f.llmModel,
})),
meta: { meta: {
provider: result.provider, provider: result.provider,
model: result.model, model: result.model,

View File

@@ -0,0 +1,35 @@
// Per-requirement keep/discard decision. Mirrors the term decision endpoint
// (see /api/projects/[id]/terms/[termId]/decision).
//
// Body: { decision: "keep" | "discard" }
import { NextResponse } from "next/server";
import { setRequirementDecision, type RequirementDecision } from "../../../../../../../lib/db/repo";
interface RouteContext {
params: Promise<{ projectId: string; reqId: string }>;
}
export async function POST(req: Request, ctx: RouteContext) {
const { reqId } = await ctx.params;
let body: { decision?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid json body" }, { status: 400 });
}
const decision = body.decision;
if (decision !== "keep" && decision !== "discard") {
return NextResponse.json(
{ error: "decision must be 'keep' or 'discard'" },
{ status: 400 }
);
}
try {
const requirement = await setRequirementDecision(reqId, decision as RequirementDecision);
return NextResponse.json({ requirement });
} catch (err) {
console.error("[requirement/decision] failed:", err);
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
}
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { listRequirements } from "../../../../../lib/db/repo";
interface RouteContext {
params: Promise<{ projectId: string }>;
}
export async function GET(_req: Request, ctx: RouteContext) {
const { projectId } = await ctx.params;
const reqs = await listRequirements(projectId);
return NextResponse.json({ requirements: reqs });
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { listLatestAnalysisRuns } from "../../../../../lib/db/repo";
interface RouteContext {
params: Promise<{ projectId: string }>;
}
export async function GET(_req: Request, ctx: RouteContext) {
const { projectId } = await ctx.params;
const runs = await listLatestAnalysisRuns(projectId);
return NextResponse.json({ runs });
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { listTerms } from "../../../../../lib/db/repo";
interface RouteContext {
params: Promise<{ projectId: string }>;
}
export async function GET(_req: Request, ctx: RouteContext) {
const { projectId } = await ctx.params;
const terms = await listTerms(projectId);
return NextResponse.json({ terms });
}

View File

@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { linkTermToBlock } from "../../../../../lib/db/repo";
interface RouteContext {
params: Promise<{ projectId: string }>;
}
export async function POST(req: Request, _ctx: RouteContext) {
let body: { termId?: string; blockId?: string | null } = {};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body.termId) {
return NextResponse.json({ error: "Missing termId" }, { status: 400 });
}
await linkTermToBlock(body.termId, body.blockId ?? null);
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,39 @@
// Per-term keep/discard decision. Drives the merge-with-review flow:
// `mergeTaxonomySuggestion` marks pending changes; this endpoint confirms or
// rejects them.
//
// Body: { decision: "keep" | "discard" }
// keep → status="accepted"; if previously "deprecated", also pinned=true
// so the next merge doesn't re-deprecate it.
// discard → row deleted.
import { NextResponse } from "next/server";
import { setTermDecision, type TermDecision } from "../../../../../../../lib/db/repo";
interface RouteContext {
params: Promise<{ projectId: string; termId: string }>;
}
export async function POST(req: Request, ctx: RouteContext) {
const { termId } = await ctx.params;
let body: { decision?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid json body" }, { status: 400 });
}
const decision = body.decision;
if (decision !== "keep" && decision !== "discard") {
return NextResponse.json(
{ error: "decision must be 'keep' or 'discard'" },
{ status: 400 }
);
}
try {
const term = await setTermDecision(termId, decision as TermDecision);
return NextResponse.json({ term });
} catch (err) {
console.error("[term/decision] failed:", err);
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
}
}

View File

@@ -0,0 +1,35 @@
// User-authored definition for a concept term.
//
// Body: { definition: string | null }
// non-empty trimmed → set definition + pin (future Analyze leaves it alone)
// empty / null → clear definition + clear pin ("Reset to AI suggestion")
import { NextResponse } from "next/server";
import { setTermDefinition } from "../../../../../../../lib/db/repo";
interface RouteContext {
params: Promise<{ projectId: string; termId: string }>;
}
export async function POST(req: Request, ctx: RouteContext) {
const { termId } = await ctx.params;
let body: { definition?: string | null };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid json body" }, { status: 400 });
}
if (body.definition !== null && typeof body.definition !== "string" && typeof body.definition !== "undefined") {
return NextResponse.json(
{ error: "definition must be a string or null" },
{ status: 400 }
);
}
try {
const term = await setTermDefinition(termId, body.definition ?? null);
return NextResponse.json({ term });
} catch (err) {
console.error("[term/definition] failed:", err);
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
}
}

View File

@@ -0,0 +1,18 @@
// GET /api/projects — list projects on this server.
import { NextResponse } from "next/server";
import { listProjects } from "../../../lib/db/repo";
export async function GET() {
const projects = await listProjects();
return NextResponse.json({
projects: projects.map(p => ({
id: p.id,
name: p.name,
scope: p.scope,
tagline: p.tagline,
updatedAt: p.updatedAt.toISOString(),
latestVersion: p.latestVersion,
})),
});
}

View File

@@ -0,0 +1,51 @@
// POST /api/seed/finalize
//
// Pivot flow: turn the SeedDraft into structured prose, persist it as the
// NarrativeDocument, create an empty model snapshot, and return the new
// project id so the client can navigate to /editor/[id]. The analyze
// pipeline runs on first paint of the editor (or via Analyze All).
import { NextResponse } from "next/server";
import { createProjectFromSeed, saveDocument } from "../../../../lib/db/repo";
import { seedToProseDoc } from "../../../../lib/llm/seedToProse";
import { runAnalyze } from "../../../../lib/llm/analyze/runAll";
import type { SeedDraft } from "../../../../lib/llm/seedInterview";
import type { SysMLModel } from "../../../../lib/sysml/model";
export async function POST(req: Request) {
let body: { draft?: SeedDraft };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid json body" }, { status: 400 });
}
const draft = body.draft;
if (!draft || !draft.problem || !draft.targetUser || !draft.desiredOutcome) {
return NextResponse.json(
{ error: "draft must include problem, targetUser, desiredOutcome" },
{ status: 400 }
);
}
try {
const emptyModel: SysMLModel = { blocks: [], associations: [], constraints: [], requirements: [] };
const created = await createProjectFromSeed({
name: draft.title?.trim() || "Untitled idea",
scope: "Untitled scope",
tagline: draft.problem.slice(0, 80),
model: emptyModel,
});
await saveDocument(created.projectId, seedToProseDoc(draft));
// Kick off the full Analyze pipeline in the background so the editor lands
// populated. Errors are logged but do not block the redirect — the user
// can hit Analyze All again from the TopBar if anything goes sideways.
void runAnalyze(created.projectId, "all").catch(err =>
console.error(`[seed/finalize] background analyze failed for ${created.projectId}:`, err)
);
return NextResponse.json({ projectId: created.projectId, version: created.version });
} catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
}
}

View File

@@ -0,0 +1,44 @@
// POST /api/seed/turn
//
// Per-turn handler for the live seed interview. Stateless — client passes
// the running thread + draft + user text; we return the next assistant turn
// + an updated draft + a `ready` flag.
import { NextResponse } from "next/server";
import { interviewStep, type InterviewTurn, type SeedDraft } from "../../../../lib/llm/seedInterview";
export async function POST(req: Request) {
let body: { history?: InterviewTurn[]; userText?: string; draft?: SeedDraft };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid json body" }, { status: 400 });
}
const history = Array.isArray(body.history) ? body.history : [];
const draft: SeedDraft = body.draft ?? {
title: "",
problem: "",
targetUser: "",
desiredOutcome: "",
};
const userText = body.userText ?? "";
try {
const result = await interviewStep({ history, userText, draft });
return NextResponse.json({
assistant: { text: result.text },
draft: result.draft,
confidence: result.confidence,
ready: result.ready,
meta: {
provider: result.provider,
model: result.model,
inputTokens: result.inputTokens,
outputTokens: result.outputTokens,
},
});
} catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
}
}

View File

@@ -1,10 +1,67 @@
import { notFound } from "next/navigation";
import { prisma } from "../../../lib/db/client";
import { EditorShell } from "../../../components/editor/EditorShell"; import { EditorShell } from "../../../components/editor/EditorShell";
import { aristotleFixture } from "../../../lib/fixtures/aristotle"; import { aristotleFixture } from "../../../lib/fixtures/aristotle";
import { loadProject, ensureAristotleSeeded, ProjectNotFoundError } from "../../../lib/db/repo";
// M1: every projectId resolves to the Aristotle fixture. // Server component: loads the latest model + project metadata. Auto-seeds the
// M4M5 wire this to a real database lookup. // Aristotle demo for the literal "aristotle" id; any other unknown id 404s.
// (EditorShell wraps itself in Suspense for useSearchParams; no boundary export default async function EditorPage(props: { params: Promise<{ projectId: string }> }) {
// needed at this level.) const { projectId } = await props.params;
export default async function EditorPage(_props: { params: Promise<{ projectId: string }> }) {
return <EditorShell data={aristotleFixture} />; if (projectId === "aristotle") await ensureAristotleSeeded();
let model, version;
try {
({ model, version } = await loadProject(projectId));
} catch (err) {
if (err instanceof ProjectNotFoundError) notFound();
throw err;
}
const projectRow = await prisma.project.findUnique({ where: { id: projectId } });
if (!projectRow) notFound();
// The narrative is fixture-derived for the Aristotle demo only. For any
// other project we render a near-empty fixture so the prose surface
// doesn't lie about the project's actual content. (Future polish: have
// the seed-finalize step also produce an opening narrative.)
const isAristotle = projectId === "aristotle";
const fixtureForUi = isAristotle
? aristotleFixture
: {
...aristotleFixture,
project: {
...aristotleFixture.project,
name: projectRow.name,
scope: projectRow.scope,
tagline: projectRow.tagline,
},
narrative: [
{ type: "h1" as const, text: projectRow.name },
{
type: "p" as const,
children: [
{ t: "text" as const, v: projectRow.tagline },
],
},
{ type: "h2" as const, text: "Notes" },
{
type: "p" as const,
children: [
{ t: "text" as const, v: "Start writing — slash to insert chips that reference your blocks, requirements, and constraints." },
],
},
],
socratesThread: [], // dock loads from DB anyway
};
return (
<EditorShell
data={fixtureForUi}
initialModel={model}
initialVersion={version}
projectId={projectId}
/>
);
} }

View File

@@ -1,6 +1,11 @@
import Link from "next/link"; // Landing page — list existing projects + entry point to start a new one.
import Link from "next/link";
import { listProjects } from "../lib/db/repo";
export default async function HomePage() {
const projects = await listProjects();
export default function HomePage() {
return ( return (
<div style={{ padding: "60px 80px", maxWidth: 720, margin: "0 auto", lineHeight: 1.55 }}> <div style={{ padding: "60px 80px", maxWidth: 720, margin: "0 auto", lineHeight: 1.55 }}>
<h1 style={{ fontFamily: "var(--font-display)", fontSize: 32, fontWeight: 600, marginBottom: 8 }}> <h1 style={{ fontFamily: "var(--font-display)", fontSize: 32, fontWeight: 600, marginBottom: 8 }}>
@@ -9,42 +14,108 @@ export default function HomePage() {
<p style={{ color: "var(--muted)", marginBottom: 32 }}> <p style={{ color: "var(--muted)", marginBottom: 32 }}>
Structured thinking and validation platform for product managers. Structured thinking and validation platform for product managers.
</p> </p>
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "grid", gap: 12 }}>
<li> <div style={{ marginBottom: 32 }}>
<Link <Link
href="/editor/aristotle" href="/seed"
style={{ style={{
display: "inline-block", display: "inline-block",
padding: "12px 18px", padding: "12px 18px",
border: "1px solid var(--border-strong)", border: "1px solid var(--accent)",
borderRadius: 6, background: "var(--accent)",
color: "var(--accent)", color: "var(--accent-on)",
fontFamily: "var(--font-mono)", borderRadius: 6,
fontSize: 13, fontFamily: "var(--font-mono)",
textDecoration: "none", fontSize: 13,
}} textDecoration: "none",
> }}
Open editor (Aristotle fixture) >
</Link> Start a new idea
</li> </Link>
<li> </div>
<Link
href="/seed" <div>
style={{ <h2 style={{
display: "inline-block", fontFamily: "var(--font-mono)",
padding: "12px 18px", fontSize: 11,
border: "1px solid var(--border)", textTransform: "uppercase",
borderRadius: 6, letterSpacing: "0.10em",
color: "var(--muted)", color: "var(--muted)",
fontFamily: "var(--font-mono)", marginBottom: 12,
fontSize: 13, }}>
textDecoration: "none", {projects.length === 0 ? "no projects yet" : `${projects.length} project${projects.length === 1 ? "" : "s"}`}
}} </h2>
>
Seed screen (coming next) {projects.length === 0 ? (
</Link> <p style={{ color: "var(--muted)", fontStyle: "italic" }}>
</li> Click "Start a new idea" above to begin a Socratic interview that bootstraps a fresh project.
</ul> </p>
) : (
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "grid", gap: 8 }}>
{projects.map(p => (
<li key={p.id}>
<Link
href={`/editor/${encodeURIComponent(p.id)}`}
style={{
display: "block",
padding: "12px 14px",
border: "1px solid var(--border)",
borderRadius: 6,
color: "var(--fg)",
textDecoration: "none",
background: "var(--surface)",
}}
>
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
<span style={{
fontFamily: "var(--font-display)",
fontWeight: 600,
fontSize: 14,
color: "var(--accent-strong)",
}}>{p.name}</span>
<span style={{
fontFamily: "var(--font-mono)",
fontSize: 10.5,
color: "var(--muted)",
}}>
{p.scope} · v{p.latestVersion}
</span>
<span style={{
marginLeft: "auto",
fontFamily: "var(--font-mono)",
fontSize: 10.5,
color: "var(--muted)",
}}>
{timeAgo(new Date(p.updatedAt))}
</span>
</div>
{p.tagline && (
<div style={{
fontSize: 12.5,
color: "var(--muted-strong)",
marginTop: 4,
lineHeight: 1.45,
}}>
{p.tagline}
</div>
)}
</Link>
</li>
))}
</ul>
)}
</div>
</div> </div>
); );
} }
function timeAgo(d: Date): string {
const sec = Math.floor((Date.now() - d.getTime()) / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
return `${day}d ago`;
}

View File

@@ -56,7 +56,7 @@ import {
type ModelOp, type ModelOp,
} from "../../lib/sync/ops"; } from "../../lib/sync/ops";
import type { ValidationIssue } from "../../lib/sysml/validate"; import type { ValidationIssue } from "../../lib/sysml/validate";
import type { Density } from "../socrates/SocratesDock"; import type { Density } from "../../lib/workspace/types";
import type { FixtureData, BlockKind } from "../../lib/fixtures/aristotle"; import type { FixtureData, BlockKind } from "../../lib/fixtures/aristotle";
import type { Block, Property, PropertyType, SysMLModel } from "../../lib/sysml/model"; import type { Block, Property, PropertyType, SysMLModel } from "../../lib/sysml/model";
@@ -69,6 +69,8 @@ interface DiagramCanvasProps {
focusBlockId: string | null; focusBlockId: string | null;
onSelect?: (id: string | null) => void; onSelect?: (id: string | null) => void;
issuesByElement?: Map<string, ValidationIssue[]>; issuesByElement?: Map<string, ValidationIssue[]>;
/** When set, term-drop on the canvas links the chosen term server-side. */
projectId?: string;
} }
const nodeTypes = { sysmlBlock: BlockNode }; const nodeTypes = { sysmlBlock: BlockNode };
@@ -93,7 +95,7 @@ export function DiagramCanvas(props: DiagramCanvasProps) {
); );
} }
function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: DiagramCanvasProps) { function DiagramInner({ data, focusBlockId, onSelect, issuesByElement, projectId }: DiagramCanvasProps) {
const model = useModel(); const model = useModel();
const apply = useApply(); const apply = useApply();
const wrapperRef = useRef<HTMLDivElement | null>(null); const wrapperRef = useRef<HTMLDivElement | null>(null);
@@ -130,14 +132,29 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
const dataChanged = const dataChanged =
existing.data?.label !== b.label || existing.data?.label !== b.label ||
existing.data?.kind !== b.kind || existing.data?.kind !== b.kind ||
!sameStringArray(existing.data?.properties ?? [], propNames); !sameStringArray(existing.data?.properties ?? [], propNames) ||
existing.data?.reviewStatus !== b.reviewStatus;
if (dataChanged) { if (dataChanged) {
next.push({ ...existing, data: { ...existing.data, label: b.label, kind: b.kind, properties: propNames } }); next.push({
...existing,
data: {
...existing.data,
label: b.label,
kind: b.kind,
properties: propNames,
reviewStatus: b.reviewStatus,
},
});
} else { } else {
next.push(existing); next.push(existing);
} }
} else { } else {
next.push({ ...makeNodeForBlock(b), position: placeNew() }); const created = makeNodeForBlock(b);
next.push({
...created,
position: placeNew(),
data: { ...created.data, reviewStatus: b.reviewStatus },
});
} }
} }
for (const c of model.constraints) { for (const c of model.constraints) {
@@ -146,14 +163,30 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
const expr = c.expression || "{ }"; const expr = c.expression || "{ }";
const dataChanged = const dataChanged =
existing.data?.label !== c.label || existing.data?.label !== c.label ||
existing.data?.expression !== expr; existing.data?.expression !== expr ||
existing.data?.reviewStatus !== c.reviewStatus;
if (dataChanged) { if (dataChanged) {
next.push({ ...existing, data: { ...existing.data, label: c.label, kind: "constraint", properties: [], expression: expr } }); next.push({
...existing,
data: {
...existing.data,
label: c.label,
kind: "constraint",
properties: [],
expression: expr,
reviewStatus: c.reviewStatus,
},
});
} else { } else {
next.push(existing); next.push(existing);
} }
} else { } else {
next.push({ ...makeNodeForConstraint(c), position: placeNew() }); const created = makeNodeForConstraint(c);
next.push({
...created,
position: placeNew(),
data: { ...created.data, reviewStatus: c.reviewStatus },
});
} }
} }
return next; return next;
@@ -295,9 +328,47 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
const onDrop = useCallback( const onDrop = useCallback(
(event: React.DragEvent) => { (event: React.DragEvent) => {
event.preventDefault(); event.preventDefault();
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
// Taxonomy/glossary term drop → block stamped from the term.
const termRaw = event.dataTransfer.getData("application/x-socrata-term");
if (termRaw) {
try {
const t = JSON.parse(termRaw) as { termId?: string; label?: string; definition?: string };
if (!t.label || !t.termId) return;
const bid = tempId("b");
const block: Block = {
id: bid,
label: t.label,
kind: "block",
stereotypes: ["block"],
properties: [],
description: t.definition ?? undefined,
linkedTermId: t.termId,
};
const result = apply([addBlockOp(block, bid)]);
if (result.applied) {
const final = result.idMapping[bid] ?? bid;
setNodes(curr => curr.map(n => (n.id === final ? { ...n, position } : n)));
onSelect?.(final);
// Persist the term → block link server-side so it survives reload
// and so other UI can show the "linked" indicator immediately.
if (projectId) {
void fetch(`/api/projects/${encodeURIComponent(projectId)}/term-link`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ termId: t.termId, blockId: final }),
}).catch(() => {});
}
}
} catch (err) {
console.error("[DiagramCanvas] term drop parse:", err);
}
return;
}
const kindRaw = event.dataTransfer.getData("application/sysml-kind"); const kindRaw = event.dataTransfer.getData("application/sysml-kind");
if (!kindRaw) return; if (!kindRaw) return;
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
if (kindRaw === "constraint") { if (kindRaw === "constraint") {
const cid = tempId("c"); const cid = tempId("c");
@@ -332,7 +403,7 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram
onSelect?.(final); onSelect?.(final);
} }
}, },
[apply, onSelect, screenToFlowPosition, setNodes] [apply, onSelect, screenToFlowPosition, setNodes, projectId]
); );
function patchSelected(patch: Partial<BlockNodeData>) { function patchSelected(patch: Partial<BlockNodeData>) {

View File

@@ -14,6 +14,9 @@ export interface BlockNodeData extends Record<string, unknown> {
expression?: string; expression?: string;
/** Highest severity of any validation issue anchored to this block. */ /** Highest severity of any validation issue anchored to this block. */
issueSeverity?: "error" | "warning" | "soft"; issueSeverity?: "error" | "warning" | "soft";
/** Review state — drives a small visual distinction so unreviewed analyzer
* suggestions stand out on the canvas. */
reviewStatus?: "suggested" | "accepted" | "deprecated";
} }
const STEREO: Record<string, string> = { const STEREO: Record<string, string> = {
@@ -33,6 +36,7 @@ export function BlockNode({ data, selected }: NodeProps) {
`sysml-node-${kind}`, `sysml-node-${kind}`,
selected ? "sysml-node-selected" : "", selected ? "sysml-node-selected" : "",
d.issueSeverity ? `sysml-node-issue-${d.issueSeverity}` : "", d.issueSeverity ? `sysml-node-issue-${d.issueSeverity}` : "",
d.reviewStatus && d.reviewStatus !== "accepted" ? `sysml-node-review-${d.reviewStatus}` : "",
] ]
.filter(Boolean) .filter(Boolean)
.join(" "); .join(" ");

View File

@@ -1,22 +0,0 @@
// Header above each canvas (Narrative / Model). Title + subtitle on the left, optional actions on the right.
// Ported from docs/design-source/socrata/project/editor-shell.jsx (CanvasHeader).
import type { ReactNode } from "react";
interface CanvasHeaderProps {
title: string;
subtitle: string;
right?: ReactNode;
}
export function CanvasHeader({ title, subtitle, right }: CanvasHeaderProps) {
return (
<div className="canvas-header">
<div>
<div className="canvas-title">{title}</div>
<div className="canvas-sub">{subtitle}</div>
</div>
<div className="canvas-header-right">{right}</div>
</div>
);
}

View File

@@ -1,38 +1,36 @@
// The dual-canvas workspace shell. // The pivoted workspace shell.
// M5: state lives in ModelStoreProvider; both canvases consume the canonical //
// SysMLModel and emit ModelOps back through useApply(). // Layout: TopBar → [LeftSidebar | MainWorkspace] → StatusBar.
// LeftSidebar lists all sections (Outline + Structure + Findings).
// MainWorkspace is a flex of panes: a pinned text editor + zero-or-more
// section panes (Model, Taxonomy, Glossary, Requirements, Findings) that
// the user opens from the sidebar.
"use client"; "use client";
import { useMemo, useState, Suspense } from "react"; import { useMemo, Suspense } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { TopBar } from "./TopBar"; import { TopBar } from "./TopBar";
import { LeftRail } from "./LeftRail"; import { LeftSidebar } from "./LeftSidebar";
import { CanvasHeader } from "./CanvasHeader";
import { StatusBar } from "./StatusBar"; import { StatusBar } from "./StatusBar";
import { IssuesPanel } from "./IssuesPanel"; import { MainWorkspace } from "./MainWorkspace";
import { FindingsPanel } from "./FindingsPanel";
import { TextCanvas } from "../text-canvas/TextCanvas";
import { DiagramCanvas, type DiagramVariant } from "../diagram-canvas/DiagramCanvas";
import { SocratesDock, type Density, type SocratesPresence } from "../socrates/SocratesDock";
import type { MarkupStyle } from "../text-canvas/Chip";
import type { FixtureData } from "../../lib/fixtures/aristotle"; import type { FixtureData } from "../../lib/fixtures/aristotle";
import { fromFixture } from "../../lib/sysml/fromFixture"; import { fromFixture } from "../../lib/sysml/fromFixture";
import { applyBreaks, BREAKS, type BreakName } from "../../lib/sysml/breaks"; import { applyBreaks, BREAKS, type BreakName } from "../../lib/sysml/breaks";
import { ModelStoreProvider, useModelStore } from "../../lib/sync/ModelStore"; import { ModelStoreProvider } from "../../lib/sync/ModelStore";
import { OpenPanesProvider } from "../../lib/workspace/openPanesStore";
import { AnalysisStoreProvider } from "../../lib/workspace/analysisStore";
import { EditorPaneContextProvider } from "./sections/paneContext";
import type { Density } from "../../lib/workspace/types";
interface EditorShellProps { interface EditorShellProps {
data: FixtureData; data: FixtureData;
/** Server-loaded initial model + version (M5.9). When omitted, falls back to /** Server-loaded initial model + version. When omitted, falls back to
* deriving from `data` (legacy fixture path; useful for tests). */ * deriving from `data` (legacy fixture path; useful for tests). */
initialModel?: import("../../lib/sysml/model").SysMLModel; initialModel?: import("../../lib/sysml/model").SysMLModel;
initialVersion?: number; initialVersion?: number;
/** When set, apply() POSTs to /api/projects/[projectId]/apply. */
projectId?: string; projectId?: string;
density?: Density; density?: Density;
markupStyle?: MarkupStyle;
diagramStyle?: DiagramVariant;
presence?: SocratesPresence;
} }
export function EditorShell(props: EditorShellProps) { export function EditorShell(props: EditorShellProps) {
@@ -43,16 +41,7 @@ export function EditorShell(props: EditorShellProps) {
); );
} }
function EditorShellInner({ function EditorShellInner({ data, initialModel, initialVersion, projectId, density = "comfortable" }: EditorShellProps) {
data,
initialModel,
initialVersion,
projectId,
density = "comfortable",
markupStyle = "color",
diagramStyle = "softened",
presence = "default",
}: EditorShellProps) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const breaks = useMemo<BreakName[]>(() => { const breaks = useMemo<BreakName[]>(() => {
@@ -61,112 +50,29 @@ function EditorShellInner({
return raw.split(",").map(s => s.trim()).filter((s): s is BreakName => s in BREAKS); return raw.split(",").map(s => s.trim()).filter((s): s is BreakName => s in BREAKS);
}, [searchParams]); }, [searchParams]);
// Prefer server-loaded model; fall back to fixture derivation for legacy
// callers without DB persistence wiring.
const startingModel = useMemo(() => { const startingModel = useMemo(() => {
const base = initialModel ?? fromFixture(data); const base = initialModel ?? fromFixture(data);
return breaks.length > 0 ? applyBreaks(base, breaks) : base; return breaks.length > 0 ? applyBreaks(base, breaks) : base;
}, [initialModel, data, breaks]); }, [initialModel, data, breaks]);
const id = projectId ?? "aristotle";
return ( return (
<ModelStoreProvider <ModelStoreProvider initialModel={startingModel} initialVersion={initialVersion ?? 1} projectId={id}>
initialModel={startingModel} <OpenPanesProvider projectId={id}>
initialVersion={initialVersion ?? 1} <AnalysisStoreProvider projectId={id}>
projectId={projectId} <EditorPaneContextProvider projectId={id}>
> <div className={`shell shell-density-${density}`}>
<ShellBody <TopBar data={data} />
data={data} <div className="shell-body shell-body-pivot">
projectId={projectId} <LeftSidebar />
density={density} <MainWorkspace data={data} projectId={id} />
markupStyle={markupStyle} </div>
diagramStyle={diagramStyle} <StatusBar data={data} />
presence={presence} </div>
breaks={breaks} </EditorPaneContextProvider>
/> </AnalysisStoreProvider>
</OpenPanesProvider>
</ModelStoreProvider> </ModelStoreProvider>
); );
} }
interface ShellBodyProps {
data: FixtureData;
projectId?: string;
density: Density;
markupStyle: MarkupStyle;
diagramStyle: DiagramVariant;
presence: SocratesPresence;
breaks: BreakName[];
}
function ShellBody({ data, projectId, density, markupStyle, diagramStyle, presence, breaks }: ShellBodyProps) {
const [focusBlockId, setFocusBlockId] = useState<string | null>(null);
const { model, version, issues, issuesByElement } = useModelStore();
const stats = `SysML · ${model.blocks.length} blocks · ${model.associations.length} associations · ${model.constraints.length} constraints`;
const subtitle = breaks.length > 0 ? `${stats} · breaks active: ${breaks.join(", ")}` : stats;
return (
<div className={`shell shell-density-${density} shell-presence-${presence}`}>
<TopBar data={data} />
<div className="shell-body">
<SocratesDock projectId={projectId ?? "aristotle"} presence={presence} density={density} />
<LeftRail
data={data}
focusBlockId={focusBlockId}
setFocusBlockId={setFocusBlockId}
issuesByElement={issuesByElement}
/>
<main className="canvases">
<section className="canvas canvas-text">
<CanvasHeader title="Narrative" subtitle="Markup-augmented prose · synced to model" />
<div className="canvas-scroll">
<TextCanvas
data={data}
density={density}
markupStyle={markupStyle}
focusBlockId={focusBlockId}
setFocusBlockId={setFocusBlockId}
/>
</div>
</section>
<div className="canvas-divider" />
<section className="canvas canvas-diagram">
<CanvasHeader
title="Model"
subtitle={subtitle}
right={
<div className="canvas-actions">
<span className="canvas-mode-pill">Fit</span>
<span className="canvas-mode-pill canvas-mode-active">100%</span>
<span className="canvas-mode-pill">Layout</span>
</div>
}
/>
<div className="canvas-scroll canvas-scroll-diagram">
<DiagramCanvas
data={data}
density={density}
variant={diagramStyle}
focusBlockId={focusBlockId}
onSelect={setFocusBlockId}
issuesByElement={issuesByElement}
/>
</div>
</section>
</main>
</div>
<StatusBar data={data} />
<IssuesPanel issues={issues} onSelectAnchor={id => setFocusBlockId(id)} />
<FindingsPanel
projectId={projectId ?? "aristotle"}
modelVersion={version}
onSelectAnchor={id => setFocusBlockId(id)}
/>
</div>
);
}

View File

@@ -1,187 +0,0 @@
// Findings panel — lists detected assumptions / risks / inconsistencies.
// Lives in the bottom-right corner above the IssuesPanel; collapsible;
// has a "detect" button that triggers a fresh background pass.
//
// Items are clickable: clicking focuses the first linked element across
// the rail + diagram (using the same setFocusBlockId path as the rail).
"use client";
import { useCallback, useEffect, useState } from "react";
export type FindingKind = "assumption" | "risk" | "inconsistency";
export interface FindingDTO {
id: string;
kind: FindingKind;
text: string;
linkedElementIds: string[];
confidence: number;
severity?: "low" | "medium" | "high" | null;
validationCode?: string | null;
modelVersion: number;
provider?: string | null;
llmModel?: string | null;
}
interface FindingsPanelProps {
projectId: string;
/** Re-fetch whenever this changes. Pass the model version so we know when to refresh. */
modelVersion: number;
onSelectAnchor?: (elementId: string) => void;
}
const KIND_GLYPH: Record<FindingKind, string> = {
assumption: "●",
risk: "▲",
inconsistency: "!",
};
const KIND_LABEL: Record<FindingKind, string> = {
assumption: "asm",
risk: "risk",
inconsistency: "inc",
};
export function FindingsPanel({ projectId, modelVersion, onSelectAnchor }: FindingsPanelProps) {
const [findings, setFindings] = useState<FindingDTO[] | null>(null);
const [collapsed, setCollapsed] = useState(false);
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [meta, setMeta] = useState<{ provider?: string; model?: string; durationMs?: number; modelVersion?: number } | null>(null);
// Initial load
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/findings`);
if (!res.ok) throw new Error(String(res.status));
const data = (await res.json()) as { findings: FindingDTO[] };
if (cancelled) return;
setFindings(data.findings);
if (data.findings.length > 0) {
const first = data.findings[0]!;
setMeta({ provider: first.provider ?? undefined, model: first.llmModel ?? undefined, modelVersion: first.modelVersion });
}
} catch (err) {
if (!cancelled) setError((err as Error).message);
}
})();
return () => { cancelled = true; };
}, [projectId]);
const detect = useCallback(async () => {
if (running) return;
setRunning(true);
setError(null);
try {
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/findings`, {
method: "POST",
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `HTTP ${res.status}`);
}
const data = (await res.json()) as {
findings: FindingDTO[];
meta: { provider: string; model: string; durationMs: number; strippedRefs: number; droppedFindings: number };
};
setFindings(data.findings);
setMeta({
provider: data.meta.provider,
model: data.meta.model,
durationMs: data.meta.durationMs,
modelVersion,
});
} catch (err) {
setError((err as Error).message);
} finally {
setRunning(false);
}
}, [projectId, modelVersion, running]);
const counts = {
assumption: findings?.filter(f => f.kind === "assumption").length ?? 0,
risk: findings?.filter(f => f.kind === "risk").length ?? 0,
inconsistency: findings?.filter(f => f.kind === "inconsistency").length ?? 0,
};
const total = counts.assumption + counts.risk + counts.inconsistency;
const stale = meta?.modelVersion !== undefined && meta.modelVersion !== modelVersion;
return (
<div className={`findings-panel ${collapsed ? "findings-panel-collapsed" : ""}`}>
<button
type="button"
className="findings-panel-head"
onClick={() => setCollapsed(c => !c)}
>
<span className="findings-panel-counts">
{counts.assumption > 0 && <span className="findings-count findings-count-asm"> {counts.assumption}</span>}
{counts.risk > 0 && <span className="findings-count findings-count-risk"> {counts.risk}</span>}
{counts.inconsistency > 0 && <span className="findings-count findings-count-inc">! {counts.inconsistency}</span>}
{total === 0 && findings !== null && <span className="findings-count findings-count-none">no findings</span>}
{findings === null && <span className="findings-count findings-count-none">loading</span>}
</span>
<span className="findings-panel-title">
{findings === null ? "findings" : total === 0 ? "no detected findings" : `${total} detected finding${total === 1 ? "" : "s"}`}
{stale && <span className="findings-panel-stale" title="Model has changed since these were detected">· stale</span>}
</span>
<span className="findings-panel-caret">{collapsed ? "▴" : "▾"}</span>
</button>
{!collapsed && (
<>
<div className="findings-panel-actions">
<button
type="button"
className="findings-panel-btn"
onClick={detect}
disabled={running}
>
{running ? "detecting…" : findings && findings.length > 0 ? "re-detect" : "detect"}
</button>
{meta?.durationMs && (
<span className="findings-panel-meta">
{meta.provider} · {meta.model?.split("/").pop()} · {(meta.durationMs / 1000).toFixed(1)}s
</span>
)}
</div>
{error && <div className="findings-panel-error"> {error}</div>}
{findings && findings.length > 0 && (
<ul className="findings-panel-list">
{(["inconsistency", "risk", "assumption"] as const).flatMap(kind =>
findings.filter(f => f.kind === kind).map(f => (
<li
key={f.id}
className={`findings-item findings-item-${f.kind}`}
onClick={() => {
if (f.linkedElementIds.length > 0 && onSelectAnchor) {
onSelectAnchor(f.linkedElementIds[0]!);
}
}}
style={{ cursor: f.linkedElementIds.length > 0 ? "pointer" : "default" }}
>
<span className={`findings-item-glyph findings-item-glyph-${f.kind}`}>{KIND_GLYPH[f.kind]}</span>
<span className="findings-item-tag">{KIND_LABEL[f.kind]}</span>
{f.severity && <span className={`findings-item-sev findings-item-sev-${f.severity}`}>{f.severity}</span>}
{f.validationCode && <span className="findings-item-code">{f.validationCode}</span>}
<span className="findings-item-text">{f.text}</span>
<span className="findings-item-conf">{f.confidence.toFixed(2)}</span>
{f.linkedElementIds.length > 0 && (
<span className="findings-item-refs" title={f.linkedElementIds.join(", ")}>
[{f.linkedElementIds.slice(0, 3).join(", ")}{f.linkedElementIds.length > 3 ? "…" : ""}]
</span>
)}
</li>
))
)}
</ul>
)}
</>
)}
</div>
);
}

View File

@@ -1,73 +0,0 @@
// Floating panel listing current validation issues — shown next to the
// status bar. Click an issue to focus its anchored element across the rail
// + diagram (via setFocusBlockId). Lets the user verify the validator is
// actually firing on the broken-fixture demos.
"use client";
import { useState } from "react";
import type { ValidationIssue } from "../../lib/sysml/validate";
interface IssuesPanelProps {
issues: ValidationIssue[];
onSelectAnchor?: (id: string) => void;
}
const SEV_GLYPH: Record<ValidationIssue["severity"], string> = {
error: "●",
warning: "▲",
soft: "·",
};
export function IssuesPanel({ issues, onSelectAnchor }: IssuesPanelProps) {
const [collapsed, setCollapsed] = useState(false);
const grouped = {
error: issues.filter(i => i.severity === "error"),
warning: issues.filter(i => i.severity === "warning"),
soft: issues.filter(i => i.severity === "soft"),
};
if (issues.length === 0) {
return (
<div className={`issues-panel issues-panel-clean ${collapsed ? "issues-panel-collapsed" : ""}`}>
<button className="issues-panel-head" onClick={() => setCollapsed(c => !c)} type="button">
<span className="issues-panel-clean-glyph"></span>
<span className="issues-panel-title">Model is clean</span>
</button>
</div>
);
}
return (
<div className={`issues-panel ${collapsed ? "issues-panel-collapsed" : ""}`}>
<button className="issues-panel-head" onClick={() => setCollapsed(c => !c)} type="button">
<span className="issues-panel-counts">
{grouped.error.length > 0 && <span className="issues-count issues-count-error"> {grouped.error.length}</span>}
{grouped.warning.length > 0 && <span className="issues-count issues-count-warning"> {grouped.warning.length}</span>}
{grouped.soft.length > 0 && <span className="issues-count issues-count-soft">· {grouped.soft.length}</span>}
</span>
<span className="issues-panel-title">{issues.length} validation issue{issues.length === 1 ? "" : "s"}</span>
<span className="issues-panel-caret">{collapsed ? "▴" : "▾"}</span>
</button>
{!collapsed && (
<ul className="issues-panel-list">
{(["error", "warning", "soft"] as const).flatMap(sev =>
grouped[sev].map((i, idx) => (
<li
key={`${sev}-${idx}`}
className={`issues-item issues-item-${i.severity}`}
onClick={() => onSelectAnchor?.(i.anchor.kind === "property" ? i.anchor.blockId : (i.anchor as { id: string }).id ?? "")}
>
<span className="issues-item-sev">{SEV_GLYPH[i.severity]}</span>
<span className="issues-item-code">{i.code}</span>
<span className="issues-item-msg">{i.message}</span>
</li>
))
)}
</ul>
)}
</div>
);
}

View File

@@ -1,201 +0,0 @@
// Outline / Model / Requirements sections, each independently collapsible.
// The whole rail can also collapse to a 36px vertical strip.
//
// M5: Model + Requirements sections read from the canonical SysMLModel via
// useModelStore() so renames in either canvas reflect here immediately.
// Outline section is still narrative-derived and uses the fixture (M6 will
// migrate it to the live ProseMirror outline).
"use client";
import { useState } from "react";
import type { FixtureData } from "../../lib/fixtures/aristotle";
import type { ValidationIssue, Severity } from "../../lib/sysml/validate";
import { useModel } from "../../lib/sync/ModelStore";
interface LeftRailProps {
data: FixtureData;
focusBlockId: string | null;
setFocusBlockId: (id: string | null) => void;
/** Map keyed by element key (block id, `req:<id>`, `assoc:<id>`, …) → issues. */
issuesByElement?: Map<string, ValidationIssue[]>;
}
function maxSeverityForKey(map: Map<string, ValidationIssue[]> | undefined, key: string): Severity | null {
const items = map?.get(key);
if (!items || items.length === 0) return null;
if (items.some(i => i.severity === "error")) return "error";
if (items.some(i => i.severity === "warning")) return "warning";
return "soft";
}
function IssueDot({ severity, title }: { severity: Severity | null; title?: string }) {
if (!severity) return null;
return <span className={`rail-issue-dot rail-issue-dot-${severity}`} title={title} />;
}
export function LeftRail({ focusBlockId, setFocusBlockId, issuesByElement }: LeftRailProps) {
const [collapsed, setCollapsed] = useState(false);
const [open, setOpen] = useState({ outline: true, model: true, requirements: true });
const toggle = (k: keyof typeof open) => setOpen(s => ({ ...s, [k]: !s[k] }));
const model = useModel();
if (collapsed) {
return (
<nav className="leftrail leftrail-collapsed">
<button
className="rail-collapse-btn"
onClick={() => setCollapsed(false)}
title="Expand rail"
type="button"
>
</button>
<div className="rail-collapsed-stack">
<span className="rail-collapsed-tag" title="Outline">OUT</span>
<span className="rail-collapsed-tag" title={`Model · ${model.blocks.length + model.constraints.length} elements`}>MOD</span>
<span className="rail-collapsed-tag" title="Requirements">REQ</span>
</div>
</nav>
);
}
// Combine blocks + constraints in the Model section, sorted by kind for a
// predictable order: system → block → actor → constraint.
const kindRank: Record<string, number> = { system: 0, block: 1, actor: 2, constraint: 3 };
const modelEntries: Array<{
id: string;
label: string;
kind: "block" | "actor" | "constraint" | "system";
propertyCount: number;
}> = [
...model.blocks.map(b => ({
id: b.id,
label: b.label,
kind: b.kind,
propertyCount: b.properties.length,
})),
...model.constraints.map(c => ({
id: c.id,
label: c.label,
kind: "constraint" as const,
propertyCount: 0,
})),
].sort((a, b) => {
const r = (kindRank[a.kind] ?? 99) - (kindRank[b.kind] ?? 99);
return r !== 0 ? r : a.label.localeCompare(b.label);
});
return (
<nav className="leftrail">
<div className="rail-section">
<button className="rail-section-head" onClick={() => toggle("outline")} type="button">
<span className={`rail-caret ${open.outline ? "rail-caret-open" : ""}`}></span>
<span className="rail-label">Outline</span>
</button>
{open.outline && (
<ul className="rail-list">
<li className="rail-item rail-item-active">
<span className="rail-item-text">Problem framing</span>
</li>
<li className="rail-item">
<span className="rail-item-text">Constraints</span>
<span className="rail-count rail-count-req">3</span>
</li>
<li className="rail-item">
<span className="rail-item-text">Why now</span>
</li>
<li className="rail-item rail-item-muted">
<span className="rail-item-text">Hypotheses</span>
<span className="rail-count rail-count-asm">3</span>
</li>
<li className="rail-item rail-item-muted">
<span className="rail-item-text">Open questions</span>
<span className="rail-count rail-count-q">5</span>
</li>
<li className="rail-item rail-item-muted">
<span className="rail-item-text">Risks</span>
<span className="rail-count rail-count-risk">2</span>
</li>
</ul>
)}
</div>
<div className="rail-section">
<button className="rail-section-head" onClick={() => toggle("model")} type="button">
<span className={`rail-caret ${open.model ? "rail-caret-open" : ""}`}></span>
<span className="rail-label">Model</span>
</button>
{open.model && (
<ul className="rail-list rail-blocks">
{modelEntries.map(b => {
const sev = maxSeverityForKey(issuesByElement, b.id);
const tooltip = issuesByElement?.get(b.id)?.map(i => `${i.code}: ${i.message}`).join("\n");
return (
<li
key={b.id}
className={`rail-block rail-${b.kind} ${focusBlockId === b.id ? "rail-block-active" : ""}`}
onMouseEnter={() => setFocusBlockId(b.id)}
onMouseLeave={() => setFocusBlockId(null)}
onClick={() => setFocusBlockId(b.id)}
style={{ cursor: "pointer" }}
>
<span className="rail-block-glyph">
{b.kind === "constraint" ? "{}" : b.kind === "actor" ? "◐" : b.kind === "system" ? "◎" : "▢"}
</span>
<span className="rail-block-label">{b.label}</span>
<IssueDot severity={sev} title={tooltip} />
{b.kind !== "constraint" && (
<span
className="rail-block-count"
title={`${b.propertyCount} ${b.propertyCount === 1 ? "property" : "properties"}`}
>
<span className="rail-block-count-glyph">·</span>
{b.propertyCount}
</span>
)}
</li>
);
})}
</ul>
)}
</div>
<div className="rail-section">
<button className="rail-section-head" onClick={() => toggle("requirements")} type="button">
<span className={`rail-caret ${open.requirements ? "rail-caret-open" : ""}`}></span>
<span className="rail-label">Requirements</span>
</button>
{open.requirements && (
<ul className="rail-list">
{model.requirements.map(r => {
const key = `req:${r.id}`;
const sev = maxSeverityForKey(issuesByElement, key);
const tooltip = issuesByElement?.get(key)?.map(i => `${i.code}: ${i.message}`).join("\n");
const traced = r.relations.some(rel => rel.kind === "satisfy");
return (
<li key={r.id} className="rail-req">
<span className="req-tag">{r.tag}</span>
{sev ? (
<IssueDot severity={sev} title={tooltip} />
) : (
<span className={`req-status ${traced ? "req-traced" : "req-untraced"}`} />
)}
</li>
);
})}
</ul>
)}
</div>
<button
className="rail-collapse-btn rail-collapse-btn-bottom"
onClick={() => setCollapsed(true)}
title="Collapse rail"
type="button"
>
</button>
</nav>
);
}

View File

@@ -0,0 +1,163 @@
// Single sidebar for the pivoted shell. Lists every section grouped under
// Structure / Findings. Each row shows: title, count, last-run timestamp,
// per-section [Analyze] button, and is itself clickable to toggle the matching
// pane open in the main workspace.
"use client";
import { useOpenPanes, SECTION_TITLES, type SectionPaneId } from "../../lib/workspace/openPanesStore";
import { useAnalysis } from "../../lib/workspace/analysisStore";
import { Spinner } from "./Spinner";
import { useState } from "react";
const STRUCTURE: SectionPaneId[] = ["concepts", "model", "requirements"];
const FINDINGS: SectionPaneId[] = ["assumptions", "risks", "inconsistencies"];
export function LeftSidebar() {
const [collapsed, setCollapsed] = useState(false);
if (collapsed) {
return (
<aside className="leftsidebar leftsidebar-collapsed" aria-label="Sections">
<button className="leftsidebar-collapse-toggle" type="button" onClick={() => setCollapsed(false)} aria-label="Expand sidebar">
</button>
</aside>
);
}
return (
<aside className="leftsidebar" aria-label="Sections">
<div className="leftsidebar-head">
<span className="leftsidebar-title">Workspace</span>
<button className="leftsidebar-collapse-toggle" type="button" onClick={() => setCollapsed(true)} aria-label="Collapse sidebar">
</button>
</div>
<Group label="Structure" sections={STRUCTURE} />
<Group label="Findings" sections={FINDINGS} />
</aside>
);
}
function Group({ label, sections }: { label: string; sections: SectionPaneId[] }) {
return (
<div className="leftsidebar-group">
<div className="leftsidebar-group-label">{label}</div>
<ul className="leftsidebar-list">
{sections.map(s => (
<SectionRow key={s} id={s} />
))}
</ul>
</div>
);
}
function SectionRow({ id }: { id: SectionPaneId }) {
const { isSectionOpen, toggleSection } = useOpenPanes();
const { terms, requirements, findings, inFlight, runs, analyze } = useAnalysis();
const open = isSectionOpen(id);
const count = sectionCount(id, { terms, requirements, findings });
const pending = pendingCount(id, { terms, requirements, findings });
// Spinner is on whenever:
// - the user just clicked Analyze on this section (inFlight)
// - the user clicked Analyze All
// - the server has a "running" AnalysisRun for this section (catches
// background runs like seed-finalize and the first-paint auto-analyze
// that the local inFlight set doesn't know about). The analysisStore
// polls `runs` every 3s while anything is running, so this stays
// truthy until the run actually finishes.
const running =
inFlight.has(id) ||
inFlight.has("all") ||
(id === "concepts" && (inFlight.has("taxonomy") || inFlight.has("glossary"))) ||
runs[id]?.status === "running" ||
runs.all?.status === "running" ||
(id === "concepts" &&
(runs.taxonomy?.status === "running" || runs.glossary?.status === "running"));
// ONE number per row: pending if there's review work, otherwise total.
// Runs the user the most useful signal first ("how much attention does
// this section need?") and avoids the duplicate-count effect when
// pending === total because nothing is accepted yet.
const showPending = pending > 0;
return (
<li className={`leftsidebar-row ${open ? "leftsidebar-row-open" : ""}`}>
<button
type="button"
className="leftsidebar-row-main"
onClick={() => toggleSection(id)}
aria-expanded={open}
aria-label={
showPending
? `${SECTION_TITLES[id]}${pending} pending review`
: `${SECTION_TITLES[id]} (${count})`
}
>
<span className="leftsidebar-row-title">{SECTION_TITLES[id]}</span>
{showPending ? (
<span className="leftsidebar-pending-chip" title="Pending review">
{pending}
</span>
) : (
<span className="leftsidebar-row-count">{count}</span>
)}
</button>
<button
type="button"
className={`leftsidebar-analyze ${running ? "leftsidebar-analyze-running" : ""}`}
title={running ? "Analyzing…" : `Re-run ${SECTION_TITLES[id]} analysis`}
onClick={e => {
e.stopPropagation();
void analyze(id);
}}
disabled={running}
aria-label={running ? "Analyzing" : `Re-run ${SECTION_TITLES[id]} analysis`}
>
{running ? <Spinner /> : <span aria-hidden="true"></span>}
</button>
</li>
);
}
function sectionCount(
id: SectionPaneId,
data: {
terms: { id: string; status: string }[];
requirements: { id: string; status: string }[];
findings: { kind: string; status: string }[];
}
): number {
if (id === "concepts") return data.terms.length;
if (id === "model") return 0; // Model count is shown inside the pane itself.
if (id === "requirements") return data.requirements.length;
return data.findings.filter(f => f.kind === idToFindingKind(id)).length;
}
/** Count items the user hasn't reviewed yet (suggested + deprecated). The
* most useful navigation cue we can put in the sidebar — tells the user
* where attention is needed at a glance. */
function pendingCount(
id: SectionPaneId,
data: {
terms: { status: string }[];
requirements: { status: string }[];
findings: { kind: string; status: string }[];
}
): number {
const isPending = (s: string) => s === "suggested" || s === "deprecated";
if (id === "concepts") return data.terms.filter(t => isPending(t.status)).length;
if (id === "requirements") return data.requirements.filter(r => isPending(r.status)).length;
if (id === "model") return 0; // Model pending count needs ModelStore — surfaced in the pane header.
return data.findings.filter(f => f.kind === idToFindingKind(id) && isPending(f.status)).length;
}
function idToFindingKind(id: SectionPaneId): string {
if (id === "assumptions") return "assumption";
if (id === "risks") return "risk";
if (id === "inconsistencies") return "inconsistency";
return "";
}

View File

@@ -0,0 +1,177 @@
// Main column-stack workspace (Finder-style).
//
// Layout: [column 0 (section)] [column 1] … [column N] [text editor (pinned)].
// The text-editor pane is always rendered last and absorbs the remaining
// horizontal space; the column stack on its left is horizontally scrollable
// so deep chains stay reachable on narrow viewports.
//
// Each column has a drag-to-resize handle on its right edge. Each column also
// gets `index` as a prop so its inner click handlers can call `pushFrom(index, …)`
// to drill in (truncating any deeper columns first).
"use client";
import { useCallback, useEffect, useRef } from "react";
import { TextCanvasPane } from "./sections/TextCanvasPane";
import { ModelPane } from "./sections/ModelPane";
import { ConceptsPane } from "./sections/ConceptsPane";
import { RequirementsPane } from "./sections/RequirementsPane";
import { FindingsPane } from "./sections/FindingsPane";
import {
TermColumn,
BlockColumn,
AssociationColumn,
ConstraintColumn,
RequirementColumn,
FindingColumn,
} from "./columns/EntityColumns";
import {
useOpenPanes,
PANE_MIN_WIDTH,
PANE_MAX_WIDTH,
columnKey,
type Column,
} from "../../lib/workspace/openPanesStore";
import type { FixtureData } from "../../lib/fixtures/aristotle";
interface MainWorkspaceProps {
data: FixtureData;
projectId: string;
}
export function MainWorkspace({ data, projectId }: MainWorkspaceProps) {
const { columns, closeFrom, widthFor, setWidth } = useOpenPanes();
return (
<main className="main-workspace">
<div className="main-workspace-columns">
{columns.map((col, index) => (
<ResizableColumn
key={columnKey(col)}
column={col}
index={index}
projectId={projectId}
width={widthFor(col)}
onResize={w => setWidth(col, w)}
onClose={() => closeFrom(index)}
/>
))}
</div>
<TextCanvasPane data={data} projectId={projectId} />
</main>
);
}
interface ResizableColumnProps {
column: Column;
index: number;
projectId: string;
width: number;
onResize: (w: number) => void;
onClose: () => void;
}
function ResizableColumn({ column, index, projectId, width, onResize, onClose }: ResizableColumnProps) {
const startXRef = useRef(0);
const startWidthRef = useRef(0);
const draggingRef = useRef(false);
const onPointerMove = useCallback(
(e: PointerEvent) => {
if (!draggingRef.current) return;
const dx = e.clientX - startXRef.current;
const next = Math.max(PANE_MIN_WIDTH, Math.min(PANE_MAX_WIDTH, startWidthRef.current + dx));
onResize(next);
},
[onResize]
);
const onPointerUp = useCallback(() => {
if (!draggingRef.current) return;
draggingRef.current = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
}, []);
useEffect(() => {
const move = (e: PointerEvent) => onPointerMove(e);
const up = () => onPointerUp();
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
return () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
};
}, [onPointerMove, onPointerUp]);
const onHandleDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
draggingRef.current = true;
startXRef.current = e.clientX;
startWidthRef.current = width;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
},
[width]
);
return (
<div className="pane-with-handle" style={{ width, minWidth: width, maxWidth: width }}>
<ColumnRenderer column={column} index={index} projectId={projectId} onClose={onClose} />
<div
className="pane-resize-handle"
onPointerDown={onHandleDown}
role="separator"
aria-orientation="vertical"
aria-label={`Resize column ${index + 1}`}
/>
</div>
);
}
interface ColumnRendererProps {
column: Column;
index: number;
projectId: string;
onClose: () => void;
}
function ColumnRenderer({ column, index, projectId, onClose }: ColumnRendererProps) {
switch (column.kind) {
case "section":
switch (column.id) {
case "concepts":
return <ConceptsPane index={index} onClose={onClose} />;
case "model":
return <ModelPane index={index} onClose={onClose} />;
case "requirements":
return <RequirementsPane index={index} onClose={onClose} />;
case "assumptions":
return <FindingsPane kind="assumption" index={index} projectId={projectId} onClose={onClose} />;
case "risks":
return <FindingsPane kind="risk" index={index} projectId={projectId} onClose={onClose} />;
case "inconsistencies":
return <FindingsPane kind="inconsistency" index={index} projectId={projectId} onClose={onClose} />;
}
case "term":
return <TermColumn termId={column.id} index={index} onClose={onClose} />;
case "block":
return <BlockColumn blockId={column.id} index={index} onClose={onClose} />;
case "association":
return <AssociationColumn associationId={column.id} index={index} onClose={onClose} />;
case "constraint":
return <ConstraintColumn constraintId={column.id} index={index} onClose={onClose} />;
case "requirement":
return <RequirementColumn requirementId={column.id} index={index} onClose={onClose} />;
case "finding":
return (
<FindingColumn
findingId={column.id}
index={index}
projectId={projectId}
onClose={onClose}
/>
);
}
}

View File

@@ -0,0 +1,97 @@
// Shared pane-header controls. Two primitives:
//
// - <PaneViewTabs/> — segmented control for VIEW MODES that are mutually
// exclusive (Tree | AZ, Diagram | Summary). Always exactly one active.
//
// - <PaneFilterChip/> — toggleable filter chip with an optional count pip.
// Use for filters like "Pending" that overlay the current view mode
// rather than replacing it. Disabled when count=0.
//
// The intent is to stop mixing "view mode" and "filter" in the same
// pane-tabs cluster, which currently makes "Pending" feel like a third
// view mode instead of a filter you can stack on Tree or AZ.
"use client";
import type { ReactNode } from "react";
// ─── PaneViewTabs ────────────────────────────────────────────────────────
export interface PaneViewTab<V extends string> {
value: V;
label: ReactNode;
/** Optional tooltip. */
title?: string;
}
interface PaneViewTabsProps<V extends string> {
value: V;
onChange: (v: V) => void;
tabs: PaneViewTab<V>[];
/** ARIA label for the segmented group. */
ariaLabel?: string;
}
export function PaneViewTabs<V extends string>({
value,
onChange,
tabs,
ariaLabel = "View mode",
}: PaneViewTabsProps<V>) {
return (
<div className="pane-view-tabs" role="tablist" aria-label={ariaLabel}>
{tabs.map(t => (
<button
key={t.value}
type="button"
role="tab"
aria-selected={value === t.value}
className={`pane-view-tab ${value === t.value ? "pane-view-tab-active" : ""}`}
onClick={() => onChange(t.value)}
title={t.title}
>
{t.label}
</button>
))}
</div>
);
}
// ─── PaneFilterChip ──────────────────────────────────────────────────────
interface PaneFilterChipProps {
/** Whether the filter is currently applied. */
active: boolean;
onToggle: () => void;
label: string;
/** Optional count to render as a pip (e.g. number of pending items).
* When 0 (or undefined), the chip is disabled. */
count?: number;
title?: string;
}
export function PaneFilterChip({
active,
onToggle,
label,
count,
title,
}: PaneFilterChipProps) {
const hasCount = typeof count === "number" && count > 0;
const disabled = typeof count === "number" && count === 0;
return (
<button
type="button"
className={`pane-filter-chip ${active ? "pane-filter-chip-active" : ""} ${
hasCount ? "pane-filter-chip-attention" : ""
}`}
onClick={onToggle}
title={title}
disabled={disabled}
aria-pressed={active}
>
<span className="pane-filter-chip-label">{label}</span>
{hasCount ? <span className="pane-filter-chip-pip">{count}</span> : null}
</button>
);
}

View File

@@ -0,0 +1,75 @@
// PaneDrawer — collapsible group inside a list pane. Used to group rows by
// review state without losing screen real estate when a group is empty or
// the user doesn't want to see it.
//
// Stack layout: Kept list (always-on, fills) → [Pending drawer] → [Discarded
// drawer]. Drawers always render their header (so the count is visible at a
// glance); their body collapses on toggle.
//
// Tone is one of:
// "default" — neutral, used for kept-only views
// "pending" — accent strip + count chip in accent
// "muted" — dim styling, used for the Discarded drawer
//
// The drawer is non-sticky on purpose: the user can scroll past kept items
// to reach pending, and the kept count never gets crushed by an over-tall
// drawer. CSS lives in styles/base.css under .pane-drawer.
"use client";
import { useState, type ReactNode } from "react";
interface PaneDrawerProps {
title: string;
/** Optional count rendered as a pip in the header. */
count?: number;
/** Visual tone. */
tone?: "default" | "pending" | "muted";
/** Initial expanded state. */
defaultOpen?: boolean;
/** When the drawer would be empty AND `hideWhenEmpty` is true, the entire
* drawer (header included) is omitted. Useful for the Discarded drawer
* where 0 items means "nothing dismissed yet, don't even show me the
* header." */
hideWhenEmpty?: boolean;
/** Override action shown to the right of the title (e.g. "Restore all"). */
rightAction?: ReactNode;
children: ReactNode;
}
export function PaneDrawer({
title,
count,
tone = "default",
defaultOpen = false,
hideWhenEmpty = false,
rightAction,
children,
}: PaneDrawerProps) {
const [open, setOpen] = useState(defaultOpen);
const isEmpty = typeof count === "number" && count === 0;
if (hideWhenEmpty && isEmpty) return null;
return (
<section className={`pane-drawer pane-drawer-${tone} ${open ? "pane-drawer-open" : ""}`}>
<header className="pane-drawer-head">
<button
type="button"
className="pane-drawer-toggle"
onClick={() => setOpen(o => !o)}
aria-expanded={open}
>
<span className="pane-drawer-caret" aria-hidden="true">
{open ? "▾" : "▸"}
</span>
<span className="pane-drawer-title">{title}</span>
{typeof count === "number" ? (
<span className="pane-drawer-count">{count}</span>
) : null}
</button>
{rightAction ? <div className="pane-drawer-action">{rightAction}</div> : null}
</header>
{open ? <div className="pane-drawer-body">{children}</div> : null}
</section>
);
}

View File

@@ -0,0 +1,43 @@
// PaneEmpty — unified empty-state for any list pane or detail column.
//
// Replaces the scatter of `.pane-empty` blocks across panes with a
// consistent structure: title (one short line), an optional descriptive
// hint, and an optional primary action.
//
// The visual treatment is deliberately quiet so empty states don't shout.
"use client";
import type { ReactNode } from "react";
interface PaneEmptyProps {
/** Short headline (≤8 words). */
title: string;
/** Optional secondary copy. Accepts ReactNode so callers can embed a
* link or a kbd tag inline. */
hint?: ReactNode;
/** Optional primary action button (e.g. "Run Analyze →"). */
action?: { label: string; onClick: () => void; disabled?: boolean };
/** Optional decorative icon — kept tiny, no SVG dependency. */
icon?: ReactNode;
}
export function PaneEmpty({ title, hint, action, icon }: PaneEmptyProps) {
return (
<div className="pane-empty-v2" role="status">
{icon ? <div className="pane-empty-icon">{icon}</div> : null}
<div className="pane-empty-title">{title}</div>
{hint ? <div className="pane-empty-hint">{hint}</div> : null}
{action ? (
<button
type="button"
className="pane-empty-action"
onClick={action.onClick}
disabled={action.disabled}
>
{action.label}
</button>
) : null}
</div>
);
}

View File

@@ -0,0 +1,37 @@
// Wrapper for any main-area pane: header (title + subtitle + close) + body.
// The text-editor pane passes closable={false} so it has no close button.
"use client";
import { ReactNode } from "react";
interface PaneFrameProps {
title: string;
subtitle?: string;
right?: ReactNode;
closable?: boolean;
onClose?: () => void;
children: ReactNode;
}
export function PaneFrame({ title, subtitle, right, closable = true, onClose, children }: PaneFrameProps) {
return (
<section className="pane">
<header className="pane-header">
<div className="pane-titles">
<span className="pane-title">{title}</span>
{subtitle ? <span className="pane-subtitle">{subtitle}</span> : null}
</div>
<div className="pane-right">
{right}
{closable ? (
<button className="pane-close" type="button" onClick={onClose} aria-label="Close pane">
×
</button>
) : null}
</div>
</header>
<div className="pane-body">{children}</div>
</section>
);
}

View File

@@ -0,0 +1,391 @@
// 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>
);
}

View File

@@ -0,0 +1,74 @@
// Spinner — a small, polished SVG ring used everywhere the UI needs to
// signal "working." Two stacked circles: a faint full ring (track) and a
// shorter accent arc on top, with the arc rotating around the center.
// The dasharray + animation give a smooth motion that doesn't depend on
// CSS-only border tricks (which look chunky at small sizes).
"use client";
interface SpinnerProps {
/** Pixel size; defaults to 14 (matches sidebar button glyph metrics). */
size?: number;
/** Accent stroke color; defaults to `currentColor` so the spinner picks
* up the surrounding text color. */
color?: string;
/** Track stroke color; defaults to a faint border tone. */
trackColor?: string;
/** Stroke width in SVG units (viewBox 24×24). 2.5 reads cleanly at 14px. */
strokeWidth?: number;
className?: string;
title?: string;
}
export function Spinner({
size = 14,
color = "currentColor",
trackColor = "rgba(0,0,0,0.14)",
strokeWidth = 2.5,
className,
title,
}: SpinnerProps) {
return (
<svg
className={`spinner ${className ?? ""}`}
width={size}
height={size}
viewBox="0 0 24 24"
role={title ? "img" : "presentation"}
aria-label={title}
aria-hidden={title ? undefined : true}
>
{/* Track */}
<circle
cx="12"
cy="12"
r="9"
fill="none"
stroke={trackColor}
strokeWidth={strokeWidth}
/>
{/* Animated arc — strokeDasharray ~= 25% of the circumference (2π·9 ≈ 56.5);
* we use 14 56.5-14 = 14 / 42.5 to draw a 14-unit arc. */}
<circle
cx="12"
cy="12"
r="9"
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray="14 42.5"
transform="rotate(-90 12 12)"
>
<animateTransform
attributeName="transform"
type="rotate"
from="-90 12 12"
to="270 12 12"
dur="0.85s"
repeatCount="indefinite"
/>
</circle>
</svg>
);
}

View File

@@ -0,0 +1,89 @@
// StatusChip — the single visual idiom for any "this thing has a state"
// signal in the workspace. Replaces the five ad-hoc chips that grew over
// time: term-badge-new / term-badge-deprecated / finding-sev / finding-code /
// req-flag / EntityColumns ReviewBadge.
//
// Variants are picked from a tiny vocabulary so the visual rhythm is
// consistent across surfaces: review state (suggested / accepted /
// deprecated / dismissed / resolved), severity (low / medium / high), or a
// validation code (S1, M2, X3, …) rendered as `code`.
//
// Usage:
// <StatusChip variant="suggested" /> // "NEW"
// <StatusChip variant="deprecated" /> // "DEPRECATED"
// <StatusChip variant="severity" value="high" />
// <StatusChip variant="code" value="X3" />
// <StatusChip variant="confidence" value={0.87} />
// <StatusChip variant="warn" label="unsupported" />
//
// CSS lives in styles/base.css under .status-chip; tone is one of:
// accent — review-pending / NEW / suggestion
// muted — neutral / deprecated / code
// warn — risky / high-severity / unsupported
// ok — confirmed / resolved
// info — informational / confidence
"use client";
export type StatusTone = "accent" | "muted" | "warn" | "ok" | "info";
export type StatusChipProps =
| { variant: "suggested"; title?: string }
| { variant: "accepted"; title?: string }
| { variant: "deprecated"; title?: string }
| { variant: "dismissed"; title?: string }
| { variant: "resolved"; title?: string }
| { variant: "severity"; value: "low" | "medium" | "high"; title?: string }
| { variant: "code"; value: string; title?: string }
| { variant: "confidence"; value: number; title?: string }
| { variant: "warn"; label: string; title?: string }
| { variant: "ok"; label: string; title?: string }
| { variant: "muted"; label: string; title?: string };
/** Single-axis style picker. Every visual decision flows through this fn so
* designers can tweak one place. */
function styleFor(props: StatusChipProps): { tone: StatusTone; label: string } {
switch (props.variant) {
case "suggested":
return { tone: "accent", label: "NEW" };
case "accepted":
return { tone: "ok", label: "KEPT" };
case "deprecated":
return { tone: "muted", label: "DEPRECATED" };
case "dismissed":
return { tone: "muted", label: "DISMISSED" };
case "resolved":
return { tone: "ok", label: "RESOLVED" };
case "severity":
return {
tone: props.value === "high" ? "warn" : props.value === "medium" ? "info" : "muted",
label: props.value.toUpperCase(),
};
case "code":
return { tone: "muted", label: props.value };
case "confidence":
return {
tone: props.value >= 0.75 ? "ok" : props.value >= 0.4 ? "info" : "muted",
label: `${Math.round(props.value * 100)}%`,
};
case "warn":
return { tone: "warn", label: props.label };
case "ok":
return { tone: "ok", label: props.label };
case "muted":
return { tone: "muted", label: props.label };
}
}
export function StatusChip(props: StatusChipProps) {
const { tone, label } = styleFor(props);
return (
<span
className={`status-chip status-chip-${tone}`}
title={props.title}
aria-label={props.title ?? label}
>
{label}
</span>
);
}

View File

@@ -1,33 +1,44 @@
// Top-level brand row: name + breadcrumbs on the left, sync pill + avatar on the right. // Top-level brand row + Analyze All action (the pivot's primary global verb).
// Ported from docs/design-source/socrata/project/editor-shell.jsx (TopBar).
"use client";
import Link from "next/link";
import type { FixtureData } from "../../lib/fixtures/aristotle"; import type { FixtureData } from "../../lib/fixtures/aristotle";
import { useAnalysis } from "../../lib/workspace/analysisStore";
interface TopBarProps { interface TopBarProps {
data: FixtureData; data: FixtureData;
} }
export function TopBar({ data }: TopBarProps) { export function TopBar({ data }: TopBarProps) {
const { analyze, inFlight } = useAnalysis();
const running = inFlight.size > 0;
return ( return (
<header className="topbar"> <header className="topbar">
<div className="topbar-left"> <div className="topbar-left">
<div className="brand"> <Link href="/" className="brand" title="Back to all projects" style={{ textDecoration: "none", color: "inherit" }}>
<span className="brand-name">Socrata</span> <span className="brand-name">Socrata</span>
</div> </Link>
<div className="breadcrumbs"> <div className="breadcrumbs">
<span className="bc-sep">/</span>
<span className="bc-item">{data.project.scope}</span>
<span className="bc-sep">/</span> <span className="bc-sep">/</span>
<span className="bc-item bc-active">{data.project.name}</span> <span className="bc-item bc-active">{data.project.name}</span>
<span className="bc-branch">
<span className="bc-branch-glyph"></span> {data.project.branch}
</span>
</div> </div>
</div> </div>
<div className="topbar-right"> <div className="topbar-right">
<div className="sync-pill"> <button
<span className="sync-dot" /> model in sync · {data.project.lastSync} type="button"
</div> className="topbar-analyze-all"
onClick={() => void analyze("all")}
disabled={running}
title="Run all analyses against the current document"
>
{inFlight.has("all") ? "Analyzing all…" : running ? "Analyzing…" : "Analyze All"}
</button>
<div className="topbar-divider" />
<Link href="/seed" className="topbar-new" title="Start a new idea">
+ new idea
</Link>
<div className="topbar-divider" /> <div className="topbar-divider" />
<div className="avatar">MC</div> <div className="avatar">MC</div>
</div> </div>

View File

@@ -0,0 +1,994 @@
// Detail-column panes for the Finder-style stack.
//
// Each column takes its own subject id (termId, blockId, etc.) and renders
// in the same `.pane` shell as the section panes, so resize / close / header
// behavior is consistent across the stack.
//
// Click handlers inside these columns call `pushFrom(index, ...)` to drill
// further (e.g. clicking a linked block on a term column pushes a block
// column to its right). The `index` prop is the column's own position in
// the stack — so child pushes can truncate everything to its right.
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { PaneFrame } from "../PaneFrame";
import { StatusChip } from "../StatusChip";
import { useAnalysis, type ClientTerm } from "../../../lib/workspace/analysisStore";
import { useModelStore } from "../../../lib/sync/ModelStore";
import { useOpenPanes } from "../../../lib/workspace/openPanesStore";
import { ContextualSocratesThread } from "../../socrates/ContextualSocratesThread";
import { PromoteToolbar } from "../PromoteToolbar";
import {
decideElement,
removeBlock,
removeAssociation,
removeConstraint,
removeRequirement,
type ReviewableElementKind,
} from "../../../lib/sync/ops";
import type { ReviewStatus, SysMLModel } from "../../../lib/sysml/model";
// ─── Term column ────────────────────────────────────────────────────────
interface TermColumnProps {
termId: string;
index: number;
onClose: () => void;
}
export function TermColumn({ termId, index, onClose }: TermColumnProps) {
const { getTerm, terms, requirements, findings } = useAnalysis();
const { model } = useModelStore();
const { pushFrom } = useOpenPanes();
const term = getTerm(termId);
if (!term) {
return (
<PaneFrame title="Concept" subtitle="not found" onClose={onClose}>
<div className="pane-empty">This concept no longer exists.</div>
</PaneFrame>
);
}
const parents = buildParentChain(term, terms);
const children = terms.filter(t => t.parentId === term.id);
const linkedBlock = term.linkedBlockId
? model.blocks.find(b => b.id === term.linkedBlockId) ?? null
: null;
const linkedAssociations = model.associations.filter(a => a.linkedTermId === term.id);
const linkedConstraints = model.constraints.filter(c => c.linkedTermId === term.id);
const linkedReqsFromModel = model.requirements.filter(r => r.linkedTermId === term.id);
const linkedReqsFromAnalyze = requirements.filter(r => r.linkedTermId === term.id);
const relatedFindings = linkedBlock
? findings.filter(f => f.linkedElementIds.includes(linkedBlock.id))
: findings.filter(f => f.linkedElementIds.includes(`term:${term.id}`));
const hasFormalism =
!!linkedBlock ||
linkedAssociations.length > 0 ||
linkedConstraints.length > 0 ||
linkedReqsFromModel.length > 0 ||
linkedReqsFromAnalyze.length > 0;
return (
<PaneFrame
title={term.label}
subtitle="concept"
onClose={onClose}
>
<div className="column-body">
<DefinitionSection term={term} />
{(parents.length > 0 || children.length > 0 || term.synonyms.length > 0) && (
<Section label="Hierarchy">
{parents.length > 0 && (
<Row label="Parents">
{parents.map((p, i) => (
<span key={p.id}>
{i > 0 ? " " : null}
<button
type="button"
className="termdetail-chip"
onClick={() => pushFrom(index - 1, { kind: "term", id: p.id })}
>
{p.label}
</button>
</span>
))}
</Row>
)}
{children.length > 0 && (
<Row label="Children">
{children.map(c => (
<button
key={c.id}
type="button"
className="termdetail-chip"
onClick={() => pushFrom(index, { kind: "term", id: c.id })}
>
{c.label}
</button>
))}
</Row>
)}
{term.synonyms.length > 0 && (
<Row label="Synonyms">
{term.synonyms.map(s => (
<span key={s} className="termdetail-synonym">
{s}
</span>
))}
</Row>
)}
</Section>
)}
<Section label="Promote to formal element">
<PromoteToolbar term={term} />
</Section>
<Section label="Formalisms">
{!hasFormalism ? (
<p className="termdetail-empty">
Not yet formalized. Promote it above to make it a block, association, constraint, or requirement.
</p>
) : (
<ul className="termdetail-formalism-list">
{linkedBlock && (
<li>
<button
type="button"
className="termdetail-formalism"
onClick={() => pushFrom(index, { kind: "block", id: linkedBlock.id })}
>
<span className="termdetail-formalism-kind">{linkedBlock.kind}</span>
<span className="termdetail-formalism-label">{linkedBlock.label}</span>
<span className="termdetail-formalism-jump"></span>
</button>
</li>
)}
{linkedAssociations.map(a => (
<li key={a.id}>
<button
type="button"
className="termdetail-formalism"
onClick={() => pushFrom(index, { kind: "association", id: a.id })}
>
<span className="termdetail-formalism-kind">{a.kind}</span>
<span className="termdetail-formalism-label">
{labelOf(model, a.fromBlockId)} <em>{a.label || "→"}</em>{" "}
{labelOf(model, a.toBlockId)}
</span>
<span className="termdetail-formalism-jump"></span>
</button>
</li>
))}
{linkedConstraints.map(c => (
<li key={c.id}>
<button
type="button"
className="termdetail-formalism"
onClick={() => pushFrom(index, { kind: "constraint", id: c.id })}
>
<span className="termdetail-formalism-kind">constraint</span>
<span className="termdetail-formalism-label">
{c.label}
{c.expression ? <> <code>{c.expression}</code></> : null}
</span>
<span className="termdetail-formalism-jump"></span>
</button>
</li>
))}
{linkedReqsFromModel.map(r => (
<li key={`m-${r.id}`}>
<button
type="button"
className="termdetail-formalism"
onClick={() => pushFrom(index, { kind: "requirement", id: r.id })}
>
<span className="termdetail-formalism-kind">{r.tag}</span>
<span className="termdetail-formalism-label">{r.text}</span>
<span className="termdetail-formalism-jump"></span>
</button>
</li>
))}
{linkedReqsFromAnalyze
.filter(r => !linkedReqsFromModel.some(m => m.tag === r.tag))
.map(r => (
<li key={`a-${r.id}`}>
<div className="termdetail-formalism termdetail-formalism-static">
<span className="termdetail-formalism-kind">{r.tag}</span>
<span className="termdetail-formalism-label">{r.text}</span>
</div>
</li>
))}
</ul>
)}
</Section>
{relatedFindings.length > 0 && (
<Section label={`Findings (${relatedFindings.length})`}>
<ul className="termdetail-findings">
{relatedFindings.map(f => (
<li key={f.id} className={`termdetail-finding termdetail-finding-${f.kind}`}>
<button
type="button"
className="termdetail-finding-btn"
onClick={() => pushFrom(index, { kind: "finding", id: f.id })}
>
<span className="termdetail-finding-kind">{f.kind}</span>
<span className="termdetail-finding-text">{f.text}</span>
</button>
</li>
))}
</ul>
</Section>
)}
</div>
</PaneFrame>
);
}
// ─── Block column ───────────────────────────────────────────────────────
export function BlockColumn({
blockId,
index,
onClose,
}: {
blockId: string;
index: number;
onClose: () => void;
}) {
const { model, apply } = useModelStore();
const { findings } = useAnalysis();
const { pushFrom } = useOpenPanes();
const block = model.blocks.find(b => b.id === blockId);
if (!block) {
return (
<PaneFrame title="Block" subtitle="not found" onClose={onClose}>
<div className="pane-empty">This block no longer exists.</div>
</PaneFrame>
);
}
const associations = model.associations.filter(
a => a.fromBlockId === block.id || a.toBlockId === block.id
);
const requirements = model.requirements.filter(r =>
r.relations.some(rel => rel.kind === "satisfy" && rel.blockId === block.id)
);
const relatedFindings = findings.filter(f => f.linkedElementIds.includes(block.id));
return (
<PaneFrame
title={block.label}
subtitle={block.kind === "block" ? "block" : `block · ${block.kind}`}
right={
<ReviewBadge status={block.reviewStatus}>
<DecideButtons
kind="block"
id={block.id}
status={block.reviewStatus}
onKeep={() => apply([decideElement({ kind: "block", id: block.id })])}
onDiscard={() => apply([removeBlock(block.id)])}
/>
</ReviewBadge>
}
onClose={onClose}
>
<div className="column-body">
{block.linkedTermId && (
<Section label="Concept">
<button
type="button"
className="termdetail-chip"
onClick={() => pushFrom(index, { kind: "term", id: block.linkedTermId! })}
>
open concept
</button>
</Section>
)}
{block.properties.length > 0 && (
<Section label="Properties">
<ul className="block-prop-list">
{block.properties.map(p => (
<li key={p.id} className="block-prop">
<span className="block-prop-name">{p.name}</span>
<span className="block-prop-type">{p.type.kind}</span>
</li>
))}
</ul>
</Section>
)}
{associations.length > 0 && (
<Section label="Associations">
<ul className="termdetail-formalism-list">
{associations.map(a => (
<li key={a.id}>
<button
type="button"
className="termdetail-formalism"
onClick={() => pushFrom(index, { kind: "association", id: a.id })}
>
<span className="termdetail-formalism-kind">{a.kind}</span>
<span className="termdetail-formalism-label">
{labelOf(model, a.fromBlockId)} <em>{a.label || "→"}</em>{" "}
{labelOf(model, a.toBlockId)}
</span>
<span className="termdetail-formalism-jump"></span>
</button>
</li>
))}
</ul>
</Section>
)}
{requirements.length > 0 && (
<Section label="Requirements satisfied">
<ul className="termdetail-formalism-list">
{requirements.map(r => (
<li key={r.id}>
<button
type="button"
className="termdetail-formalism"
onClick={() => pushFrom(index, { kind: "requirement", id: r.id })}
>
<span className="termdetail-formalism-kind">{r.tag}</span>
<span className="termdetail-formalism-label">{r.text}</span>
<span className="termdetail-formalism-jump"></span>
</button>
</li>
))}
</ul>
</Section>
)}
{relatedFindings.length > 0 && (
<Section label={`Findings (${relatedFindings.length})`}>
<ul className="termdetail-findings">
{relatedFindings.map(f => (
<li key={f.id} className={`termdetail-finding termdetail-finding-${f.kind}`}>
<button
type="button"
className="termdetail-finding-btn"
onClick={() => pushFrom(index, { kind: "finding", id: f.id })}
>
<span className="termdetail-finding-kind">{f.kind}</span>
<span className="termdetail-finding-text">{f.text}</span>
</button>
</li>
))}
</ul>
</Section>
)}
</div>
</PaneFrame>
);
}
// ─── Association column ─────────────────────────────────────────────────
export function AssociationColumn({
associationId,
index,
onClose,
}: {
associationId: string;
index: number;
onClose: () => void;
}) {
const { model, apply } = useModelStore();
const { pushFrom } = useOpenPanes();
const a = model.associations.find(x => x.id === associationId);
if (!a) {
return (
<PaneFrame title="Association" subtitle="not found" onClose={onClose}>
<div className="pane-empty">This association no longer exists.</div>
</PaneFrame>
);
}
const from = model.blocks.find(b => b.id === a.fromBlockId);
const to = model.blocks.find(b => b.id === a.toBlockId);
return (
<PaneFrame
title={a.label || "Association"}
subtitle={a.kind === "association" ? "association" : `association · ${a.kind}`}
right={
<ReviewBadge status={a.reviewStatus}>
<DecideButtons
kind="association"
id={a.id}
status={a.reviewStatus}
onKeep={() => apply([decideElement({ kind: "association", id: a.id })])}
onDiscard={() => apply([removeAssociation(a.id)])}
/>
</ReviewBadge>
}
onClose={onClose}
>
<div className="column-body">
<Section label="Endpoints">
<Row label="From">
<button
type="button"
className="termdetail-chip"
onClick={() => from && pushFrom(index, { kind: "block", id: from.id })}
disabled={!from}
>
{from?.label ?? a.fromBlockId}
</button>
</Row>
<Row label="To">
<button
type="button"
className="termdetail-chip"
onClick={() => to && pushFrom(index, { kind: "block", id: to.id })}
disabled={!to}
>
{to?.label ?? a.toBlockId}
</button>
</Row>
</Section>
{a.linkedTermId && (
<Section label="Concept">
<button
type="button"
className="termdetail-chip"
onClick={() => pushFrom(index, { kind: "term", id: a.linkedTermId! })}
>
open concept
</button>
</Section>
)}
</div>
</PaneFrame>
);
}
// ─── Constraint column ──────────────────────────────────────────────────
export function ConstraintColumn({
constraintId,
index,
onClose,
}: {
constraintId: string;
index: number;
onClose: () => void;
}) {
const { model, apply } = useModelStore();
const { pushFrom } = useOpenPanes();
const c = model.constraints.find(x => x.id === constraintId);
if (!c) {
return (
<PaneFrame title="Constraint" subtitle="not found" onClose={onClose}>
<div className="pane-empty">This constraint no longer exists.</div>
</PaneFrame>
);
}
return (
<PaneFrame
title={c.label}
subtitle="constraint"
right={
<ReviewBadge status={c.reviewStatus}>
<DecideButtons
kind="constraint"
id={c.id}
status={c.reviewStatus}
onKeep={() => apply([decideElement({ kind: "constraint", id: c.id })])}
onDiscard={() => apply([removeConstraint(c.id)])}
/>
</ReviewBadge>
}
onClose={onClose}
>
<div className="column-body">
{c.expression && (
<Section label="Expression">
<code className="constraint-expr">{c.expression}</code>
</Section>
)}
{c.appliesTo.length > 0 && (
<Section label="Applies to">
<ul className="termdetail-formalism-list">
{c.appliesTo.map(bid => {
const b = model.blocks.find(x => x.id === bid);
return (
<li key={bid}>
<button
type="button"
className="termdetail-formalism"
onClick={() => pushFrom(index, { kind: "block", id: bid })}
>
<span className="termdetail-formalism-kind">block</span>
<span className="termdetail-formalism-label">{b?.label ?? bid}</span>
<span className="termdetail-formalism-jump"></span>
</button>
</li>
);
})}
</ul>
</Section>
)}
{c.linkedTermId && (
<Section label="Concept">
<button
type="button"
className="termdetail-chip"
onClick={() => pushFrom(index, { kind: "term", id: c.linkedTermId! })}
>
open concept
</button>
</Section>
)}
</div>
</PaneFrame>
);
}
// ─── Requirement column ─────────────────────────────────────────────────
export function RequirementColumn({
requirementId,
index,
onClose,
}: {
requirementId: string;
index: number;
onClose: () => void;
}) {
const { model, apply } = useModelStore();
const { requirements: analyzedReqs, getTerm, decideRequirement } = useAnalysis();
const { pushFrom } = useOpenPanes();
// Match by id in either list.
const modelReq = model.requirements.find(r => r.id === requirementId);
const analyzedReq = analyzedReqs.find(r => r.id === requirementId);
if (!modelReq && !analyzedReq) {
return (
<PaneFrame title="Requirement" subtitle="not found" onClose={onClose}>
<div className="pane-empty">This requirement no longer exists.</div>
</PaneFrame>
);
}
const tag = modelReq?.tag ?? analyzedReq?.tag ?? requirementId;
const text = modelReq?.text ?? analyzedReq?.text ?? "";
const linkedTermId = modelReq?.linkedTermId ?? analyzedReq?.linkedTermId ?? null;
const tracedTo = modelReq?.relations
.filter((r): r is { kind: "satisfy"; blockId: string } => r.kind === "satisfy")
.map(r => r.blockId) ?? analyzedReq?.tracedToIds ?? [];
const reviewStatus = modelReq?.reviewStatus;
const analyzedStatus = analyzedReq?.status;
return (
<PaneFrame
title={tag}
subtitle="requirement"
right={
modelReq ? (
<ReviewBadge status={reviewStatus}>
<DecideButtons
kind="requirement"
id={modelReq.id}
status={reviewStatus}
onKeep={() => apply([decideElement({ kind: "requirement", id: modelReq.id })])}
onDiscard={() => apply([removeRequirement(modelReq.id)])}
/>
</ReviewBadge>
) : analyzedReq && analyzedStatus !== "accepted" ? (
<div className="term-decision-row">
<button
type="button"
className="term-decide term-decide-keep"
onClick={() => void decideRequirement(analyzedReq.id, "keep")}
>
Keep
</button>
<button
type="button"
className="term-decide term-decide-discard"
onClick={() => void decideRequirement(analyzedReq.id, "discard")}
>
Discard
</button>
</div>
) : null
}
onClose={onClose}
>
<div className="column-body">
<Section label="Text">
<p className="termdetail-def">{text}</p>
</Section>
{linkedTermId && (
<Section label="Concept">
<button
type="button"
className="termdetail-chip"
onClick={() => pushFrom(index, { kind: "term", id: linkedTermId })}
>
{getTerm(linkedTermId)?.label ?? "↪ open concept"}
</button>
</Section>
)}
{tracedTo.length > 0 && (
<Section label="Traced to">
<ul className="termdetail-formalism-list">
{tracedTo.map(bid => {
const b = model.blocks.find(x => x.id === bid);
return (
<li key={bid}>
<button
type="button"
className="termdetail-formalism"
onClick={() => pushFrom(index, { kind: "block", id: bid })}
>
<span className="termdetail-formalism-kind">block</span>
<span className="termdetail-formalism-label">{b?.label ?? bid}</span>
<span className="termdetail-formalism-jump"></span>
</button>
</li>
);
})}
</ul>
</Section>
)}
</div>
</PaneFrame>
);
}
// ─── Finding column ─────────────────────────────────────────────────────
export function FindingColumn({
findingId,
index,
projectId,
onClose,
}: {
findingId: string;
index: number;
projectId: string;
onClose: () => void;
}) {
const { findings, decideFinding, refresh } = useAnalysis();
const { pushFrom } = useOpenPanes();
const f = findings.find(x => x.id === findingId);
if (!f) {
return (
<PaneFrame title="Finding" subtitle="not found" onClose={onClose}>
<div className="pane-empty">This finding no longer exists.</div>
</PaneFrame>
);
}
const isPending = f.status === "suggested" || f.status === "deprecated";
const termAnchors = f.linkedElementIds
.filter(s => s.startsWith("term:"))
.map(s => s.slice("term:".length));
const elementAnchors = f.linkedElementIds.filter(s => !s.startsWith("term:"));
return (
<PaneFrame
title={titleCase(f.kind)}
subtitle={
f.severity
? `${f.kind} · ${f.severity} · conf ${(f.confidence * 100).toFixed(0)}%`
: `${f.kind} · conf ${(f.confidence * 100).toFixed(0)}%`
}
right={
isPending ? (
<div className="term-decision-row">
<button
type="button"
className="term-decide term-decide-keep"
onClick={() => void decideFinding(f.id, "keep")}
>
Keep
</button>
<button
type="button"
className="term-decide term-decide-discard"
onClick={() => void decideFinding(f.id, "discard")}
>
Dismiss
</button>
<button
type="button"
className="term-decide term-decide-resolve"
onClick={() => void decideFinding(f.id, "resolve")}
>
Resolve
</button>
</div>
) : null
}
onClose={onClose}
>
<div className="column-body">
<Section label={f.validationCode ? `${f.validationCode} — finding` : "Finding"}>
<p className="termdetail-def">{f.text}</p>
</Section>
{(termAnchors.length > 0 || elementAnchors.length > 0) && (
<Section label="Anchors">
{termAnchors.map(tid => (
<button
key={tid}
type="button"
className="termdetail-chip"
onClick={() => pushFrom(index, { kind: "term", id: tid })}
>
concept
</button>
))}
{elementAnchors.map(eid => (
<button
key={eid}
type="button"
className="termdetail-chip"
onClick={() => pushFrom(index, { kind: "block", id: eid })}
>
{eid}
</button>
))}
</Section>
)}
<Section label="Discussion">
<ContextualSocratesThread
projectId={projectId}
findingId={f.id}
findingText={f.text}
onResolved={() => {
void refresh();
onClose();
}}
/>
</Section>
</div>
</PaneFrame>
);
}
// ─── Shared helpers ─────────────────────────────────────────────────────
/**
* Definition section for TermColumn. Read state by default; click anywhere
* on the definition (or the empty placeholder) to enter edit mode. Save
* with Cmd/Ctrl-Enter or by clicking Save; cancel with Esc. When the user
* has authored the definition (definitionPinned), shows a "pinned" chip
* in the header and a "Reset to AI suggestion" link in edit mode that
* clears the text + the pin.
*/
function DefinitionSection({ term }: { term: ClientTerm }) {
const { setTermDefinition } = useAnalysis();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(term.definition ?? "");
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
// When the user opens the editor, sync the draft with the latest text and
// focus the textarea. Selecting nothing keeps the cursor at the end.
useEffect(() => {
if (!editing) return;
setDraft(term.definition ?? "");
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
});
// term.id intentionally excluded from deps — re-running on every term
// change would clobber an in-flight edit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editing]);
const commit = () => {
setEditing(false);
const next = draft.trim();
const cur = (term.definition ?? "").trim();
if (next === cur) return;
void setTermDefinition(term.id, next.length > 0 ? next : null);
};
const cancel = () => {
setEditing(false);
setDraft(term.definition ?? "");
};
const reset = () => {
setEditing(false);
setDraft("");
void setTermDefinition(term.id, null);
};
const headerRight = term.definitionPinned ? (
<StatusChip variant="muted" label="pinned" title="You authored this definition. Future Analyze runs will leave it alone." />
) : null;
if (editing) {
return (
<Section label="Definition" right={headerRight}>
<div className="termdetail-def-edit">
<textarea
ref={textareaRef}
className="termdetail-def-textarea"
value={draft}
onChange={e => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={e => {
if (e.key === "Escape") {
e.preventDefault();
cancel();
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
commit();
}
}}
placeholder="A short noun-phrase definition…"
rows={3}
/>
<div className="termdetail-def-actions">
<button
type="button"
className="termdetail-def-btn termdetail-def-btn-primary"
onMouseDown={e => {
// Don't lose focus before commit — onBlur would fire and
// commit a possibly-stale value race.
e.preventDefault();
commit();
}}
>
Save
</button>
<button
type="button"
className="termdetail-def-btn"
onMouseDown={e => {
e.preventDefault();
cancel();
}}
>
Cancel
</button>
{term.definitionPinned ? (
<button
type="button"
className="termdetail-def-link"
onMouseDown={e => {
e.preventDefault();
reset();
}}
title="Clear the definition and let the next Analyze pass refill it from prose"
>
Reset to AI suggestion
</button>
) : null}
<span className="termdetail-def-hint"> to save · Esc to cancel</span>
</div>
</div>
</Section>
);
}
return (
<Section label="Definition" right={headerRight}>
{term.definition ? (
<p
className="termdetail-def termdetail-def-clickable"
onClick={() => setEditing(true)}
role="button"
tabIndex={0}
onKeyDown={e => {
if (e.key === "Enter") {
e.preventDefault();
setEditing(true);
}
}}
>
{term.definition}
</p>
) : (
<button
type="button"
className="termdetail-empty termdetail-def-clickable termdetail-def-empty-btn"
onClick={() => setEditing(true)}
>
Click to write a definition
</button>
)}
</Section>
);
}
function Section({ label, children, right }: { label: string; children: ReactNode; right?: ReactNode }) {
return (
<section className="termdetail-section">
<header className="termdetail-section-head">
<h3 className="termdetail-section-h">{label}</h3>
{right ? <div className="termdetail-section-right">{right}</div> : null}
</header>
<div className="termdetail-section-body">{children}</div>
</section>
);
}
function Row({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="termdetail-row">
<span className="termdetail-row-label">{label}</span>
<span className="termdetail-row-body">{children}</span>
</div>
);
}
function ReviewBadge({
status,
children,
}: {
status?: ReviewStatus;
children?: ReactNode;
}) {
if (status === "suggested" || status === "deprecated") {
return (
<div className="column-review-row">
<StatusChip variant={status} />
{children}
</div>
);
}
return null;
}
function DecideButtons({
status,
onKeep,
onDiscard,
}: {
kind: ReviewableElementKind;
id: string;
status?: ReviewStatus;
onKeep: () => void;
onDiscard: () => void;
}) {
if (status !== "suggested" && status !== "deprecated") return null;
return (
<span className="term-decision-row">
<button type="button" className="term-decide term-decide-keep" onClick={onKeep}>
Keep
</button>
<button type="button" className="term-decide term-decide-discard" onClick={onDiscard}>
Discard
</button>
</span>
);
}
function buildParentChain(
term: { id: string; parentId: string | null },
terms: Array<{ id: string; parentId: string | null; label: string }>
): Array<{ id: string; label: string }> {
const byId = new Map(terms.map(t => [t.id, t]));
const chain: Array<{ id: string; label: string }> = [];
let cur = term.parentId ? byId.get(term.parentId) : null;
const seen = new Set<string>();
while (cur && !seen.has(cur.id)) {
seen.add(cur.id);
chain.unshift({ id: cur.id, label: cur.label });
cur = cur.parentId ? byId.get(cur.parentId) : null;
}
return chain;
}
function labelOf(model: SysMLModel, id: string): string {
return model.blocks.find(b => b.id === id)?.label ?? id;
}
function titleCase(s: string): string {
return s.length ? s[0].toUpperCase() + s.slice(1) : s;
}

View File

@@ -0,0 +1,320 @@
// Concepts pane — unified view of taxonomy + glossary. The two layers share a
// single TaxonomyTerm table, so what looked like two panes was always one
// dataset rendered two ways. This pane gives the user a Tree/Alphabetical
// toggle and a single Analyze action that refreshes both layers.
//
// Re-running Analyze does NOT replace; it produces a *review*. Each row
// carries one of three statuses:
// accepted — confirmed, surfaces normally
// suggested — analyzer added in last run, awaits keep/discard
// deprecated — analyzer didn't see in last run, awaits keep/discard
// Pending statuses show a NEW / DEPRECATED badge plus inline Keep / Discard
// buttons. A Pending filter narrows the list to just the changes awaiting
// review when there are many.
//
// Each row is also draggable onto the Model canvas (MIME
// `application/x-socrata-term`), and clicking a row opens TermDetail.
"use client";
import { useMemo, useState } from "react";
import { PaneFrame } from "../PaneFrame";
import { PaneViewTabs } from "../PaneControls";
import { PaneEmpty } from "../PaneEmpty";
import { PaneDrawer } from "../PaneDrawer";
import { StatusChip } from "../StatusChip";
import { useAnalysis, type ClientTerm } from "../../../lib/workspace/analysisStore";
import { useOpenPanes } from "../../../lib/workspace/openPanesStore";
type ViewMode = "tree" | "alpha";
interface ConceptsPaneProps {
/** Position of this pane in the column stack (0 = root). */
index: number;
onClose: () => void;
}
export function ConceptsPane({ index, onClose }: ConceptsPaneProps) {
const { terms, analyze, inFlight } = useAnalysis();
const [mode, setMode] = useState<ViewMode>("tree");
const running = inFlight.has("concepts") || inFlight.has("all");
const kept = useMemo(() => terms.filter(t => t.status === "accepted"), [terms]);
const pending = useMemo(() => terms.filter(t => t.status !== "accepted"), [terms]);
const definedCount = kept.filter(t => t.definition && t.definition.length > 0).length;
const subtitle =
pending.length > 0
? `${kept.length} kept · ${definedCount} defined · ${pending.length} pending`
: `${kept.length} terms · ${definedCount} defined`;
const right = (
<div className="pane-controls">
<PaneViewTabs<ViewMode>
value={mode}
onChange={setMode}
ariaLabel="Concepts view mode"
tabs={[
{ value: "tree", label: "Tree", title: "Hierarchical view" },
{ value: "alpha", label: "AZ", title: "Alphabetical view with definitions" },
]}
/>
<button
type="button"
className="pane-action"
onClick={() => void analyze("concepts")}
disabled={running}
>
{running ? "Analyzing…" : "Analyze"}
</button>
</div>
);
return (
<PaneFrame title="Concepts" subtitle={subtitle} right={right} onClose={onClose}>
<div className="pane-drawer-stack">
<div className="pane-drawer-main">
{kept.length === 0 && pending.length === 0 ? (
<PaneEmpty
title="No concepts yet"
hint={
<>
Concepts are extracted from your prose. Write your idea in the editor on the right,
then run <strong>Analyze</strong>.
</>
}
action={{
label: running ? "Analyzing…" : "Run Analyze →",
onClick: () => void analyze("concepts"),
disabled: running,
}}
/>
) : kept.length === 0 ? (
<PaneEmpty
title="Nothing kept yet"
hint="Review the pending suggestions below to start building your concept list."
/>
) : mode === "tree" ? (
<TreeView terms={kept} parentIndex={index} />
) : (
<AlphaView terms={kept} parentIndex={index} />
)}
</div>
{pending.length > 0 ? (
<PaneDrawer
title="Pending review"
count={pending.length}
tone="pending"
defaultOpen
>
<AlphaView terms={pending} parentIndex={index} />
</PaneDrawer>
) : null}
</div>
</PaneFrame>
);
}
interface TreeNode {
term: ClientTerm;
children: TreeNode[];
}
function buildTree(terms: ClientTerm[]): TreeNode[] {
const byId = new Map<string, TreeNode>(terms.map(t => [t.id, { term: t, children: [] }]));
const roots: TreeNode[] = [];
for (const t of terms) {
const node = byId.get(t.id)!;
if (t.parentId && byId.has(t.parentId)) {
byId.get(t.parentId)!.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
function TreeView({ terms, parentIndex }: { terms: ClientTerm[]; parentIndex: number }) {
const tree = useMemo(() => buildTree(terms), [terms]);
return (
<ul className="term-list">
{tree.map(node => (
<TermNode key={node.term.id} node={node} depth={0} parentIndex={parentIndex} />
))}
</ul>
);
}
function AlphaView({ terms, parentIndex }: { terms: ClientTerm[]; parentIndex: number }) {
const sorted = useMemo(() => [...terms].sort((a, b) => a.label.localeCompare(b.label)), [terms]);
const { pushFrom, columns } = useOpenPanes();
const activeId =
columns[parentIndex + 1]?.kind === "term" ? columns[parentIndex + 1]!.id : null;
return (
<ul className="term-list">
{sorted.map(t => (
<li key={t.id}>
<ConceptCard
term={t}
active={activeId === t.id}
onOpen={() => pushFrom(parentIndex, { kind: "term", id: t.id })}
/>
</li>
))}
</ul>
);
}
/** The shared visual unit for both Tree and AZ views in Concepts. The
* leading column is ALWAYS the chevron slot — Tree passes a real toggle
* button, AZ passes nothing and we render an empty placeholder of the
* same width. That keeps the cards visually identical at every depth and
* across views; only the chevron's behavior differs. */
function ConceptCard({
term,
active,
onOpen,
chevron,
}: {
term: ClientTerm;
active: boolean;
onOpen: () => void;
chevron?: React.ReactNode;
}) {
return (
<div
className={`term-tile term-status-${term.status} ${active ? "row-active" : ""}`}
draggable
onDragStart={e => dragTerm(e, term)}
onClick={onOpen}
role="button"
tabIndex={0}
onKeyDown={e => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen();
}
}}
>
{chevron}
<div className="term-tile-content">
<div className="term-tile-head">
<span className="term-label">{term.label}</span>
<StatusBadge term={term} />
</div>
{term.definition ? (
<div className="term-def">{term.definition}</div>
) : (
<div className="term-def term-def-empty">(no definition yet)</div>
)}
<DecisionRow term={term} />
</div>
</div>
);
}
function TermNode({
node,
depth,
parentIndex,
}: {
node: TreeNode;
depth: number;
parentIndex: number;
}) {
const { pushFrom, columns } = useOpenPanes();
const [expanded, setExpanded] = useState(true);
const t = node.term;
const hasChildren = node.children.length > 0;
const activeId =
columns[parentIndex + 1]?.kind === "term" ? columns[parentIndex + 1]!.id : null;
const open = activeId === t.id;
// Only parents get a chevron; leaves render flush-left (matches AZ view).
const chevron = hasChildren ? (
<button
type="button"
className="term-tile-chevron"
onClick={e => {
e.stopPropagation();
setExpanded(v => !v);
}}
aria-label={expanded ? "Collapse" : "Expand"}
>
{expanded ? "▾" : "▸"}
</button>
) : undefined;
return (
<li className="term-tree-node" style={{ "--tree-depth": depth } as React.CSSProperties}>
<ConceptCard
term={t}
active={open}
onOpen={() => pushFrom(parentIndex, { kind: "term", id: t.id })}
chevron={chevron}
/>
{hasChildren && expanded ? (
<ul className="term-list term-list-children">
{node.children.map(c => (
<TermNode key={c.term.id} node={c} depth={depth + 1} parentIndex={parentIndex} />
))}
</ul>
) : null}
</li>
);
}
function StatusBadge({ term }: { term: ClientTerm }) {
if (term.status === "suggested") {
return <StatusChip variant="suggested" title="Added by latest analyze" />;
}
if (term.status === "deprecated") {
return <StatusChip variant="deprecated" title="Analyzer didn't see this in the latest run" />;
}
if (term.linkedBlockId) {
return <span className="term-linked-dot" title="Has a linked block"></span>;
}
return <span className="term-drag-hint">drag to canvas</span>;
}
function DecisionRow({ term }: { term: ClientTerm }) {
const { decideTerm } = useAnalysis();
if (term.status === "accepted") return null;
const isSuggested = term.status === "suggested";
return (
<div className="term-decision-row" onClick={e => e.stopPropagation()}>
<button
type="button"
className="term-decide term-decide-keep"
onClick={e => {
e.stopPropagation();
void decideTerm(term.id, "keep");
}}
title={isSuggested ? "Accept this suggestion" : "Pin this term despite the analyzer dropping it"}
>
Keep
</button>
<button
type="button"
className="term-decide term-decide-discard"
onClick={e => {
e.stopPropagation();
void decideTerm(term.id, "discard");
}}
title={isSuggested ? "Reject this suggestion" : "Remove this term"}
>
Discard
</button>
</div>
);
}
function dragTerm(e: React.DragEvent, t: ClientTerm) {
e.dataTransfer.setData(
"application/x-socrata-term",
JSON.stringify({ termId: t.id, label: t.label, definition: t.definition })
);
e.dataTransfer.effectAllowed = "move";
}

View File

@@ -0,0 +1,231 @@
// Findings pane — three groups of rows for one kind (assumption / risk /
// inconsistency):
//
// 1. Kept — status="accepted". Top of the pane, scrollable.
// 2. Pending — status in {suggested, deprecated}. Drawer below kept.
// 3. Discarded — status in {dismissed, resolved}. Collapsed drawer at the
// bottom, hidden when empty.
//
// Clicking a row pushes a FindingColumn to the right (full detail + Socrates
// thread + decision actions). Decisions also work inline from the row.
"use client";
import { useMemo } from "react";
import { PaneFrame } from "../PaneFrame";
import { PaneEmpty } from "../PaneEmpty";
import { PaneDrawer } from "../PaneDrawer";
import { StatusChip } from "../StatusChip";
import { useAnalysis, type ClientFinding } from "../../../lib/workspace/analysisStore";
import { useOpenPanes } from "../../../lib/workspace/openPanesStore";
interface FindingsPaneProps {
kind: "assumption" | "risk" | "inconsistency";
index: number;
projectId: string;
onClose: () => void;
}
const TITLES: Record<FindingsPaneProps["kind"], string> = {
assumption: "Assumptions",
risk: "Risks",
inconsistency: "Inconsistencies",
};
const SECTION_TO_ANALYZE = {
assumption: "assumptions",
risk: "risks",
inconsistency: "inconsistencies",
} as const;
const PENDING_STATUSES = new Set(["suggested", "deprecated"]);
const DISCARDED_STATUSES = new Set(["dismissed", "resolved"]);
export function FindingsPane({ kind, index, onClose }: FindingsPaneProps) {
const { findings, analyze, inFlight } = useAnalysis();
const { pushFrom, columns } = useOpenPanes();
const items = useMemo(() => findings.filter(f => f.kind === kind), [findings, kind]);
const kept = useMemo(() => items.filter(f => f.status === "accepted"), [items]);
const pending = useMemo(() => items.filter(f => PENDING_STATUSES.has(f.status)), [items]);
const discarded = useMemo(() => items.filter(f => DISCARDED_STATUSES.has(f.status)), [items]);
const section = SECTION_TO_ANALYZE[kind];
const running = inFlight.has(section) || inFlight.has("all");
const activeId =
columns[index + 1]?.kind === "finding" ? columns[index + 1]!.id : null;
const subtitle =
pending.length > 0
? `${kept.length} kept · ${pending.length} pending`
: `${kept.length} kept`;
const right = (
<div className="pane-controls">
<button
type="button"
className="pane-action"
onClick={() => void analyze(section)}
disabled={running}
>
{running ? "Analyzing…" : "Analyze"}
</button>
</div>
);
const renderRow = (f: ClientFinding) => (
<FindingRow
key={f.id}
finding={f}
active={activeId === f.id}
onOpen={() => pushFrom(index, { kind: "finding", id: f.id })}
/>
);
return (
<PaneFrame title={TITLES[kind]} subtitle={subtitle} right={right} onClose={onClose}>
<div className="pane-drawer-stack">
<div className="pane-drawer-main">
{items.length === 0 ? (
<PaneEmpty
title={`No ${TITLES[kind].toLowerCase()} yet`}
hint={`${TITLES[kind]} are detected against the current model. Run Analyze to scan it for issues.`}
action={{
label: running ? "Analyzing…" : "Run Analyze →",
onClick: () => void analyze(section),
disabled: running,
}}
/>
) : kept.length === 0 ? (
<PaneEmpty
title="Nothing kept yet"
hint="Review the pending findings below to start tracking the ones that matter."
/>
) : (
<ul className="finding-list">{kept.map(renderRow)}</ul>
)}
</div>
{pending.length > 0 ? (
<PaneDrawer title="Pending review" count={pending.length} tone="pending" defaultOpen>
<ul className="finding-list">{pending.map(renderRow)}</ul>
</PaneDrawer>
) : null}
<PaneDrawer
title="Discarded"
count={discarded.length}
tone="muted"
hideWhenEmpty
>
<ul className="finding-list">{discarded.map(renderRow)}</ul>
</PaneDrawer>
</div>
</PaneFrame>
);
}
function FindingRow({
finding,
active,
onOpen,
}: {
finding: ClientFinding;
active: boolean;
onOpen: () => void;
}) {
const { decideFinding } = useAnalysis();
const isPending = PENDING_STATUSES.has(finding.status);
const isDiscarded = DISCARDED_STATUSES.has(finding.status);
return (
<li
className={`finding-row finding-status-${finding.status} ${active ? "row-active" : ""}`}
onClick={onOpen}
role="button"
tabIndex={0}
onKeyDown={e => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen();
}
}}
>
<div className="finding-row-head finding-row-head-static">
<span className="finding-text">{finding.text}</span>
<span className="finding-meta">
{finding.status === "suggested" ? <StatusChip variant="suggested" /> : null}
{finding.status === "deprecated" ? <StatusChip variant="deprecated" /> : null}
{finding.status === "dismissed" ? <StatusChip variant="dismissed" /> : null}
{finding.status === "resolved" ? <StatusChip variant="resolved" /> : null}
{finding.validationCode ? (
<StatusChip variant="code" value={finding.validationCode} title="Validation rule" />
) : null}
{finding.severity ? (
<StatusChip
variant="severity"
value={finding.severity as "low" | "medium" | "high"}
title="Severity"
/>
) : null}
<StatusChip
variant="confidence"
value={finding.confidence}
title={`${Math.round(finding.confidence * 100)}% confidence`}
/>
</span>
</div>
{isPending ? (
<div className="term-decision-row" onClick={e => e.stopPropagation()}>
<button
type="button"
className="term-decide term-decide-keep"
onClick={e => {
e.stopPropagation();
void decideFinding(finding.id, "keep");
}}
>
Keep
</button>
<button
type="button"
className="term-decide term-decide-discard"
onClick={e => {
e.stopPropagation();
void decideFinding(finding.id, "discard");
}}
>
Dismiss
</button>
{finding.status === "suggested" ? (
<button
type="button"
className="term-decide term-decide-resolve"
onClick={e => {
e.stopPropagation();
void decideFinding(finding.id, "resolve");
}}
>
Resolve
</button>
) : null}
</div>
) : isDiscarded ? (
<div className="term-decision-row" onClick={e => e.stopPropagation()}>
<button
type="button"
className="term-decide term-decide-keep"
onClick={e => {
e.stopPropagation();
void decideFinding(finding.id, "restore");
}}
title="Move back to Kept"
>
Restore
</button>
</div>
) : null}
</li>
);
}

View File

@@ -0,0 +1,340 @@
// Model pane — fully editable React Flow diagram + a Summary subtab listing
// blocks/associations/constraints/requirements with a click-to-jump UX +
// review controls (Keep / Discard) on analyzer-suggested or analyzer-deprecated
// elements.
"use client";
import { useState } from "react";
import { PaneFrame } from "../PaneFrame";
import { PaneViewTabs, PaneFilterChip } from "../PaneControls";
import { StatusChip } from "../StatusChip";
import { DiagramCanvas } from "../../diagram-canvas/DiagramCanvas";
import { useModelStore } from "../../../lib/sync/ModelStore";
import { useAnalysis } from "../../../lib/workspace/analysisStore";
import { useEditorPaneContext } from "./paneContext";
import { useOpenPanes } from "../../../lib/workspace/openPanesStore";
import {
decideElement,
removeBlock,
removeAssociation,
removeConstraint,
removeRequirement,
type ReviewableElementKind,
} from "../../../lib/sync/ops";
import type { ReviewStatus, SysMLModel } from "../../../lib/sysml/model";
interface ModelPaneProps {
index: number;
onClose: () => void;
}
type Tab = "diagram" | "summary";
export function ModelPane({ index, onClose }: ModelPaneProps) {
const [tab, setTab] = useState<Tab>("diagram");
const [pendingOnly, setPendingOnly] = useState(false);
const { model, issuesByElement } = useModelStore();
const { analyze, inFlight } = useAnalysis();
const { focusBlockId, setFocusBlockId, projectId } = useEditorPaneContext();
const running = inFlight.has("model") || inFlight.has("all");
const pendingCount = countPending(model);
const right = (
<div className="pane-controls">
<PaneViewTabs<Tab>
value={tab}
onChange={setTab}
ariaLabel="Model view mode"
tabs={[
{ value: "diagram", label: "Diagram" },
{ value: "summary", label: "Summary" },
]}
/>
<PaneFilterChip
active={pendingOnly}
onToggle={() => {
// Pending now overlays the current view mode rather than replacing
// it. If we're on Diagram and the user wants to triage pending
// items, switching to Summary makes far more sense than rendering
// a filtered diagram, so we still nudge to Summary on toggle-on.
setPendingOnly(p => {
if (!p && tab === "diagram") setTab("summary");
return !p;
});
}}
label="Pending"
count={pendingCount}
title="Show only suggestions and deprecated model elements"
/>
<button
type="button"
className="pane-action"
onClick={() => void analyze("model")}
disabled={running}
title="Re-derive model from prose"
>
{running ? "Analyzing…" : "Analyze"}
</button>
</div>
);
const subtitle =
pendingCount > 0
? `${model.blocks.length} blocks · ${model.associations.length} assocs · ${model.constraints.length} constraints · ${pendingCount} pending`
: `${model.blocks.length} blocks · ${model.associations.length} assocs · ${model.constraints.length} constraints`;
return (
<PaneFrame title="Model" subtitle={subtitle} right={right} onClose={onClose}>
{tab === "diagram" && !pendingOnly ? (
<div className="canvas-scroll canvas-scroll-diagram">
<DiagramCanvas
focusBlockId={focusBlockId}
onSelect={setFocusBlockId}
issuesByElement={issuesByElement}
projectId={projectId}
/>
</div>
) : (
<ModelSummary pendingOnly={pendingOnly} parentIndex={index} />
)}
</PaneFrame>
);
}
function countPending(model: SysMLModel): number {
const isPending = (rs?: ReviewStatus) => rs === "suggested" || rs === "deprecated";
return (
model.blocks.filter(b => isPending(b.reviewStatus)).length +
model.associations.filter(a => isPending(a.reviewStatus)).length +
model.constraints.filter(c => isPending(c.reviewStatus)).length +
model.requirements.filter(r => isPending(r.reviewStatus)).length
);
}
function ModelSummary({ pendingOnly, parentIndex }: { pendingOnly: boolean; parentIndex: number }) {
const { model, apply } = useModelStore();
const { pushFrom } = useOpenPanes();
const filterPending = <T extends { reviewStatus?: ReviewStatus }>(xs: T[]): T[] =>
pendingOnly ? xs.filter(x => x.reviewStatus === "suggested" || x.reviewStatus === "deprecated") : xs;
const blocks = filterPending(model.blocks);
const associations = filterPending(model.associations);
const constraints = filterPending(model.constraints);
const requirements = filterPending(model.requirements);
const totalShown = blocks.length + associations.length + constraints.length + requirements.length;
if (totalShown === 0) {
return (
<div className="pane-empty">
{pendingOnly ? (
<>No pending changes. Everything is up to date.</>
) : (
<>
No model yet. Click <strong>Analyze</strong> to derive one from your prose, or open the
diagram and start dragging.
</>
)}
</div>
);
}
const decideKeep = (kind: ReviewableElementKind, id: string) =>
apply([decideElement({ kind, id })]);
const decideDiscard = (kind: ReviewableElementKind, id: string) => {
if (kind === "block") apply([removeBlock(id)]);
else if (kind === "association") apply([removeAssociation(id)]);
else if (kind === "constraint") apply([removeConstraint(id)]);
else if (kind === "requirement") apply([removeRequirement(id)]);
};
return (
<div className="model-summary">
{blocks.length > 0 && (
<>
<h3 className="model-summary-h">Blocks ({blocks.length})</h3>
<ul className="model-summary-list">
{blocks.map(b => (
<li key={b.id}>
<ReviewRow status={b.reviewStatus}>
<button
type="button"
className="model-summary-row"
onClick={() => pushFrom(parentIndex, { kind: "block", id: b.id })}
>
<span className="model-summary-kind">{b.kind}</span>
<span className="model-summary-label">{b.label}</span>
</button>
{b.linkedTermId ? (
<button
type="button"
className="model-summary-link"
title="Open concept detail"
onClick={() =>
pushFrom(parentIndex, { kind: "term", id: b.linkedTermId! })
}
>
concept
</button>
) : null}
<DecideButtons
status={b.reviewStatus}
onKeep={() => decideKeep("block", b.id)}
onDiscard={() => decideDiscard("block", b.id)}
/>
</ReviewRow>
</li>
))}
</ul>
</>
)}
{associations.length > 0 && (
<>
<h3 className="model-summary-h">Associations ({associations.length})</h3>
<ul className="model-summary-list">
{associations.map(a => (
<li key={a.id}>
<ReviewRow status={a.reviewStatus}>
<button
type="button"
className="model-summary-row"
onClick={() => pushFrom(parentIndex, { kind: "association", id: a.id })}
>
<span className="model-summary-kind">{a.kind}</span>
<span className="model-summary-label">
{labelOf(model, a.fromBlockId)} <em>{a.label || "→"}</em>{" "}
{labelOf(model, a.toBlockId)}
</span>
</button>
<DecideButtons
status={a.reviewStatus}
onKeep={() => decideKeep("association", a.id)}
onDiscard={() => decideDiscard("association", a.id)}
/>
</ReviewRow>
</li>
))}
</ul>
</>
)}
{constraints.length > 0 && (
<>
<h3 className="model-summary-h">Constraints ({constraints.length})</h3>
<ul className="model-summary-list">
{constraints.map(c => (
<li key={c.id}>
<ReviewRow status={c.reviewStatus}>
<button
type="button"
className="model-summary-row"
onClick={() => pushFrom(parentIndex, { kind: "constraint", id: c.id })}
>
<span className="model-summary-kind">constraint</span>
<span className="model-summary-label">
{c.label}
{c.expression ? <> <code>{c.expression}</code></> : null}
</span>
</button>
<DecideButtons
status={c.reviewStatus}
onKeep={() => decideKeep("constraint", c.id)}
onDiscard={() => decideDiscard("constraint", c.id)}
/>
</ReviewRow>
</li>
))}
</ul>
</>
)}
{requirements.length > 0 && (
<>
<h3 className="model-summary-h">Requirements ({requirements.length})</h3>
<ul className="model-summary-list">
{requirements.map(r => (
<li key={r.id}>
<ReviewRow status={r.reviewStatus}>
<button
type="button"
className="model-summary-row"
onClick={() => pushFrom(parentIndex, { kind: "requirement", id: r.id })}
>
<span className="model-summary-kind">{r.tag}</span>
<span className="model-summary-label">{r.text}</span>
</button>
<DecideButtons
status={r.reviewStatus}
onKeep={() => decideKeep("requirement", r.id)}
onDiscard={() => decideDiscard("requirement", r.id)}
/>
</ReviewRow>
</li>
))}
</ul>
</>
)}
</div>
);
}
function ReviewRow({
status,
children,
}: {
status?: ReviewStatus;
children: React.ReactNode;
}) {
return (
<div className={`model-summary-row-wrap model-status-${status ?? "accepted"}`}>
{status === "suggested" ? <StatusChip variant="suggested" /> : null}
{status === "deprecated" ? <StatusChip variant="deprecated" /> : null}
{children}
</div>
);
}
function DecideButtons({
status,
onKeep,
onDiscard,
}: {
status?: ReviewStatus;
onKeep: () => void;
onDiscard: () => void;
}) {
if (status !== "suggested" && status !== "deprecated") return null;
return (
<span className="model-decide-row">
<button
type="button"
className="term-decide term-decide-keep"
onClick={e => {
e.stopPropagation();
onKeep();
}}
title={
status === "suggested" ? "Accept this analyzer suggestion" : "Pin this element despite the analyzer dropping it"
}
>
Keep
</button>
<button
type="button"
className="term-decide term-decide-discard"
onClick={e => {
e.stopPropagation();
onDiscard();
}}
title={status === "suggested" ? "Reject this suggestion" : "Remove this element"}
>
Discard
</button>
</span>
);
}
function labelOf(model: SysMLModel, id: string): string {
return model.blocks.find(b => b.id === id)?.label ?? id;
}

View File

@@ -0,0 +1,212 @@
// Requirements pane — list of extracted requirements with traceability +
// unsupported flags. Click a requirement's traced block → focus that block
// in Model. Re-running Analyze MERGES; new and deprecated requirements
// surface for the user to keep or discard.
"use client";
import { useMemo } from "react";
import { PaneFrame } from "../PaneFrame";
import { PaneEmpty } from "../PaneEmpty";
import { PaneDrawer } from "../PaneDrawer";
import { StatusChip } from "../StatusChip";
import { useAnalysis, type ClientRequirement } from "../../../lib/workspace/analysisStore";
import { useOpenPanes } from "../../../lib/workspace/openPanesStore";
import { useModelStore } from "../../../lib/sync/ModelStore";
interface RequirementsPaneProps {
index: number;
onClose: () => void;
}
export function RequirementsPane({ index, onClose }: RequirementsPaneProps) {
const { requirements, analyze, inFlight } = useAnalysis();
const { pushFrom } = useOpenPanes();
const { model } = useModelStore();
const running = inFlight.has("requirements") || inFlight.has("all");
const kept = useMemo(
() => requirements.filter(r => r.status === "accepted"),
[requirements]
);
const pending = useMemo(
() => requirements.filter(r => r.status !== "accepted"),
[requirements]
);
const unsupportedCount = kept.filter(r => r.unsupported).length;
const labelOf = (id: string) => model.blocks.find(b => b.id === id)?.label ?? id;
const subtitle =
requirements.length === 0
? "no requirements yet"
: pending.length > 0
? `${kept.length} kept · ${pending.length} pending`
: unsupportedCount > 0
? `${kept.length} total · ${unsupportedCount} unsupported`
: `${kept.length} total · all traced`;
const right = (
<div className="pane-controls">
<button
type="button"
className="pane-action"
onClick={() => void analyze("requirements")}
disabled={running}
>
{running ? "Analyzing…" : "Analyze"}
</button>
</div>
);
const renderRow = (r: ClientRequirement) => (
<RequirementItem
key={r.id}
req={r}
labelOf={labelOf}
onOpenSelf={() => pushFrom(index, { kind: "requirement", id: r.id })}
onJumpToBlock={id => pushFrom(index, { kind: "block", id })}
onJumpToTerm={id => pushFrom(index, { kind: "term", id })}
/>
);
return (
<PaneFrame title="Requirements" subtitle={subtitle} right={right} onClose={onClose}>
<div className="pane-drawer-stack">
<div className="pane-drawer-main">
{kept.length === 0 && pending.length === 0 ? (
<PaneEmpty
title="No requirements yet"
hint={
<>
Requirements are extracted from sentences in your prose that say the system
<em> must / should / needs to</em>. Run <strong>Analyze</strong> to scan.
</>
}
action={{
label: running ? "Analyzing…" : "Run Analyze →",
onClick: () => void analyze("requirements"),
disabled: running,
}}
/>
) : kept.length === 0 ? (
<PaneEmpty
title="Nothing kept yet"
hint="Review the pending requirements below."
/>
) : (
<ul className="req-list">{kept.map(renderRow)}</ul>
)}
</div>
{pending.length > 0 ? (
<PaneDrawer
title="Pending review"
count={pending.length}
tone="pending"
defaultOpen
>
<ul className="req-list">{pending.map(renderRow)}</ul>
</PaneDrawer>
) : null}
</div>
</PaneFrame>
);
}
interface RequirementItemProps {
req: ClientRequirement;
labelOf: (id: string) => string;
onOpenSelf: () => void;
onJumpToBlock: (id: string) => void;
onJumpToTerm: (id: string) => void;
}
function RequirementItem({ req, labelOf, onOpenSelf, onJumpToBlock, onJumpToTerm }: RequirementItemProps) {
const { decideRequirement } = useAnalysis();
return (
<li
className={`req-row req-status-${req.status} ${req.unsupported ? "req-row-unsupported" : ""}`}
onClick={onOpenSelf}
role="button"
tabIndex={0}
onKeyDown={e => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenSelf();
}
}}
>
<div className="req-row-head">
<span className="req-tag">{req.tag}</span>
<span className="req-row-meta">
<ReqStatusBadge status={req.status} />
{req.unsupported ? <StatusChip variant="warn" label="unsupported" /> : null}
</span>
</div>
<div className="req-text">{req.text}</div>
{req.linkedTermId ? (
<button
type="button"
className="req-term-link"
onClick={e => {
e.stopPropagation();
onJumpToTerm(req.linkedTermId!);
}}
title="Open concept detail"
>
concept
</button>
) : null}
{req.tracedToIds.length > 0 ? (
<div className="req-traced" onClick={e => e.stopPropagation()}>
Traced to:{" "}
{req.tracedToIds.map((id, i) => (
<button
key={id}
type="button"
className="req-traced-link"
onClick={e => {
e.stopPropagation();
onJumpToBlock(id);
}}
>
{labelOf(id)}
{i < req.tracedToIds.length - 1 ? ", " : ""}
</button>
))}
</div>
) : null}
{req.status !== "accepted" ? (
<div className="term-decision-row" onClick={e => e.stopPropagation()}>
<button
type="button"
className="term-decide term-decide-keep"
onClick={e => {
e.stopPropagation();
void decideRequirement(req.id, "keep");
}}
>
Keep
</button>
<button
type="button"
className="term-decide term-decide-discard"
onClick={e => {
e.stopPropagation();
void decideRequirement(req.id, "discard");
}}
>
Discard
</button>
</div>
) : null}
</li>
);
}
function ReqStatusBadge({ status }: { status: ClientRequirement["status"] }) {
if (status === "suggested") return <StatusChip variant="suggested" />;
if (status === "deprecated") return <StatusChip variant="deprecated" />;
return null;
}

View File

@@ -0,0 +1,22 @@
// Pinned text-editor pane. Always present in the workspace; not closable.
"use client";
import { TextCanvas } from "../../text-canvas/TextCanvas";
import { PaneFrame } from "../PaneFrame";
import type { FixtureData } from "../../../lib/fixtures/aristotle";
interface TextCanvasPaneProps {
data: FixtureData;
projectId: string;
}
export function TextCanvasPane({ data, projectId }: TextCanvasPaneProps) {
return (
<PaneFrame title="Narrative" subtitle="Your document" closable={false}>
<div className="canvas-scroll">
<TextCanvas data={data} projectId={projectId} />
</div>
</PaneFrame>
);
}

View File

@@ -0,0 +1,37 @@
// Shared context for cross-pane focus state and projectId plumbing.
//
// `focusBlockId` drives diagram selection / chip hover styling. The richer
// term/finding/etc detail navigation now lives in the column-stack
// (openPanesStore), so this context is intentionally minimal.
"use client";
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
interface PaneContextValue {
projectId: string;
focusBlockId: string | null;
setFocusBlockId: (id: string | null) => void;
}
const Ctx = createContext<PaneContextValue | null>(null);
interface ProviderProps {
projectId: string;
children: ReactNode;
}
export function EditorPaneContextProvider({ projectId, children }: ProviderProps) {
const [focusBlockId, setFocusBlockId] = useState<string | null>(null);
const value = useMemo<PaneContextValue>(
() => ({ projectId, focusBlockId, setFocusBlockId }),
[projectId, focusBlockId]
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useEditorPaneContext(): PaneContextValue {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useEditorPaneContext must be inside <EditorPaneContextProvider>");
return ctx;
}

View File

@@ -1,74 +1,166 @@
// Seed onboarding screen — two-column layout: emerging-seed rail (left) + Socrates conversation (right). // Live seed interview screen.
// Ported from docs/design-source/socrata/project/seed-screen.jsx. //
// 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"; import { Sigil } from "../socrates/Sigil";
interface SeedField { interface InterviewTurn {
key: string; role: "socrates" | "user";
label: string;
value: string;
inferred?: boolean;
}
interface ThreadTurn {
who: "socrates" | "user";
text: string; text: string;
pending?: boolean;
} }
const fields: SeedField[] = [ interface SeedDraft {
{ title: string;
key: "problem", problem: string;
label: "Problem", targetUser: string;
value: "First-year STEM students disengage in the long tail between lectures and office hours; a self-efficacy gap forms quickly.", desiredOutcome: string;
}, initialHypothesis?: string;
{ constraints?: string[];
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,
},
];
const thread: ThreadTurn[] = [ const EMPTY_DRAFT: SeedDraft = {
{ title: "",
who: "socrates", problem: "",
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?", targetUser: "",
}, desiredOutcome: "",
{ };
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?",
},
];
export function SeedScreen() { 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 ( return (
<div className="seed-screen"> <div className="seed-screen">
<header className="seed-top"> <header className="seed-top">
@@ -76,110 +168,147 @@ export function SeedScreen() {
<Sigil size={28} /> <Sigil size={28} />
<span className="seed-brand">Socrata</span> <span className="seed-brand">Socrata</span>
<span className="seed-pip">·</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>
<div className="seed-top-right"> <div className="seed-top-right">
<span className="seed-mode-pill seed-mode-active">Interview</span> <a href="/" className="seed-mode-pill" style={{ textDecoration: "none" }}> back</a>
<span className="seed-mode-pill">Form</span>
</div> </div>
</header> </header>
<div className="seed-body"> <div className="seed-body">
{/* Left: emerging seed */}
<section className="seed-left"> <section className="seed-left">
<div className="seed-section-label">Emerging seed</div> <div className="seed-section-label">Emerging seed</div>
<div className="seed-fields"> <div className="seed-fields">
{fields.map(f => ( <Field label="Title" value={draft.title} />
<div key={f.key} className={`seed-field ${f.inferred ? "seed-field-inferred" : ""}`}> <Field label="Problem" value={draft.problem} />
<div className="seed-field-label"> <Field label="Target user" value={draft.targetUser} />
{f.label} <Field label="Desired outcome" value={draft.desiredOutcome} />
{f.inferred && <span className="seed-conf">inferred · 0.74</span>} {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 className="seed-field-value">{f.value}</div>
</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>
<div className="seed-confidence"> <div className="seed-confidence">
<div className="seed-confidence-row"> <div className="seed-confidence-row">
<span>Model confidence</span> <span>Draft confidence</span>
<span>0.62</span> <span>{confidencePct}%</span>
</div> </div>
<div className="seed-confidence-bar"> <div className="seed-confidence-bar">
<div className="seed-confidence-fill" style={{ width: "62%" }} /> <div className="seed-confidence-fill" style={{ width: `${confidencePct}%` }} />
</div> </div>
<div className="seed-confidence-hint"> <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> </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>
{/* Right: Socrates conversation */}
<section className="seed-right"> <section className="seed-right">
<div className="seed-thread"> <div className="seed-thread">
{thread.map((m, i) => ( {history.map((m, i) => (
<div key={i} className={`seed-bubble seed-bubble-${m.who}`}> <div key={i} className={`seed-bubble seed-bubble-${m.role}`}>
{m.who === "socrates" && ( {m.role === "socrates" && (
<div className="seed-bubble-avatar"> <div className="seed-bubble-avatar">
<Sigil size={32} /> <Sigil size={32} />
</div> </div>
)} )}
<div className="seed-bubble-body"> <div className="seed-bubble-body">
<div className="seed-bubble-who">{m.who === "socrates" ? "Socrates" : "You"}</div> <div className="seed-bubble-who">{m.role === "socrates" ? "Socrates" : "You"}</div>
<div className="seed-bubble-text">{m.text}</div> <div
className="seed-bubble-text"
style={m.pending ? { opacity: 0.55, fontStyle: "italic" } : undefined}
>
{m.text}
</div>
</div> </div>
</div> </div>
))} ))}
<div ref={threadEndRef} />
<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> </div>
<div className="seed-input-row"> <form
className="seed-input-row"
onSubmit={e => {
e.preventDefault();
void onSubmit();
}}
>
<div className="seed-input"> <div className="seed-input">
<span className="seed-input-prompt"></span> <span className="seed-input-prompt"></span>
<span className="seed-input-text"> <input
Public-university undergrads, weeks 310. A partner that asks. The market is saturated with explainers. className="seed-input-field"
</span> value={input}
<span className="seed-input-caret" /> 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>
<div className="seed-input-actions"> <div className="seed-input-actions">
<button className="seed-btn" type="button">Save draft</button> <button
<button className="seed-btn seed-btn-primary" type="button">Send · </button> type="submit"
className="seed-btn seed-btn-primary"
disabled={sending || generating || !input.trim()}
>
Send ·
</button>
</div> </div>
</div> </form>
</section> </section>
</div> </div>
</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>
);
}

View File

@@ -0,0 +1,195 @@
// Inline mini-Socrates thread anchored to a single finding. Replaces the
// global SocratesDock for the conversational surface.
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Sigil } from "./Sigil";
interface ApiMessage {
id: string;
role: "user" | "assistant" | string;
text: string;
options?: Array<{ n: number; label: string; sub?: string }>;
}
interface ContextualSocratesThreadProps {
projectId: string;
findingId: string;
findingText: string;
onResolved?: () => void;
}
export function ContextualSocratesThread({ projectId, findingId, onResolved }: ContextualSocratesThreadProps) {
const [messages, setMessages] = useState<ApiMessage[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [loaded, setLoaded] = useState(false);
const endRef = useRef<HTMLDivElement | null>(null);
const url = `/api/projects/${encodeURIComponent(projectId)}/findings/${encodeURIComponent(findingId)}/socrates`;
// Initial load + open the conversation if empty.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`load: ${res.status}`);
const body = (await res.json()) as { messages?: ApiMessage[] };
if (cancelled) return;
const initial = body.messages ?? [];
setMessages(initial);
setLoaded(true);
if (initial.length === 0) {
await sendImpl("");
}
} catch (err) {
console.error("[ContextualSocratesThread] load failed:", err);
if (!cancelled) setLoaded(true);
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [messages]);
const sendImpl = useCallback(
async (text: string) => {
setSending(true);
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
if (!res.ok) throw new Error(`send: ${res.status}`);
const body = (await res.json()) as {
assistant: { id: string; turn: { text: string; options?: Array<{ n: number; label: string; sub?: string }> } };
user: { id: string };
};
setMessages(prev => {
const next = [...prev];
if (text.trim().length > 0) {
next.push({ id: body.user.id, role: "user", text });
}
next.push({
id: body.assistant.id,
role: "assistant",
text: body.assistant.turn.text,
options: body.assistant.turn.options,
});
return next;
});
} catch (err) {
console.error("[ContextualSocratesThread] send failed:", err);
} finally {
setSending(false);
}
},
[url]
);
const onSend = useCallback(async () => {
const text = input.trim();
if (!text || sending) return;
setInput("");
await sendImpl(text);
}, [input, sending, sendImpl]);
const onResolve = useCallback(async () => {
try {
await fetch(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "resolved" }),
});
onResolved?.();
} catch (err) {
console.error("[ContextualSocratesThread] resolve failed:", err);
}
}, [url, onResolved]);
const onDismiss = useCallback(async () => {
try {
await fetch(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "dismissed" }),
});
onResolved?.();
} catch (err) {
console.error("[ContextualSocratesThread] dismiss failed:", err);
}
}, [url, onResolved]);
return (
<div className="ctx-thread">
<div className="ctx-thread-messages">
{!loaded ? (
<div className="ctx-thread-loading"></div>
) : (
messages.map(m => (
<div key={m.id} className={`ctx-bubble ctx-bubble-${m.role}`}>
{m.role === "assistant" ? <Sigil size={18} /> : null}
<div className="ctx-bubble-body">
<div className="ctx-bubble-text">{m.text}</div>
{m.options && m.options.length > 0 ? (
<div className="ctx-bubble-options">
{m.options.map(o => (
<button
key={o.n}
type="button"
className="ctx-option"
onClick={() => setInput(prev => (prev ? `${prev} ${o.label}` : o.label))}
>
<span className="ctx-option-n">{o.n}.</span>
<span className="ctx-option-label">{o.label}</span>
{o.sub ? <span className="ctx-option-sub">{o.sub}</span> : null}
</button>
))}
</div>
) : null}
</div>
</div>
))
)}
<div ref={endRef} />
</div>
<div className="ctx-thread-input-row">
<textarea
className="ctx-thread-input"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
void onSend();
}
}}
placeholder="Reply to Socrates…"
rows={2}
/>
<div className="ctx-thread-actions">
<button type="button" className="ctx-send" onClick={onSend} disabled={sending || !input.trim()}>
</button>
</div>
</div>
<div className="ctx-thread-resolve">
<button type="button" className="ctx-resolve-btn" onClick={onResolve}>
Mark resolved
</button>
<button type="button" className="ctx-dismiss-btn" onClick={onDismiss}>
Dismiss
</button>
</div>
</div>
);
}

View File

@@ -1,208 +0,0 @@
// In-dock card showing a Socrates-proposed change.
//
// Displays: reasoning, op summary, impact summary (added/removed/changed +
// validation diff), and Accept / Reject controls. Accept calls into the
// ModelStore which routes through the same applyOps + persistence pipeline
// as user-originated edits.
"use client";
import { useState } from "react";
import type { ImpactSummary } from "../../lib/sysml/impact";
import type { ModelOp } from "../../lib/sync/ops";
export interface ProposalPayload {
reasoning: string;
ops: ModelOp[];
impactSummary: ImpactSummary;
meta?: { provider?: string; model?: string };
}
interface ProposalCardProps {
proposal: ProposalPayload;
onAccept: () => Promise<void> | void;
onReject: () => void;
pending?: boolean;
}
export function ProposalCard({ proposal, onAccept, onReject, pending }: ProposalCardProps) {
const [busy, setBusy] = useState(false);
const { reasoning, ops, impactSummary } = proposal;
async function accept() {
if (busy) return;
setBusy(true);
try {
await onAccept();
} finally {
setBusy(false);
}
}
return (
<div className={`proposal-card ${pending ? "proposal-card-pending" : ""}`}>
<div className="proposal-card-head">
<span className="proposal-card-tag">PROPOSAL</span>
<span className="proposal-card-stats">
{summarizeStats(impactSummary)}
</span>
</div>
<div className="proposal-card-reasoning">{reasoning}</div>
{ops.length > 0 && (
<details className="proposal-card-ops" open>
<summary>Ops · {ops.length}</summary>
<ul>
{ops.map((op, i) => (
<li key={i}>{summarizeOp(op)}</li>
))}
</ul>
</details>
)}
{(impactSummary.added.length > 0 || impactSummary.removed.length > 0 || impactSummary.changed.length > 0) && (
<div className="proposal-card-section">
<div className="proposal-card-section-label">Impact</div>
<div className="proposal-card-impact-grid">
{impactSummary.added.length > 0 && (
<div className="proposal-card-impact-row">
<span className="proposal-card-impact-key">+ added</span>
<span className="proposal-card-impact-val">
{impactSummary.added.map(e => `${e.label} (${e.kind})`).join(", ")}
</span>
</div>
)}
{impactSummary.removed.length > 0 && (
<div className="proposal-card-impact-row">
<span className="proposal-card-impact-key"> removed</span>
<span className="proposal-card-impact-val">
{impactSummary.removed.map(e => `${e.label} (${e.kind})`).join(", ")}
</span>
</div>
)}
{impactSummary.changed.length > 0 && (
<div className="proposal-card-impact-row">
<span className="proposal-card-impact-key">~ changed</span>
<span className="proposal-card-impact-val">
{impactSummary.changed.map(e => `${e.label} (${e.kind})`).join(", ")}
</span>
</div>
)}
</div>
</div>
)}
{(impactSummary.issuesCreated.length > 0 || impactSummary.issuesResolved.length > 0) && (
<div className="proposal-card-section">
<div className="proposal-card-section-label">Validation</div>
{impactSummary.issuesResolved.length > 0 && (
<ul className="proposal-card-issue-list proposal-card-issues-resolved">
{impactSummary.issuesResolved.slice(0, 4).map((i, idx) => (
<li key={idx}> resolves {i.code}: {i.message}</li>
))}
</ul>
)}
{impactSummary.issuesCreated.length > 0 && (
<ul className="proposal-card-issue-list proposal-card-issues-created">
{impactSummary.issuesCreated.slice(0, 4).map((i, idx) => (
<li key={idx}> creates {i.code}: {i.message}</li>
))}
</ul>
)}
</div>
)}
{!impactSummary.ok && (
<div className="proposal-card-section proposal-card-error">
<div className="proposal-card-section-label">Cannot apply</div>
<ul className="proposal-card-issue-list">
{impactSummary.errors.map((e, i) => (
<li key={i}>{e.code}: {e.message}</li>
))}
</ul>
</div>
)}
<div className="proposal-card-actions">
<button
type="button"
className="proposal-card-btn proposal-card-btn-primary"
onClick={accept}
disabled={busy || pending || !impactSummary.ok || ops.length === 0}
>
{busy ? "Applying…" : "Accept"}
</button>
<button
type="button"
className="proposal-card-btn"
onClick={onReject}
disabled={busy || pending}
>
Reject
</button>
</div>
{proposal.meta?.model && (
<div className="proposal-card-meta">
via {proposal.meta.provider} · {proposal.meta.model}
</div>
)}
</div>
);
}
function summarizeStats(impact: ImpactSummary): string {
if (!impact.ok) return "would not apply";
const parts: string[] = [];
const { stats } = impact;
if (stats.blocksDelta) parts.push(`${signed(stats.blocksDelta)} block${Math.abs(stats.blocksDelta) === 1 ? "" : "s"}`);
if (stats.associationsDelta) parts.push(`${signed(stats.associationsDelta)} assoc`);
if (stats.constraintsDelta) parts.push(`${signed(stats.constraintsDelta)} constraint${Math.abs(stats.constraintsDelta) === 1 ? "" : "s"}`);
if (stats.requirementsDelta) parts.push(`${signed(stats.requirementsDelta)} req${Math.abs(stats.requirementsDelta) === 1 ? "" : "s"}`);
if (stats.issuesDelta) parts.push(`${signed(stats.issuesDelta)} issue${Math.abs(stats.issuesDelta) === 1 ? "" : "s"}`);
if (parts.length === 0) parts.push("structural rearrangement");
return parts.join(" · ");
}
function signed(n: number): string {
return n > 0 ? `+${n}` : `${n}`;
}
function summarizeOp(op: ModelOp): string {
switch (op.kind) {
case "add-block":
return `+ block "${op.block.label}" (${op.block.kind})`;
case "remove-block":
return ` block ${op.blockId}`;
case "update-block":
return `~ block ${op.blockId}`;
case "add-association":
return `+ assoc ${op.association.fromBlockId}${op.association.toBlockId} (${op.association.kind})`;
case "remove-association":
return ` assoc ${op.associationId}`;
case "update-association":
return `~ assoc ${op.associationId}`;
case "add-constraint":
return `+ constraint "${op.constraint.label}"`;
case "remove-constraint":
return ` constraint ${op.constraintId}`;
case "update-constraint":
return `~ constraint ${op.constraintId}`;
case "add-requirement":
return `+ req ${op.requirement.tag}`;
case "remove-requirement":
return ` req ${op.requirementId}`;
case "update-requirement":
return `~ req ${op.requirementId}`;
case "add-property":
return `+ property ${op.blockId}.${op.property.name}`;
case "update-property":
return `~ property ${op.blockId}.${op.propertyId}`;
case "remove-property":
return ` property ${op.blockId}.${op.propertyId}`;
case "add-relation":
return `+ relation ${op.requirementId}.${op.relation.kind}`;
case "remove-relation":
return ` relation ${op.requirementId}[${op.relationIndex}]`;
}
}

View File

@@ -1,389 +0,0 @@
// Active-thread dock with Sigil header, conversation bubbles, numbered options.
//
// M6: live thread loaded from /api/projects/[id]/socrates. The reply input
// POSTs each user turn and renders Socrates' response when it arrives.
// Numbered options pre-fill the input when clicked / pressed (13).
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Sigil } from "./Sigil";
import { ProposalCard, type ProposalPayload } from "./ProposalCard";
import { useApply } from "../../lib/sync/ModelStore";
export type SocratesPresence = "subtle" | "default" | "prominent";
export type Density = "comfortable" | "compact";
export interface DockMessage {
id: string;
role: "user" | "assistant" | "proposal" | "system";
text: string;
options?: Array<{ n: number; label: string; sub?: string }>;
pending?: boolean;
proposal?: ProposalPayload;
}
interface SocratesDockProps {
projectId: string;
presence: SocratesPresence;
density: Density;
}
interface ApiMessage {
id: string;
role: "user" | "assistant";
text: string;
options?: Array<{ n: number; label: string; sub?: string }>;
createdAt: string;
}
export function SocratesDock({ projectId, presence }: SocratesDockProps) {
const [messages, setMessages] = useState<DockMessage[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [proposing, setProposing] = useState(false);
const [loaded, setLoaded] = useState(false);
const [meta, setMeta] = useState<{ provider?: string; model?: string }>({});
const threadEndRef = useRef<HTMLDivElement | null>(null);
const apply = useApply();
// Load thread on mount and trigger an opening turn if the thread is empty.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/socrates`);
if (!res.ok) throw new Error(`load thread: ${res.status}`);
const data = (await res.json()) as { messages?: ApiMessage[] };
if (cancelled) return;
const initial: DockMessage[] = (data.messages ?? []).map(m => ({
id: m.id,
role: m.role,
text: m.text,
options: m.options,
}));
setMessages(initial);
setLoaded(true);
if (initial.length === 0 && !cancelled) {
// Trigger Socrates' opening turn
await sendImpl("", true);
}
} catch {
if (!cancelled) setLoaded(true);
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
// Auto-scroll on new turns
useEffect(() => {
threadEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [messages]);
const sendImpl = useCallback(
async (text: string, isOpening = false) => {
setSending(true);
try {
// Add the user bubble locally (optimistic) — unless this is the opening
if (!isOpening) {
setMessages(curr => [
...curr,
{ id: `tmp-u-${Date.now()}`, role: "user", text },
]);
}
// Show a pending Socrates bubble
const pendingId = `tmp-a-${Date.now()}`;
setMessages(curr => [
...curr,
{ id: pendingId, role: "assistant", text: "thinking…", pending: true },
]);
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/socrates`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error ?? `${res.status}`);
}
const data = await res.json();
const a = data.assistant as { id: string; text: string; options?: Array<{ n: number; label: string; sub?: string }> };
if (data.meta) setMeta({ provider: data.meta.provider, model: data.meta.model });
// Replace the pending bubble with the real one
setMessages(curr => curr.map(m =>
m.id === pendingId
? { id: a.id, role: "assistant", text: a.text, options: a.options }
: m
));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setMessages(curr => curr.map(m =>
m.pending ? { ...m, text: `${msg}`, pending: false } : m
));
} finally {
setSending(false);
}
},
[projectId]
);
const onSubmit = useCallback(async () => {
const text = input.trim();
if (!text || sending) return;
setInput("");
await sendImpl(text);
}, [input, sending, sendImpl]);
const pickOption = useCallback(async (option: { n: number; label: string; sub?: string }) => {
if (sending) return;
const text = `[${option.n}] ${option.label}${option.sub ? `${option.sub}` : ""}`;
await sendImpl(text);
}, [sending, sendImpl]);
// Ask Socrates to propose a model change.
const requestProposal = useCallback(async () => {
if (proposing || sending) return;
setProposing(true);
const pendingId = `tmp-p-${Date.now()}`;
setMessages(curr => [
...curr,
{ id: pendingId, role: "proposal", text: "Drafting a proposal…", pending: true },
]);
try {
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/socrates/propose`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error ?? `${res.status}`);
}
const data = (await res.json()) as ProposalPayload;
setMessages(curr => curr.map(m =>
m.id === pendingId
? { id: pendingId, role: "proposal", text: data.reasoning, proposal: data }
: m
));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setMessages(curr => curr.map(m =>
m.id === pendingId
? { ...m, text: `⚠ propose failed: ${msg}`, pending: false }
: m
));
} finally {
setProposing(false);
}
}, [proposing, sending, projectId]);
const acceptProposal = useCallback(async (messageId: string, proposal: ProposalPayload) => {
const result = apply(proposal.ops);
if (!result.applied) {
setMessages(curr => [
...curr,
{
id: `tmp-sys-${Date.now()}`,
role: "system",
text: `⚠ Couldn't apply: ${result.errors.map(e => `${e.code}: ${e.message}`).join("; ")}`,
},
]);
return;
}
// Mark the proposal accepted (drop the live card; keep a summary line)
setMessages(curr => curr.map(m =>
m.id === messageId
? {
id: messageId,
role: "system",
text: `✓ Applied · ${proposal.ops.length} op${proposal.ops.length === 1 ? "" : "s"} · ${truncate(proposal.reasoning, 90)}`,
}
: m
));
}, [apply]);
const rejectProposal = useCallback((messageId: string) => {
setMessages(curr => curr.map(m =>
m.id === messageId
? { id: messageId, role: "system", text: "Proposal dismissed." }
: m
));
}, []);
// Number-key shortcuts on the most recent assistant turn with options
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
// Don't hijack number keys when typing in any input/contenteditable
const target = e.target as HTMLElement | null;
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return;
if (sending) return;
const last = [...messages].reverse().find(m => m.role === "assistant" && m.options?.length);
if (!last?.options) return;
const n = parseInt(e.key, 10);
if (Number.isNaN(n) || n < 1 || n > last.options.length) return;
e.preventDefault();
const opt = last.options.find(o => o.n === n);
if (opt) void pickOption(opt);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [messages, pickOption, sending]);
if (presence === "subtle") {
return (
<div className="dock dock-subtle" title={meta.model ? `Σ via ${meta.model}` : "Σ Socrates"}>
<Sigil size={36} />
{messages.filter(m => m.role === "assistant").length > 0 && (
<div className="dock-subtle-count">{messages.filter(m => m.role === "assistant").length}</div>
)}
</div>
);
}
return (
<aside className={`dock ${presence === "prominent" ? "dock-prominent" : "dock-default"}`}>
<header className="dock-header">
<Sigil size={28} />
<div className="dock-header-text">
<div className="dock-name">Socrates</div>
<div className="dock-status">
<span className="dock-dot" />
{meta.provider ? `${meta.provider}${meta.model ? ` · ${meta.model.split("/").pop()}` : ""}` : "ready"}
</div>
</div>
<button className="dock-header-action" title="New thread (coming soon)" type="button" disabled>
+
</button>
</header>
<section className="dock-thread-wrap">
<div className="dock-section-label dock-section-label-inline">Active thread</div>
<div className="dock-thread">
{!loaded && (
<div className="bubble bubble-assistant">
<span className="bubble-sigil">Σ</span>
<span className="bubble-body">
<span className="bubble-text" style={{ opacity: 0.6 }}>loading</span>
</span>
</div>
)}
{messages.map(m => {
// Proposal card — render in place of a normal bubble
if (m.role === "proposal") {
if (m.proposal) {
return (
<ProposalCard
key={m.id}
proposal={m.proposal}
onAccept={() => acceptProposal(m.id, m.proposal!)}
onReject={() => rejectProposal(m.id)}
/>
);
}
// Pending or errored proposal — show a thin status line
return (
<div key={m.id} className="bubble bubble-socrates">
<span className="bubble-sigil">Σ</span>
<span className="bubble-body">
<span className="bubble-text" style={m.pending ? { opacity: 0.55, fontStyle: "italic" } : undefined}>
{m.text}
</span>
</span>
</div>
);
}
// System notes (apply confirmation, errors)
if (m.role === "system") {
return (
<div key={m.id} className="bubble bubble-system">
<span className="bubble-text">{m.text}</span>
</div>
);
}
// Normal user / assistant bubble
return (
<div key={m.id} className={`bubble bubble-${m.role === "assistant" ? "socrates" : "user"}`}>
{m.role === "assistant" && <span className="bubble-sigil">Σ</span>}
<span className="bubble-body">
<span className="bubble-text" style={m.pending ? { opacity: 0.55, fontStyle: "italic" } : undefined}>
{m.text}
</span>
{m.options && m.options.length > 0 && (
<div className="bubble-options">
{m.options.map(o => (
<button
key={o.n}
className="bubble-option"
type="button"
onClick={() => pickOption(o)}
disabled={sending}
>
<span className="bubble-option-num">{o.n}</span>
<span className="bubble-option-text">
<span className="bubble-option-label">{o.label}</span>
{o.sub && <span className="bubble-option-sub">{o.sub}</span>}
</span>
<span className="bubble-option-key">{o.n}</span>
</button>
))}
<div className="bubble-options-hint">
Press <kbd>1</kbd><kbd>{m.options.length}</kbd>, or type a reply
</div>
</div>
)}
</span>
</div>
);
})}
<div ref={threadEndRef} />
</div>
</section>
<footer className="dock-input-wrap">
<form
className="dock-input"
onSubmit={e => {
e.preventDefault();
void onSubmit();
}}
>
<span className="dock-input-prompt"></span>
<input
className="dock-input-field"
placeholder={sending ? "Socrates is thinking…" : "Reply to Socrates…"}
value={input}
onChange={e => setInput(e.target.value)}
disabled={sending}
/>
<button
type="button"
className="dock-input-propose"
onClick={() => void requestProposal()}
disabled={proposing || sending}
title="Ask Socrates to propose a model change"
>
propose
</button>
<button
type="submit"
className="dock-input-send"
disabled={sending || !input.trim()}
title="Send (⌘↵)"
>
</button>
</form>
</footer>
</aside>
);
}
function truncate(s: string, n: number): string {
return s.length > n ? s.slice(0, n - 1) + "…" : s;
}

View File

@@ -0,0 +1,168 @@
// TipTap/ProseMirror plugin: dotted-underline decoration on text spans whose
// surface form matches a taxonomy term label or synonym. Reads the term list
// from the editor's storage (set by TextCanvas via `editor.storage.terms`).
//
// Click → convert the underlined span into a chip via the editor's
// insertChip command. Hover → tooltip handled by CSS title attribute (we
// stash the definition there so we don't need a portal).
//
// Decorations only apply over plain text, never inside a chip node (atomic).
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
interface TermLite {
id: string;
label: string;
definition: string | null;
synonyms: string[];
linkedBlockId: string | null;
}
const KEY = new PluginKey("chip-suggestion");
export const ChipSuggestionExtension = Extension.create({
name: "chipSuggestion",
addProseMirrorPlugins() {
const editor = this.editor;
return [
new Plugin({
key: KEY,
state: {
init(_, state) {
const terms = readTerms(editor);
return buildDecorations(state.doc, terms);
},
apply(tr, oldSet, _oldState, newState) {
// Always rebuild on doc change OR when the editor signals that the
// term list might have changed (force-update meta, dispatched by
// TextCanvas after analyze runs).
if (tr.docChanged || tr.getMeta("force-update")) {
const terms = readTerms(editor);
return buildDecorations(newState.doc, terms);
}
return oldSet.map(tr.mapping, tr.doc);
},
},
props: {
decorations(state) {
return KEY.getState(state);
},
handleClick(view, _pos, ev) {
const target = ev.target as HTMLElement | null;
if (!target) return false;
const span = target.closest(".chip-suggestion") as HTMLElement | null;
if (!span) return false;
const from = parseInt(span.dataset.from ?? "", 10);
const to = parseInt(span.dataset.to ?? "", 10);
const termId = span.dataset.termId ?? "";
const label = span.dataset.label ?? span.innerText;
if (Number.isNaN(from) || Number.isNaN(to) || !termId) return false;
// Replace the span with a chip node.
const { tr } = view.state;
const chipType = view.state.schema.nodes.chip;
if (!chipType) return false;
tr.replaceWith(
from,
to,
chipType.create({ kind: "block", refId: termId, label })
);
view.dispatch(tr);
return true;
},
},
}),
];
},
});
function readTerms(editor: { storage: unknown }): TermLite[] {
const storage = editor.storage as Record<string, unknown>;
const raw = storage?.terms;
if (!Array.isArray(raw)) return [];
return (raw as TermLite[]).filter(t => t && typeof t.label === "string");
}
interface MatchSpec {
pattern: RegExp;
term: TermLite;
surface: string;
}
function buildPatterns(terms: TermLite[]): MatchSpec[] {
const specs: MatchSpec[] = [];
for (const t of terms) {
const surfaces = dedupe([t.label, ...t.synonyms]).filter(s => s.trim().length >= 2);
for (const s of surfaces) {
specs.push({
pattern: new RegExp(`\\b${escapeRegex(s)}\\b`, "gi"),
term: t,
surface: s,
});
}
}
// Match longer phrases first so "Socratic Tutor" wins over "Tutor".
specs.sort((a, b) => b.surface.length - a.surface.length);
return specs;
}
function buildDecorations(doc: import("@tiptap/pm/model").Node, terms: TermLite[]): DecorationSet {
if (terms.length === 0) return DecorationSet.empty;
const specs = buildPatterns(terms);
const decos: Decoration[] = [];
doc.descendants((node, pos) => {
if (!node.isText || !node.text) return;
// Skip text nodes that are inside a chip — chips are atomic, but be safe.
const text = node.text;
const occupied: Array<[number, number]> = []; // [start, end) within the text node
for (const spec of specs) {
spec.pattern.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = spec.pattern.exec(text)) !== null) {
const start = m.index;
const end = start + m[0].length;
if (overlaps(occupied, start, end)) continue;
occupied.push([start, end]);
const from = pos + start;
const to = pos + end;
decos.push(
Decoration.inline(from, to, {
class: "chip-suggestion",
"data-term-id": spec.term.id,
"data-label": spec.term.label,
"data-from": String(from),
"data-to": String(to),
title: spec.term.definition ?? "",
})
);
}
}
});
return DecorationSet.create(doc, decos);
}
function overlaps(ranges: Array<[number, number]>, a: number, b: number): boolean {
for (const [s, e] of ranges) if (a < e && b > s) return true;
return false;
}
function dedupe(xs: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const x of xs) {
const k = x.trim().toLowerCase();
if (!k || seen.has(k)) continue;
seen.add(k);
out.push(x.trim());
}
return out;
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -14,6 +14,8 @@ import type { ChipKind } from "../../lib/fixtures/aristotle";
import type { MarkupStyle } from "./Chip"; import type { MarkupStyle } from "./Chip";
import { useChipFocus } from "./FocusContext"; import { useChipFocus } from "./FocusContext";
import { useModelStore } from "../../lib/sync/ModelStore"; import { useModelStore } from "../../lib/sync/ModelStore";
import { useAnalysis } from "../../lib/workspace/analysisStore";
import { useOpenPanes } from "../../lib/workspace/openPanesStore";
import { updateBlock, updateRequirement, updateAssociation } from "../../lib/sync/ops"; import { updateBlock, updateRequirement, updateAssociation } from "../../lib/sync/ops";
const KIND_LABEL: Record<ChipKind, string> = { const KIND_LABEL: Record<ChipKind, string> = {
@@ -42,6 +44,8 @@ export function ChipView({ node, selected, editor }: NodeViewProps) {
const { focusBlockId, setFocusBlockId } = useChipFocus(); const { focusBlockId, setFocusBlockId } = useChipFocus();
const { model, apply } = useModelStore(); const { model, apply } = useModelStore();
const { getTerm } = useAnalysis();
const { setStack } = useOpenPanes();
// Resolve the live label from the model, falling back to the node's stored // Resolve the live label from the model, falling back to the node's stored
// label (e.g. for chips created via slash-menu before they're bound). // label (e.g. for chips created via slash-menu before they're bound).
@@ -88,7 +92,18 @@ export function ChipView({ node, selected, editor }: NodeViewProps) {
e.preventDefault(); e.preventDefault();
if (refId) setIsEditing(true); if (refId) setIsEditing(true);
}, },
onClick: () => setFocusBlockId(refId), onClick: () => {
// Single-click signals local diagram focus AND, if the chip refers
// to a term, opens that term as a Concepts → term column chain so
// the user lands in a coherent place.
setFocusBlockId(refId);
if (kind === "block" && getTerm(refId)) {
setStack([
{ kind: "section", id: "concepts" },
{ kind: "term", id: refId },
]);
}
},
} }
: {}; : {};

View File

@@ -1,20 +0,0 @@
// TipTap extension wrapping the slash-menu suggestion plugin.
"use client";
import { Extension } from "@tiptap/core";
import Suggestion from "@tiptap/suggestion";
import { slashSuggestion } from "./slashSuggestion";
export const SlashExtension = Extension.create({
name: "slashMenu",
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...slashSuggestion,
}),
];
},
});

View File

@@ -1,110 +0,0 @@
// Slash menu — appears when the user types `/`. Shows the four chip kinds.
// On selection, inserts a chip with a placeholder label that the user can
// then rename inline.
"use client";
import { useEffect, useImperativeHandle, useState, forwardRef } from "react";
import type { ChipKind } from "../../lib/fixtures/aristotle";
export interface SlashItem {
kind: ChipKind;
label: string;
hint: string;
glyph: string;
}
export const SLASH_ITEMS: SlashItem[] = [
{ kind: "block", label: "Block", hint: "an entity in the system", glyph: "▢" },
{ kind: "property", label: "Property", hint: "an attribute of a block", glyph: "·" },
{ kind: "association", label: "Association", hint: "a relationship", glyph: "→" },
{ kind: "requirement", label: "Requirement", hint: "a stated goal (REQ-NNN)", glyph: "§" },
];
export interface SlashMenuHandle {
onKeyDown: (event: KeyboardEvent) => boolean;
}
interface SlashMenuProps {
query: string;
command: (item: SlashItem) => void;
}
export const SlashMenu = forwardRef<SlashMenuHandle, SlashMenuProps>(function SlashMenu(
{ query, command },
ref
) {
const filtered = SLASH_ITEMS.filter(
item =>
query.length === 0 ||
item.kind.toLowerCase().startsWith(query.toLowerCase()) ||
item.label.toLowerCase().startsWith(query.toLowerCase())
);
const [activeIndex, setActiveIndex] = useState(0);
useEffect(() => {
setActiveIndex(0);
}, [query]);
useImperativeHandle(ref, () => ({
onKeyDown(event: KeyboardEvent) {
if (filtered.length === 0) return false;
if (event.key === "ArrowUp") {
setActiveIndex(prev => (prev - 1 + filtered.length) % filtered.length);
return true;
}
if (event.key === "ArrowDown") {
setActiveIndex(prev => (prev + 1) % filtered.length);
return true;
}
if (event.key === "Enter" || event.key === "Tab") {
const choice = filtered[activeIndex];
if (choice) {
command(choice);
return true;
}
}
// Number-key shortcut: 14 picks the corresponding item
const num = parseInt(event.key, 10);
if (!Number.isNaN(num) && num >= 1 && num <= filtered.length) {
const choice = filtered[num - 1];
if (choice) {
command(choice);
return true;
}
}
return false;
},
}));
if (filtered.length === 0) {
return (
<div className="slash-menu slash-menu-empty">
<span className="slash-menu-empty-text">no matches for {query}</span>
</div>
);
}
return (
<div className="slash-menu">
{filtered.map((item, idx) => (
<button
key={item.kind}
type="button"
className={`slash-menu-item ${idx === activeIndex ? "slash-menu-item-active" : ""}`}
onMouseEnter={() => setActiveIndex(idx)}
onMouseDown={e => {
// mousedown so the click registers before the editor blurs
e.preventDefault();
command(item);
}}
>
<span className={`slash-menu-glyph slash-menu-glyph-${item.kind}`}>{item.glyph}</span>
<span className="slash-menu-label">{item.label}</span>
<span className="slash-menu-hint">{item.hint}</span>
<span className="slash-menu-key">{idx + 1}</span>
</button>
))}
</div>
);
});

View File

@@ -1,78 +1,129 @@
// TipTap-backed narrative editor. // TipTap-backed narrative editor.
// Renders the same prose surface as the static port, but typing actually works //
// and chips can be inserted via the slash menu (`/block`, `/property`, etc.). // After the pivot the text editor is the canonical surface. It loads its doc
// from /api/projects/[id]/document and saves on a 600ms debounce. Chips remain
// the rendered primitive but are no longer inserted via slash menu — the
// ChipSuggestionDecorator (added separately) suggests chip-ifying terms that
// match the project's taxonomy.
"use client"; "use client";
import { useEditor, EditorContent } from "@tiptap/react"; import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { ChipNode } from "./ChipNode"; import { ChipNode } from "./ChipNode";
import { SlashExtension } from "./SlashExtension";
import { ChipFocusContext } from "./FocusContext"; import { ChipFocusContext } from "./FocusContext";
import { fixtureToDoc } from "./fixtureToDoc"; import { fixtureToDoc } from "./fixtureToDoc";
import type { Density } from "../socrates/SocratesDock"; import { ChipSuggestionExtension } from "./ChipSuggestionExtension";
import type { FixtureData } from "../../lib/fixtures/aristotle"; import type { FixtureData } from "../../lib/fixtures/aristotle";
import type { MarkupStyle } from "./Chip"; import type { MarkupStyle } from "./Chip";
import { useAnalysis } from "../../lib/workspace/analysisStore";
export type Density = "comfortable" | "compact";
interface TextCanvasProps { interface TextCanvasProps {
data: FixtureData; data: FixtureData;
density: Density; projectId: string;
markupStyle: MarkupStyle; density?: Density;
focusBlockId: string | null; markupStyle?: MarkupStyle;
setFocusBlockId: (id: string | null) => void;
} }
export function TextCanvas({ data, density, markupStyle, focusBlockId, setFocusBlockId }: TextCanvasProps) { export function TextCanvas({ data, projectId, density = "comfortable", markupStyle = "color" }: TextCanvasProps) {
const padY = density === "compact" ? 10 : 18; const padY = density === "compact" ? 10 : 18;
const padX = density === "compact" ? 22 : 36; const padX = density === "compact" ? 22 : 36;
const focusValue = useMemo( const [focusBlockId, setFocusBlockId] = useState<string | null>(null);
() => ({ focusBlockId, setFocusBlockId }), const [initialDoc, setInitialDoc] = useState<unknown | null>(null);
[focusBlockId, setFocusBlockId] const [loaded, setLoaded] = useState(false);
); const focusValue = useMemo(() => ({ focusBlockId, setFocusBlockId }), [focusBlockId, setFocusBlockId]);
const editor = useEditor({ const { terms } = useAnalysis();
immediatelyRender: false,
extensions: [ // Load the saved doc once on mount; fall back to the fixture if there is none.
StarterKit.configure({ useEffect(() => {
heading: { levels: [1, 2] }, let cancelled = false;
// Drop features we don't need yet (async () => {
codeBlock: false, try {
blockquote: false, const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/document`);
horizontalRule: false, if (!res.ok) throw new Error(`load doc: ${res.status}`);
bulletList: false, const body = (await res.json()) as { doc: unknown };
orderedList: false, if (cancelled) return;
listItem: false, setInitialDoc(body.doc ?? fixtureToDoc(data));
strike: false, } catch {
code: false, if (!cancelled) setInitialDoc(fixtureToDoc(data));
link: false, } finally {
}), if (!cancelled) setLoaded(true);
ChipNode, }
SlashExtension, })();
], return () => {
content: fixtureToDoc(data), cancelled = true;
editorProps: { };
attributes: { }, [projectId, data]);
class: "text-canvas tiptap",
style: `padding: ${padY}px ${padX}px;`, const editor = useEditor(
{
immediatelyRender: false,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2] },
codeBlock: false,
blockquote: false,
horizontalRule: false,
bulletList: false,
orderedList: false,
listItem: false,
strike: false,
code: false,
link: false,
}),
ChipNode,
ChipSuggestionExtension,
],
content: initialDoc ?? null,
editorProps: {
attributes: {
class: "text-canvas tiptap",
style: `padding: ${padY}px ${padX}px;`,
},
}, },
}, },
}); [initialDoc]
);
// Push the markup style into the editor so the ChipView NodeView can read it. // Push markup style + terms into the editor storage so node-views read them.
useEffect(() => { useEffect(() => {
if (!editor) return; if (!editor) return;
(editor.storage as unknown as Record<string, unknown>).markupStyle = markupStyle; const storage = editor.storage as unknown as Record<string, unknown>;
// Force a re-render of all chip node views so they pick up the new style. storage.markupStyle = markupStyle;
storage.terms = terms;
editor.view.dispatch(editor.state.tr.setMeta("force-update", true)); editor.view.dispatch(editor.state.tr.setMeta("force-update", true));
}, [editor, markupStyle]); }, [editor, markupStyle, terms]);
if (!editor) { // Debounced save on document change.
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!editor) return;
const onUpdate = () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
const doc = editor.getJSON();
void fetch(`/api/projects/${encodeURIComponent(projectId)}/document`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ doc }),
}).catch(err => console.error("[TextCanvas] save failed:", err));
}, 600);
};
editor.on("update", onUpdate);
return () => {
editor.off("update", onUpdate);
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, [editor, projectId]);
if (!loaded || !editor) {
return ( return (
<div className="text-canvas" style={{ padding: `${padY}px ${padX}px` }}> <div className="text-canvas" style={{ padding: `${padY}px ${padX}px` }}>
<div style={{ color: "var(--muted)", fontFamily: "var(--font-mono)", fontSize: 12 }}> <div style={{ color: "var(--muted)", fontFamily: "var(--font-mono)", fontSize: 12 }}>loading editor</div>
loading editor
</div>
</div> </div>
); );
} }
@@ -80,15 +131,6 @@ export function TextCanvas({ data, density, markupStyle, focusBlockId, setFocusB
return ( return (
<ChipFocusContext.Provider value={focusValue}> <ChipFocusContext.Provider value={focusValue}>
<EditorContent editor={editor} /> <EditorContent editor={editor} />
<div className="margin-note" style={{ margin: `0 ${padX}px ${padY}px ${padX}px`, maxWidth: 720 }}>
<span className="margin-note-glyph">Σ</span>
<span>
<span className="margin-note-who">Socrates · margin</span>
<span className="margin-note-text">
&ldquo;Refuses to produce solutions&rdquo; is a strong constraint. Have you decided what counts as a &ldquo;solution&rdquo; vs. a &ldquo;scaffold&rdquo;? This boundary will determine whether the refusal policy is enforceable.
</span>
</span>
</div>
</ChipFocusContext.Provider> </ChipFocusContext.Provider>
); );
} }

View File

@@ -1,92 +0,0 @@
// Suggestion plugin config that wires the slash menu into TipTap.
// Renders SlashMenu in a fixed-position floating panel near the caret.
"use client";
import type { Editor, Range } from "@tiptap/core";
import type { SuggestionOptions, SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion";
import { createRoot, type Root } from "react-dom/client";
import { createElement, createRef } from "react";
import { SlashMenu, SLASH_ITEMS, type SlashItem, type SlashMenuHandle } from "./SlashMenu";
export const slashSuggestion: Omit<SuggestionOptions<SlashItem, SlashItem>, "editor"> = {
char: "/",
startOfLine: false,
allowSpaces: false,
items: ({ query }) =>
SLASH_ITEMS.filter(
item =>
query.length === 0 ||
item.kind.toLowerCase().startsWith(query.toLowerCase()) ||
item.label.toLowerCase().startsWith(query.toLowerCase())
),
command: ({ editor, range, props }: { editor: Editor; range: Range; props: SlashItem }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertChip({
kind: props.kind,
refId: null,
label: props.kind === "requirement" ? "REQ-001" : "untitled",
})
.run();
},
render: () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
const handleRef = createRef<SlashMenuHandle>();
function position(rect: DOMRect | null) {
if (!container || !rect) return;
container.style.position = "fixed";
container.style.top = `${rect.bottom + 6}px`;
container.style.left = `${rect.left}px`;
container.style.zIndex = "1000";
}
function rerender(props: SuggestionProps<SlashItem>) {
if (!root) return;
root.render(
createElement(SlashMenu, {
ref: handleRef,
query: props.query,
command: (item: SlashItem) => props.command(item),
})
);
}
return {
onStart(props) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
rerender(props);
position(props.clientRect?.() ?? null);
},
onUpdate(props) {
rerender(props);
position(props.clientRect?.() ?? null);
},
onKeyDown(props: SuggestionKeyDownProps) {
if (props.event.key === "Escape") {
props.event.preventDefault();
return true;
}
return handleRef.current?.onKeyDown(props.event) ?? false;
},
onExit() {
if (root) root.unmount();
if (container && container.parentNode) container.parentNode.removeChild(container);
root = null;
container = null;
},
};
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,95 @@
// Single-pass "concepts" analyzer — replaces the legacy taxonomy + glossary
// pair (T3 of the integration plan). One LLM call emits both the term list
// (with hierarchy + synonyms) and definitions, so the two layers can never
// drift out of sync.
//
// The legacy `taxonomy` / `glossary` analyzers (analyze/taxonomy.ts and
// analyze/glossary.ts) are still in the tree as deprecated fallbacks for one
// release; nothing in the runtime path imports them anymore.
import "server-only";
import { defaultGateway, chatJSON, type Message } from "../gateway";
import { loadPrompt } from "../prompts";
import type { DetectedTerm } from "../../db/repo";
interface RawTerm {
label?: string;
parentLabel?: string | null;
synonyms?: string[];
definition?: string;
}
const conceptsJsonSchema = {
type: "object",
additionalProperties: false,
required: ["terms"],
properties: {
terms: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["label"],
properties: {
label: { type: "string", minLength: 1 },
parentLabel: { type: ["string", "null"] },
synonyms: { type: "array", items: { type: "string" } },
definition: { type: "string" },
},
},
},
},
} as const;
export interface ConceptsResult {
terms: DetectedTerm[];
definitions: Array<{ label: string; definition: string }>;
inputTokens: number;
outputTokens: number;
provider: string;
model: string;
}
export async function analyzeConcepts(documentText: string): Promise<ConceptsResult> {
const prompt = loadPrompt("socrates/analyze-concepts.md");
const gateway = defaultGateway();
const messages: Message[] = [
{ role: "system", content: prompt },
{ role: "user", content: `Document:\n\n${documentText}` },
];
const { value, result } = await chatJSON<{ terms?: RawTerm[] }>(gateway, messages, {
temperature: 0.2,
maxTokens: 2400,
jsonSchema: { name: "concepts", schema: conceptsJsonSchema as Record<string, unknown> },
jsonObjectMode: true,
maxRepairs: 2,
});
const seen = new Set<string>();
const terms: DetectedTerm[] = [];
const definitions: Array<{ label: string; definition: string }> = [];
for (const t of value?.terms ?? []) {
const label = (t.label ?? "").trim();
if (!label) continue;
const key = label.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
terms.push({
label,
parentLabel: t.parentLabel?.trim() || null,
synonyms: Array.isArray(t.synonyms) ? t.synonyms.filter(s => typeof s === "string") : [],
});
const def = (t.definition ?? "").trim();
if (def) definitions.push({ label, definition: def });
}
return {
terms,
definitions,
inputTokens: result.inputTokens,
outputTokens: result.outputTokens,
provider: gateway.provider,
model: gateway.model,
};
}

View File

@@ -0,0 +1,81 @@
// Analyze pass: write definitions for taxonomy terms grounded in the prose.
import "server-only";
import { defaultGateway, chatJSON, type Message } from "../gateway";
import { loadPrompt } from "../prompts";
const glossaryJsonSchema = {
type: "object",
additionalProperties: false,
required: ["definitions"],
properties: {
definitions: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["label", "definition"],
properties: {
label: { type: "string", minLength: 1 },
definition: { type: "string" },
},
},
},
},
} as const;
export interface GlossaryResult {
defs: Array<{ label: string; definition: string }>;
inputTokens: number;
outputTokens: number;
provider: string;
model: string;
}
export async function analyzeGlossary(
documentText: string,
terms: Array<{ label: string; parentLabel?: string | null; synonyms?: string[] }>
): Promise<GlossaryResult> {
if (terms.length === 0) {
return { defs: [], inputTokens: 0, outputTokens: 0, provider: "n/a", model: "n/a" };
}
const prompt = loadPrompt("socrates/analyze-glossary.md");
const gateway = defaultGateway();
const userPayload =
`Document:\n\n${documentText}\n\n---\n\n` +
`Terms:\n\`\`\`json\n${JSON.stringify(terms, null, 2)}\n\`\`\``;
const messages: Message[] = [
{ role: "system", content: prompt },
{ role: "user", content: userPayload },
];
const { value, result } = await chatJSON<{ definitions?: Array<{ label?: string; definition?: string }> }>(
gateway,
messages,
{
temperature: 0.2,
maxTokens: 1800,
jsonSchema: { name: "glossary", schema: glossaryJsonSchema as Record<string, unknown> },
jsonObjectMode: true,
maxRepairs: 2,
}
);
const defs: Array<{ label: string; definition: string }> = [];
for (const d of value?.definitions ?? []) {
const label = (d.label ?? "").trim();
const def = (d.definition ?? "").trim();
if (!label || !def) continue;
defs.push({ label, definition: def });
}
return {
defs,
inputTokens: result.inputTokens,
outputTokens: result.outputTokens,
provider: gateway.provider,
model: gateway.model,
};
}

View File

@@ -0,0 +1,496 @@
// Analyze pass: derive (or refresh) the SysML model from prose + taxonomy.
//
// Differs from generateModel.ts (the seed-time generator) in two ways:
// 1. Inputs are prose + the current taxonomy term list, not a SeedDraft.
// 2. Re-runs *merge* with the existing model: blocks already linked to a
// taxonomy term are preserved (id, position, properties) so the user's
// manual canvas work isn't blown away on every Analyze.
import "server-only";
import { defaultGateway, chatJSON, type Message } from "../gateway";
import { loadPrompt } from "../prompts";
import type {
SysMLModel,
Block,
Association,
Constraint,
Requirement,
PropertyType,
ReviewStatus,
} from "../../sysml/model";
interface LeanProperty {
name: string;
type: { kind: "string" | "number" | "boolean" | "enum"; values?: string[] };
}
interface LeanBlock {
id: string;
label: string;
kind: "system" | "actor" | "block";
linkedTermLabel?: string;
properties?: LeanProperty[];
confidence: number;
}
interface LeanAssoc {
id: string;
fromBlockId: string;
toBlockId: string;
label?: string;
kind: "association" | "composition" | "aggregation" | "generalization" | "constraintApplies";
/** Optional concept-name for the relationship itself (T2 integration). */
linkedTermLabel?: string;
confidence: number;
}
interface LeanConstraint {
id: string;
label: string;
expression?: string;
appliesTo?: string[];
/** Optional concept-name the constraint enforces. */
linkedTermLabel?: string;
confidence: number;
}
interface LeanRequirement {
id: string;
tag: string;
text: string;
satisfiedBy?: string[];
/** Optional concept this requirement is "about." */
linkedTermLabel?: string;
confidence: number;
}
interface LeanModel {
systemOfInterestId?: string;
blocks: LeanBlock[];
associations?: LeanAssoc[];
constraints?: LeanConstraint[];
requirements?: LeanRequirement[];
overallConfidence?: number;
}
const modelJsonSchema = {
type: "object",
additionalProperties: false,
required: ["blocks"],
properties: {
systemOfInterestId: { type: "string" },
blocks: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["id", "label", "kind", "confidence"],
properties: {
id: { type: "string", minLength: 1 },
label: { type: "string", minLength: 1 },
kind: { type: "string", enum: ["system", "actor", "block"] },
linkedTermLabel: { type: "string" },
confidence: { type: "number", minimum: 0, maximum: 1 },
properties: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["name", "type"],
properties: {
name: { type: "string", minLength: 1 },
type: {
type: "object",
additionalProperties: false,
required: ["kind"],
properties: {
kind: { type: "string", enum: ["string", "number", "boolean", "enum"] },
values: { type: "array", items: { type: "string" } },
},
},
},
},
},
},
},
},
associations: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["id", "fromBlockId", "toBlockId", "kind", "confidence"],
properties: {
id: { type: "string", minLength: 1 },
fromBlockId: { type: "string", minLength: 1 },
toBlockId: { type: "string", minLength: 1 },
label: { type: "string" },
kind: {
type: "string",
enum: ["association", "composition", "aggregation", "generalization", "constraintApplies"],
},
linkedTermLabel: { type: "string" },
confidence: { type: "number", minimum: 0, maximum: 1 },
},
},
},
constraints: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["id", "label", "confidence"],
properties: {
id: { type: "string", minLength: 1 },
label: { type: "string", minLength: 1 },
expression: { type: "string" },
appliesTo: { type: "array", items: { type: "string" } },
linkedTermLabel: { type: "string" },
confidence: { type: "number", minimum: 0, maximum: 1 },
},
},
},
requirements: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["id", "tag", "text", "confidence"],
properties: {
id: { type: "string", minLength: 1 },
tag: { type: "string", minLength: 1 },
text: { type: "string", minLength: 1 },
satisfiedBy: { type: "array", items: { type: "string" } },
linkedTermLabel: { type: "string" },
confidence: { type: "number", minimum: 0, maximum: 1 },
},
},
},
overallConfidence: { type: "number", minimum: 0, maximum: 1 },
},
} as const;
export interface AnalyzeModelResult {
model: SysMLModel;
termIdToBlockId: Map<string, string>;
inputTokens: number;
outputTokens: number;
provider: string;
model_name: string;
}
export async function analyzeModelFromProse(
documentText: string,
terms: Array<{ id: string; label: string; linkedBlockId: string | null }>,
current: SysMLModel
): Promise<AnalyzeModelResult> {
const character = loadPrompt("socrates/character.md");
const generate = loadPrompt("socrates/analyze-model.md");
const userPayload =
`Document:\n\n${documentText}\n\n---\n\n` +
`Taxonomy:\n\`\`\`json\n${JSON.stringify(terms.map(t => t.label), null, 2)}\n\`\`\`\n` +
`Return only the JSON object conforming to the schema.`;
const messages: Message[] = [
{ role: "system", content: `${character}\n\n---\n\n${generate}` },
{ role: "user", content: userPayload },
];
const gateway = defaultGateway();
const { value, result } = await chatJSON<LeanModel>(gateway, messages, {
temperature: 0.3,
maxTokens: 2400,
jsonSchema: { name: "sysml_model", schema: modelJsonSchema as Record<string, unknown> },
jsonObjectMode: true,
maxRepairs: 2,
});
const merged = mergeIntoCurrent(value, current, terms);
return {
...merged,
inputTokens: result.inputTokens,
outputTokens: result.outputTokens,
provider: gateway.provider,
model_name: gateway.model,
};
}
// ─── Lean → canonical, merged with current model (review-aware) ─────────
//
// Re-running the analyzer is non-destructive. For each element kind we
// match against current by a stable key:
// block — id (preserved via linkedTermId chain) OR linkedTermId
// association — `${kind}|${fromBlockId}|${toBlockId}`
// constraint — `${linkedTermId ?? "label:"+normalize(label)}`
// requirement — `${linkedTermId ?? "text:"+normalize(text)}`
//
// New incoming → reviewStatus = "suggested".
// Matched + currently deprecated → flips back to "suggested".
// Existing not in incoming + not pinned + not currently suggested → "deprecated".
// Existing not in incoming + status was "suggested" → quietly dropped.
// Pinned elements left alone regardless.
function mergeIntoCurrent(
lean: LeanModel | null | undefined,
current: SysMLModel,
terms: Array<{ id: string; label: string; linkedBlockId: string | null }>
): { model: SysMLModel; termIdToBlockId: Map<string, string> } {
if (!lean || !Array.isArray(lean.blocks)) {
return { model: current, termIdToBlockId: new Map() };
}
const termByLabel = new Map<string, { id: string; linkedBlockId: string | null }>();
for (const t of terms) termByLabel.set(t.label.toLowerCase(), { id: t.id, linkedBlockId: t.linkedBlockId });
const resolveTermId = (label: string | undefined): string | undefined => {
if (!label) return undefined;
return termByLabel.get(label.toLowerCase())?.id;
};
// ─── Blocks ────────────────────────────────────────────────────────────
const existingByLinkedTerm = new Map<string, Block>();
const existingById = new Map<string, Block>();
for (const b of current.blocks) {
existingById.set(b.id, b);
if (b.linkedTermId) existingByLinkedTerm.set(b.linkedTermId, b);
}
const blocks: Block[] = [];
const leanIdToCanonical = new Map<string, string>();
const termIdToBlockId = new Map<string, string>();
const matchedBlockIds = new Set<string>();
for (const b of lean.blocks) {
const linkedTerm = b.linkedTermLabel ? termByLabel.get(b.linkedTermLabel.toLowerCase()) : undefined;
let existing: Block | undefined;
if (linkedTerm) existing = existingByLinkedTerm.get(linkedTerm.id);
if (!existing && existingById.has(b.id)) existing = existingById.get(b.id);
let canonicalId = b.id;
let stereotypes: string[] = [b.kind];
let propertiesSource = b.properties ?? [];
let nextStatus: ReviewStatus = "suggested";
let pinned = false;
if (existing) {
canonicalId = existing.id;
stereotypes = existing.stereotypes;
propertiesSource = mergeProperties(existing.properties, b.properties ?? []);
pinned = !!existing.reviewPinned;
// Existing accepted stays accepted; existing deprecated flips back to
// suggested (analyzer changed its mind); existing suggested stays
// suggested.
const cur: ReviewStatus = (existing.reviewStatus ?? "accepted") as ReviewStatus;
nextStatus = cur === "deprecated" ? "suggested" : cur;
matchedBlockIds.add(existing.id);
}
if (linkedTerm) termIdToBlockId.set(linkedTerm.id, canonicalId);
leanIdToCanonical.set(b.id, canonicalId);
blocks.push({
id: canonicalId,
label: b.label,
kind: b.kind,
stereotypes,
properties: propertiesSource.map((p, i) => ({
id: `${canonicalId}_p${i + 1}`,
name: p.name,
type: expandPropertyType(p.type),
multiplicity: "0..1" as const,
})),
linkedTermId: linkedTerm?.id,
reviewStatus: nextStatus,
reviewPinned: pinned,
});
}
// Existing blocks the analyzer didn't mention.
for (const b of current.blocks) {
if (matchedBlockIds.has(b.id)) continue;
const cur: ReviewStatus = (b.reviewStatus ?? "accepted") as ReviewStatus;
if (cur === "suggested") continue; // never reviewed + dropped → drop.
if (b.reviewPinned) {
blocks.push(b);
continue;
}
blocks.push({ ...b, reviewStatus: "deprecated" });
}
// ─── Associations ─────────────────────────────────────────────────────
const associations = mergeListByKey<Association, LeanAssoc>({
existing: current.associations,
incoming: lean.associations ?? [],
keyExisting: a => `${a.kind}|${a.fromBlockId}|${a.toBlockId}`,
keyIncoming: a =>
`${a.kind}|${leanIdToCanonical.get(a.fromBlockId) ?? a.fromBlockId}|${
leanIdToCanonical.get(a.toBlockId) ?? a.toBlockId
}`,
fromIncoming: a => ({
id: a.id,
fromBlockId: leanIdToCanonical.get(a.fromBlockId) ?? a.fromBlockId,
toBlockId: leanIdToCanonical.get(a.toBlockId) ?? a.toBlockId,
label: a.label ?? "",
kind: a.kind,
linkedTermId: resolveTermId(a.linkedTermLabel),
}),
mergeFields: (existing, incoming) => ({
...existing,
label: incoming.label ?? existing.label,
// linkedTermId: prefer fresh suggestion, fall back to existing.
linkedTermId:
resolveTermId(incoming.linkedTermLabel) ?? existing.linkedTermId,
}),
});
// ─── Constraints ──────────────────────────────────────────────────────
const constraints = mergeListByKey<Constraint, LeanConstraint>({
existing: current.constraints,
incoming: lean.constraints ?? [],
keyExisting: c => c.linkedTermId ? `term:${c.linkedTermId}` : `label:${normalizeText(c.label)}`,
keyIncoming: c => {
const tid = resolveTermId(c.linkedTermLabel);
return tid ? `term:${tid}` : `label:${normalizeText(c.label)}`;
},
fromIncoming: c => ({
id: c.id,
label: c.label,
expression: c.expression ?? "",
appliesTo: (c.appliesTo ?? []).map(x => leanIdToCanonical.get(x) ?? x),
linkedTermId: resolveTermId(c.linkedTermLabel),
}),
mergeFields: (existing, incoming) => ({
...existing,
label: incoming.label,
expression: incoming.expression ?? existing.expression,
appliesTo: (incoming.appliesTo ?? []).map(x => leanIdToCanonical.get(x) ?? x),
linkedTermId: resolveTermId(incoming.linkedTermLabel) ?? existing.linkedTermId,
}),
});
// ─── Requirements ─────────────────────────────────────────────────────
const requirements = mergeListByKey<Requirement, LeanRequirement>({
existing: current.requirements,
incoming: lean.requirements ?? [],
keyExisting: r => r.linkedTermId ? `term:${r.linkedTermId}` : `text:${normalizeText(r.text)}`,
keyIncoming: r => {
const tid = resolveTermId(r.linkedTermLabel);
return tid ? `term:${tid}` : `text:${normalizeText(r.text)}`;
},
fromIncoming: r => ({
id: r.id,
tag: r.tag,
text: r.text,
relations: (r.satisfiedBy ?? []).map(blockId => ({
kind: "satisfy" as const,
blockId: leanIdToCanonical.get(blockId) ?? blockId,
})),
linkedTermId: resolveTermId(r.linkedTermLabel),
}),
mergeFields: (existing, incoming) => ({
...existing,
tag: incoming.tag || existing.tag,
text: incoming.text || existing.text,
relations: (incoming.satisfiedBy ?? []).map(blockId => ({
kind: "satisfy" as const,
blockId: leanIdToCanonical.get(blockId) ?? blockId,
})),
linkedTermId: resolveTermId(incoming.linkedTermLabel) ?? existing.linkedTermId,
}),
});
let soiId = lean.systemOfInterestId ? leanIdToCanonical.get(lean.systemOfInterestId) ?? lean.systemOfInterestId : undefined;
if (!soiId) {
const systems = blocks.filter(b => b.kind === "system");
if (systems.length === 1) soiId = systems[0]!.id;
}
return { model: { systemOfInterestId: soiId, blocks, associations, constraints, requirements }, termIdToBlockId };
}
// ─── Generic merge-with-review helper ──────────────────────────────────
interface ReviewableElement {
reviewStatus?: ReviewStatus;
reviewPinned?: boolean;
}
interface MergeArgs<E extends ReviewableElement, I> {
existing: E[];
incoming: I[];
keyExisting: (e: E) => string;
keyIncoming: (i: I) => string;
fromIncoming: (i: I) => E;
mergeFields: (existing: E, incoming: I) => E;
}
function mergeListByKey<E extends ReviewableElement, I>(args: MergeArgs<E, I>): E[] {
const existingByKey = new Map<string, E>();
for (const e of args.existing) existingByKey.set(args.keyExisting(e), e);
const incomingByKey = new Map<string, I>();
for (const i of args.incoming) {
const k = args.keyIncoming(i);
if (!incomingByKey.has(k)) incomingByKey.set(k, i);
}
const out: E[] = [];
const matched = new Set<string>();
// Phase 1 — incoming.
for (const [key, inc] of incomingByKey.entries()) {
const prior = existingByKey.get(key);
if (!prior) {
const created = args.fromIncoming(inc);
out.push({ ...created, reviewStatus: "suggested", reviewPinned: false });
} else {
const merged = args.mergeFields(prior, inc);
const cur: ReviewStatus = (prior.reviewStatus ?? "accepted") as ReviewStatus;
const nextStatus: ReviewStatus = cur === "deprecated" ? "suggested" : cur;
out.push({ ...merged, reviewStatus: nextStatus, reviewPinned: !!prior.reviewPinned });
matched.add(key);
}
}
// Phase 2 — existing not in incoming.
for (const e of args.existing) {
const k = args.keyExisting(e);
if (matched.has(k)) continue;
const cur: ReviewStatus = (e.reviewStatus ?? "accepted") as ReviewStatus;
if (cur === "suggested") continue; // unreviewed + dropped → drop.
if (e.reviewPinned) {
out.push(e);
continue;
}
out.push({ ...e, reviewStatus: "deprecated" });
}
return out;
}
function normalizeText(s: string): string {
return (s ?? "").trim().toLowerCase().replace(/\s+/g, " ");
}
function mergeProperties(
current: { name: string }[],
incoming: LeanProperty[]
): LeanProperty[] {
const have = new Set(current.map(p => p.name.toLowerCase()));
const merged: LeanProperty[] = current.map(p => ({
name: p.name,
type: { kind: "string" }, // type re-expanded in caller
}));
for (const p of incoming) {
if (!have.has(p.name.toLowerCase())) merged.push(p);
}
return merged;
}
function expandPropertyType(type: { kind: string; values?: string[] }): PropertyType {
if (type.kind === "enum") return { kind: "enum", values: type.values ?? [] };
if (type.kind === "number") return { kind: "number" };
if (type.kind === "boolean") return { kind: "boolean" };
return { kind: "string" };
}

View File

@@ -0,0 +1,52 @@
// Convert a ProseMirror JSON document into a plain-text string for LLM input.
// Headings get markdown-style "##"; chip nodes render as their label so the
// analyzer sees the same surface forms a human would.
import "server-only";
interface PMNode {
type: string;
text?: string;
attrs?: Record<string, unknown>;
content?: PMNode[];
}
export function proseDocToPlainText(doc: unknown): string {
if (!doc || typeof doc !== "object") return "";
const root = doc as PMNode;
return walk(root, 0).trim();
}
function walk(node: PMNode, depth: number): string {
if (!node) return "";
if (node.type === "text") return node.text ?? "";
if (node.type === "chip") {
const label = (node.attrs?.label as string | undefined) ?? "";
return label;
}
const children = (node.content ?? []).map(c => walk(c, depth + 1)).join("");
switch (node.type) {
case "heading": {
const level = typeof node.attrs?.level === "number" ? (node.attrs.level as number) : 1;
const hash = "#".repeat(Math.max(1, Math.min(level, 6)));
return `\n\n${hash} ${children}\n\n`;
}
case "paragraph":
return `${children}\n\n`;
case "bulletList":
case "bullet_list":
case "orderedList":
case "ordered_list":
return `${children}\n`;
case "listItem":
case "list_item":
return `- ${children.trim()}\n`;
case "hardBreak":
case "hard_break":
return "\n";
default:
return children;
}
}

View File

@@ -0,0 +1,104 @@
// Analyze pass: extract requirements from prose with traceability hints.
import "server-only";
import { defaultGateway, chatJSON, type Message } from "../gateway";
import { loadPrompt } from "../prompts";
import type { DetectedRequirement } from "../../db/repo";
const reqsJsonSchema = {
type: "object",
additionalProperties: false,
required: ["requirements"],
properties: {
requirements: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["tag", "text"],
properties: {
tag: { type: "string", minLength: 1 },
text: { type: "string", minLength: 1 },
tracedToLabels: { type: "array", items: { type: "string" } },
/// Optional concept the requirement is conceptually "about." Lets the
/// requirement render a back-link to the term in TermDetail even when
/// the term has no formalized block yet.
linkedTermLabel: { type: "string" },
},
},
},
},
} as const;
export interface RequirementsResult {
reqs: DetectedRequirement[];
inputTokens: number;
outputTokens: number;
provider: string;
model: string;
}
export async function analyzeRequirements(
documentText: string,
terms: Array<{ id: string; label: string; linkedBlockId: string | null }>
): Promise<RequirementsResult> {
const prompt = loadPrompt("socrates/analyze-requirements.md");
const gateway = defaultGateway();
const userPayload =
`Document:\n\n${documentText}\n\n---\n\n` +
`Available term labels:\n${JSON.stringify(terms.map(t => t.label))}`;
const messages: Message[] = [
{ role: "system", content: prompt },
{ role: "user", content: userPayload },
];
const { value, result } = await chatJSON<{
requirements?: Array<{
tag?: string;
text?: string;
tracedToLabels?: string[];
linkedTermLabel?: string;
}>;
}>(gateway, messages, {
temperature: 0.2,
maxTokens: 1500,
jsonSchema: { name: "requirements", schema: reqsJsonSchema as Record<string, unknown> },
jsonObjectMode: true,
maxRepairs: 2,
});
// Map term labels → block ids via the term-to-block links + label → termId.
const labelToBlock = new Map<string, string>();
const labelToTermId = new Map<string, string>();
for (const t of terms) {
if (t.linkedBlockId) labelToBlock.set(t.label.toLowerCase(), t.linkedBlockId);
labelToTermId.set(t.label.toLowerCase(), t.id);
}
const reqs: DetectedRequirement[] = [];
for (const r of value?.requirements ?? []) {
const tag = (r.tag ?? "").trim();
const text = (r.text ?? "").trim();
if (!tag || !text) continue;
const blockIds = (r.tracedToLabels ?? [])
.map(l => labelToBlock.get(l.toLowerCase()))
.filter((id): id is string => Boolean(id));
const linkedTermId = r.linkedTermLabel ? labelToTermId.get(r.linkedTermLabel.toLowerCase()) : undefined;
reqs.push({
tag,
text,
tracedToIds: blockIds,
unsupported: blockIds.length === 0,
linkedTermId: linkedTermId ?? null,
});
}
return {
reqs,
inputTokens: result.inputTokens,
outputTokens: result.outputTokens,
provider: gateway.provider,
model: gateway.model,
};
}

View File

@@ -0,0 +1,206 @@
// Analyze orchestrator. Runs the requested sections sequentially. Sections
// are independent: a partial failure in one doesn't abort the rest. Each
// section writes an AnalysisRun row for telemetry / "last-run" timestamps.
//
// Order matters when "all" is selected:
// 1. concepts (taxonomy + glossary in one pass — others depend on its output)
// 2. model (uses the term list, can populate Block/Assoc/Constraint/Req
// linkedTermIds)
// 3. requirements (uses term-to-block links from model)
// 4. assumptions, risks, inconsistencies (run on the refreshed model)
//
// Per-section runs skip the dependency steps and use whatever's already in
// the DB. That keeps "I just want to refresh concepts" cheap.
import "server-only";
import {
loadDocument,
loadProject,
replaceModel,
startAnalysisRun,
finishAnalysisRun,
mergeTaxonomySuggestion,
applyGlossaryDefinitions,
listTerms,
mergeRequirementsSuggestion,
mergeFindingsSuggestion,
linkTermToBlock,
type AnalysisSection,
} from "../../db/repo";
import { proseDocToPlainText } from "./proseText";
import { analyzeConcepts } from "./concepts";
import { analyzeRequirements } from "./requirements";
import { analyzeModelFromProse } from "./model";
import { detectFindings, type Finding } from "../detect";
import { crossValidate } from "../../sysml/crossValidate";
export type RunSection = AnalysisSection;
export interface SectionOutcome {
section: RunSection;
status: "succeeded" | "failed" | "skipped";
message?: string;
inputTokens?: number;
outputTokens?: number;
}
export interface AnalyzeRunResult {
outcomes: SectionOutcome[];
modelVersion: number;
}
const ALL: RunSection[] = [
"concepts",
"model",
"requirements",
"assumptions",
"risks",
"inconsistencies",
];
export async function runAnalyze(projectId: string, requested: RunSection): Promise<AnalyzeRunResult> {
const sections: RunSection[] = requested === "all" ? ALL : [requested];
const docRow = await loadDocument(projectId);
const documentText = docRow ? proseDocToPlainText(docRow.doc) : "";
if (!documentText.trim()) {
return {
outcomes: sections.map(s => ({ section: s, status: "skipped", message: "Empty document" })),
modelVersion: (await loadProject(projectId)).version,
};
}
const outcomes: SectionOutcome[] = [];
for (const section of sections) {
const { version: modelVersion } = await loadProject(projectId);
const runId = await startAnalysisRun(projectId, section, modelVersion);
try {
const out = await runSection(projectId, section, documentText);
await finishAnalysisRun(runId, {
status: "succeeded",
inputTokens: out.inputTokens,
outputTokens: out.outputTokens,
});
outcomes.push({ section, status: "succeeded", inputTokens: out.inputTokens, outputTokens: out.outputTokens });
} catch (err) {
const message = (err as Error).message;
console.error(`[analyze] ${section} failed:`, message);
await finishAnalysisRun(runId, { status: "failed", errorMessage: message });
outcomes.push({ section, status: "failed", message });
}
}
const finalVersion = (await loadProject(projectId)).version;
return { outcomes, modelVersion: finalVersion };
}
// ─── Per-section runners ─────────────────────────────────────────────────
interface RunOut {
inputTokens: number;
outputTokens: number;
}
async function runSection(projectId: string, section: RunSection, documentText: string): Promise<RunOut> {
switch (section) {
case "concepts":
return runConcepts(projectId, documentText);
case "model":
return runModel(projectId, documentText);
case "requirements":
return runRequirements(projectId, documentText);
case "assumptions":
case "risks":
case "inconsistencies":
return runFindings(projectId);
case "all":
throw new Error("'all' must be expanded by the caller");
}
}
async function runConcepts(projectId: string, documentText: string): Promise<RunOut> {
const { version } = await loadProject(projectId);
const { terms, definitions, inputTokens, outputTokens } = await analyzeConcepts(documentText);
// Merge instead of replace — analyzer suggestions surface as a *review*.
// The user keeps or discards each pending change; accepted state survives.
await mergeTaxonomySuggestion(projectId, terms, version);
if (definitions.length > 0) {
// Definitions still apply only to "accepted" terms whose definition is
// empty; mergeTaxonomySuggestion's gentle-update path keeps user-edited
// definitions, but the dedicated patch is harmless for new terms.
await applyGlossaryDefinitions(projectId, definitions);
}
return { inputTokens, outputTokens };
}
async function runModel(projectId: string, documentText: string): Promise<RunOut> {
const { model: current } = await loadProject(projectId);
const terms = await listTerms(projectId);
const { model, termIdToBlockId, inputTokens, outputTokens } = await analyzeModelFromProse(
documentText,
terms.map(t => ({ id: t.id, label: t.label, linkedBlockId: t.linkedBlockId })),
current
);
await replaceModel(projectId, model, "Analyze: model refresh");
// Update term → block linkage based on what the analyzer linked.
for (const [termId, blockId] of termIdToBlockId.entries()) {
await linkTermToBlock(termId, blockId);
}
return { inputTokens, outputTokens };
}
async function runRequirements(projectId: string, documentText: string): Promise<RunOut> {
const { version } = await loadProject(projectId);
const terms = await listTerms(projectId);
const { reqs, inputTokens, outputTokens } = await analyzeRequirements(
documentText,
terms.map(t => ({ id: t.id, label: t.label, linkedBlockId: t.linkedBlockId }))
);
await mergeRequirementsSuggestion(projectId, reqs, version);
return { inputTokens, outputTokens };
}
async function runFindings(projectId: string): Promise<RunOut> {
const { model, version } = await loadProject(projectId);
const result = await detectFindings(model);
// Cross-layer validation (T2): glue rules between terms and ontology.
// Surfaced as inconsistency-kind findings keyed by validationCode = X*.
const terms = await listTerms(projectId);
const cross = crossValidate({
model,
terms: terms.map(t => ({ id: t.id, label: t.label, linkedBlockId: t.linkedBlockId })),
// X4 needs prose-occurrence counts; left undefined here so it stays
// off until we wire prose extraction. X1X3 still fire.
});
const crossFindings: Finding[] = cross.map(issue => ({
kind: "inconsistency",
text: issue.message,
linkedElementIds: anchorIds(issue.anchor),
confidence: 1.0,
severity: issue.severity === "warning" ? "medium" : "low",
validationCode: issue.code,
}));
await mergeFindingsSuggestion(
projectId,
[...result.findings, ...crossFindings],
version,
result.provider,
result.model
);
return { inputTokens: result.inputTokens, outputTokens: result.outputTokens };
}
function anchorIds(a: import("../../sysml/validate").IssueAnchor): string[] {
if (a.kind === "model") return [];
if (a.kind === "property") return [a.blockId];
// T3: prefix term anchors with `term:` so consumers (FindingsPane,
// TermDetail) can route them to the concept popover instead of the model.
if (a.kind === "term") return [`term:${a.id}`];
return [a.id];
}

View File

@@ -0,0 +1,81 @@
// Analyze pass: extract taxonomy terms from prose.
import "server-only";
import { defaultGateway, chatJSON, type Message } from "../gateway";
import { loadPrompt } from "../prompts";
import type { DetectedTerm } from "../../db/repo";
interface RawTerm {
label?: string;
parentLabel?: string | null;
synonyms?: string[];
}
const taxonomyJsonSchema = {
type: "object",
additionalProperties: false,
required: ["terms"],
properties: {
terms: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["label"],
properties: {
label: { type: "string", minLength: 1 },
parentLabel: { type: ["string", "null"] },
synonyms: { type: "array", items: { type: "string" } },
},
},
},
},
} as const;
export interface TaxonomyResult {
terms: DetectedTerm[];
inputTokens: number;
outputTokens: number;
provider: string;
model: string;
}
export async function analyzeTaxonomy(documentText: string): Promise<TaxonomyResult> {
const prompt = loadPrompt("socrates/analyze-taxonomy.md");
const gateway = defaultGateway();
const messages: Message[] = [
{ role: "system", content: prompt },
{ role: "user", content: `Document:\n\n${documentText}` },
];
const { value, result } = await chatJSON<{ terms?: RawTerm[] }>(gateway, messages, {
temperature: 0.2,
maxTokens: 1500,
jsonSchema: { name: "taxonomy", schema: taxonomyJsonSchema as Record<string, unknown> },
jsonObjectMode: true,
maxRepairs: 2,
});
const seen = new Set<string>();
const terms: DetectedTerm[] = [];
for (const t of value?.terms ?? []) {
const label = (t.label ?? "").trim();
if (!label) continue;
const key = label.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
terms.push({
label,
parentLabel: t.parentLabel?.trim() || null,
synonyms: Array.isArray(t.synonyms) ? t.synonyms.filter(s => typeof s === "string") : [],
});
}
return {
terms,
inputTokens: result.inputTokens,
outputTokens: result.outputTokens,
provider: gateway.provider,
model: gateway.model,
};
}

View File

@@ -0,0 +1,275 @@
// Seed → SysMLModel via the Phase-0-validated generate prompt.
//
// Returns a validated, expanded SysMLModel with property ids, multiplicities,
// stereotypes filled in. The LLM emits a "lean" shape; we expand it to match
// the canonical lib/sysml/model.ts types.
import "server-only";
import { defaultGateway, chatJSON, type Message } from "./gateway";
import { loadPrompt } from "./prompts";
import type {
SysMLModel,
Block,
Association,
Constraint,
Requirement,
Property,
PropertyType,
} from "../sysml/model";
import type { SeedDraft } from "./seedInterview";
// ─── LLM-facing lean shape (matches Phase 0's generate.md output) ───────
interface LeanProperty {
name: string;
type: { kind: "string" | "number" | "boolean" | "enum"; values?: string[] };
}
interface LeanBlock {
id: string;
label: string;
kind: "system" | "actor" | "block";
properties?: LeanProperty[];
confidence: number;
}
interface LeanAssociation {
id: string;
fromBlockId: string;
toBlockId: string;
label?: string;
kind: "association" | "composition" | "aggregation" | "generalization" | "constraintApplies";
confidence: number;
}
interface LeanConstraint {
id: string;
label: string;
expression?: string;
appliesTo?: string[];
confidence: number;
}
interface LeanRequirement {
id: string;
tag: string;
text: string;
satisfiedBy?: string[];
confidence: number;
}
interface LeanModel {
systemOfInterestId?: string;
blocks: LeanBlock[];
associations?: LeanAssociation[];
constraints?: LeanConstraint[];
requirements?: LeanRequirement[];
overallConfidence?: number;
notes?: string;
}
// ─── JSON Schema for response_format ─────────────────────────────────────
const generateJsonSchema = {
type: "object",
additionalProperties: false,
properties: {
systemOfInterestId: { type: "string" },
blocks: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["id", "label", "kind", "confidence"],
properties: {
id: { type: "string", minLength: 1 },
label: { type: "string", minLength: 1 },
kind: { type: "string", enum: ["system", "actor", "block"] },
confidence: { type: "number", minimum: 0, maximum: 1 },
properties: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["name", "type"],
properties: {
name: { type: "string", minLength: 1 },
type: {
type: "object",
additionalProperties: false,
required: ["kind"],
properties: {
kind: { type: "string", enum: ["string", "number", "boolean", "enum"] },
values: { type: "array", items: { type: "string" } },
},
},
},
},
},
},
},
},
associations: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["id", "fromBlockId", "toBlockId", "kind", "confidence"],
properties: {
id: { type: "string", minLength: 1 },
fromBlockId: { type: "string", minLength: 1 },
toBlockId: { type: "string", minLength: 1 },
label: { type: "string" },
kind: { type: "string", enum: ["association", "composition", "aggregation", "generalization", "constraintApplies"] },
confidence: { type: "number", minimum: 0, maximum: 1 },
},
},
},
constraints: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["id", "label", "confidence"],
properties: {
id: { type: "string", minLength: 1 },
label: { type: "string", minLength: 1 },
expression: { type: "string" },
appliesTo: { type: "array", items: { type: "string" } },
confidence: { type: "number", minimum: 0, maximum: 1 },
},
},
},
requirements: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["id", "tag", "text", "confidence"],
properties: {
id: { type: "string", minLength: 1 },
tag: { type: "string", minLength: 1 },
text: { type: "string", minLength: 1 },
satisfiedBy: { type: "array", items: { type: "string" } },
confidence: { type: "number", minimum: 0, maximum: 1 },
},
},
},
overallConfidence: { type: "number", minimum: 0, maximum: 1 },
notes: { type: "string" },
},
required: ["blocks"],
} as const;
// ─── Public API ──────────────────────────────────────────────────────────
export interface GenerateModelResult {
model: SysMLModel;
overallConfidence: number;
notes?: string;
inputTokens: number;
outputTokens: number;
provider: string;
model_name: string;
}
export async function generateModelFromSeed(seed: SeedDraft): Promise<GenerateModelResult> {
const character = loadPrompt("socrates/character.md");
const generate = loadPrompt("socrates/generate.md");
const userPayload =
`Seed payload to model:\n\`\`\`json\n${JSON.stringify(seed, null, 2)}\n\`\`\`\n` +
`Return only the JSON object conforming to the schema. No prose preamble, no code fences.`;
const messages: Message[] = [
{ role: "system", content: `${character}\n\n---\n\n${generate}` },
{ role: "user", content: userPayload },
];
const gateway = defaultGateway();
const { value, result } = await chatJSON<LeanModel>(gateway, messages, {
temperature: 0.3,
maxTokens: 2048,
jsonSchema: { name: "sysml_model", schema: generateJsonSchema as Record<string, unknown> },
jsonObjectMode: true,
maxRepairs: 2,
});
const model = expand(value);
return {
model,
overallConfidence: clamp01(value?.overallConfidence ?? 0.5),
notes: value?.notes,
inputTokens: result.inputTokens,
outputTokens: result.outputTokens,
provider: gateway.provider,
model_name: gateway.model,
};
}
// ─── Lean → canonical SysMLModel ────────────────────────────────────────
function expand(lean: LeanModel | null | undefined): SysMLModel {
if (!lean || !Array.isArray(lean.blocks)) {
return { blocks: [], associations: [], constraints: [], requirements: [] };
}
const blocks: Block[] = lean.blocks.map(b => ({
id: b.id,
label: b.label,
kind: b.kind,
stereotypes: [b.kind],
properties: (b.properties ?? []).map((p, i) => ({
id: `${b.id}_p${i + 1}`,
name: p.name,
type: expandPropertyType(p.type),
multiplicity: "0..1" as const,
})),
}));
const associations: Association[] = (lean.associations ?? []).map(a => ({
id: a.id,
fromBlockId: a.fromBlockId,
toBlockId: a.toBlockId,
label: a.label ?? "",
kind: a.kind,
}));
const constraints: Constraint[] = (lean.constraints ?? []).map(c => ({
id: c.id,
label: c.label,
expression: c.expression ?? "",
appliesTo: c.appliesTo ?? [],
}));
const requirements: Requirement[] = (lean.requirements ?? []).map(r => ({
id: r.id,
tag: r.tag,
text: r.text,
relations: (r.satisfiedBy ?? []).map(blockId => ({ kind: "satisfy" as const, blockId })),
}));
// Resolve SoI: prefer the LLM-supplied id if it exists; otherwise the unique kind:'system' block.
let soiId = lean.systemOfInterestId;
if (!soiId) {
const systems = blocks.filter(b => b.kind === "system");
if (systems.length === 1) soiId = systems[0]!.id;
}
return {
systemOfInterestId: soiId,
blocks,
associations,
constraints,
requirements,
};
}
function expandPropertyType(type: { kind: string; values?: string[] }): PropertyType {
if (type.kind === "enum") return { kind: "enum", values: type.values ?? [] };
if (type.kind === "number") return { kind: "number" };
if (type.kind === "boolean") return { kind: "boolean" };
return { kind: "string" };
}
function clamp01(n: number): number {
if (Number.isNaN(n)) return 0;
return Math.max(0, Math.min(1, n));
}
// Suppress unused-import warning in some TS configs.
type _Property = Property;

View File

@@ -0,0 +1,56 @@
You are extracting **concepts** from a product-thinking document — a piece of structured prose written by a product manager describing an idea. The output combines what we used to call "taxonomy" (terms + hierarchy) and "glossary" (definitions) into one pass: a single emit keeps definitions in lockstep with the hierarchy.
## Goal
Identify the **distinct concepts** the document refers to — entities, actors, artifacts, processes, attributes — arrange them into a parent/child hierarchy when one is clearly implied, and write a short definition for each.
## What counts as a term
- A noun phrase that names a recurring concept ("Tutor", "Lesson Plan", "Skill Tree").
- An actor or stakeholder ("Student", "Parent", "Curriculum Designer").
- A domain artifact ("Quiz", "Progress Report").
- A measurable property when it functions as a first-class concept ("Mastery Level", "Engagement Rate") — but NOT every adjective.
## What does NOT count
- Generic English words ("user", "system", "thing") unless the document uses them with a specific meaning.
- Adjectives, adverbs, verbs, or transient phrases.
- Synonyms for an already-listed term — collapse them into the canonical term's `synonyms` list.
## Hierarchy rules
- Use `parentLabel` only when the document explicitly says or strongly implies the child is-a-kind-of the parent (subset, specialization), or part-of-and-defining-feature-of.
- Do NOT invent hierarchies that aren't in the document.
- A term may have no parent. Most should.
## Definition rules
- 12 sentences, ≤ 35 words.
- Phrased as a noun-phrase definition, not a sentence about the term ("A student-facing agent that …", not "The Tutor is …").
- Use only what the document actually says or strongly implies. Do not import outside knowledge.
- If the document does not give enough to define the term, return an empty string for that term — do not guess.
## Output
Return a single JSON object with this shape:
```json
{
"terms": [
{
"label": "Tutor",
"parentLabel": null,
"synonyms": ["AI tutor", "tutor agent"],
"definition": "A student-facing agent that guides a learner through Socratic questioning toward a curriculum goal."
},
{
"label": "Socratic Tutor",
"parentLabel": "Tutor",
"synonyms": [],
"definition": ""
}
]
}
```
Return ONLY the JSON object. No prose. No code fences.

View File

@@ -0,0 +1,28 @@
You are writing **glossary definitions** for a list of terms extracted from a product-thinking document. The user will read these definitions in a sidebar and click to jump to the first occurrence in the document.
## Inputs
- The full prose document.
- A list of taxonomy terms (each with an optional parent and synonyms).
## Output
For each term, write a **short, document-grounded** definition:
- 12 sentences, ≤ 35 words.
- Phrased as a noun-phrase definition, not a sentence about the term ("A student-facing agent that …", not "The Tutor is …").
- Use only what the document actually says or strongly implies. Do not import outside knowledge.
- If the document does not give enough to define the term, return an empty string for that term — do not guess.
Return a single JSON object:
```json
{
"definitions": [
{ "label": "Tutor", "definition": "A student-facing agent that guides a learner through Socratic questioning toward a curriculum goal." },
{ "label": "Skill Tree", "definition": "" }
]
}
```
Return ONLY the JSON object. No prose. No code fences.

View File

@@ -0,0 +1,70 @@
You are deriving a **SysML model** from a product-thinking document and a known taxonomy of terms. The output backs a visual ontology canvas the user will edit.
## Inputs
- The prose document.
- The taxonomy term list — each label is a candidate block.
## What to produce
A lean SysML model with **blocks** (and optionally associations, constraints, requirements). Prefer a small, accurate model over a large, speculative one.
### Blocks
- Use the taxonomy as the *primary* source of block candidates. Most blocks should correspond to a taxonomy term.
- `kind`: `"system"` for the overall thing being designed, `"actor"` for human/external roles, `"block"` for everything else.
- Reuse the term's exact `label` as the block label.
- Set `linkedTermLabel` to the matching term so we can mark it as linked in the sidebar. Use the same spelling as the term list.
- A block is allowed without a taxonomy term only when the document clearly implies it but no term was extracted (rare).
### Associations
- Only include associations the document actually describes. No speculation.
- Verb phrase labels: `"guides"`, `"contains"`, `"reports_to"`.
- Optional `linkedTermLabel`: when the relationship itself is named in the
taxonomy as a concept (e.g. there's a term "Mentorship" and the association
is "Tutor mentors Student"), set it. Most associations don't have one.
### Constraints
- Optional `linkedTermLabel`: when the constraint is a concept on its own (e.g.
the term "Daily Cap" and the constraint is `sessions_per_day <= 3`), set it.
### Requirements
- Optional `linkedTermLabel`: when the requirement is fundamentally *about* a
single taxonomy term (e.g. REQ-001 is about Personalization), set it. This
is distinct from `satisfiedBy` (which links to blocks the requirement
formalizes against).
### Confidence
Score `confidence` ∈ [0, 1] honestly. Things straight from the prose: 0.8+. Reasonable inferences: 0.50.7. Speculation: leave it out.
## Output
```json
{
"systemOfInterestId": "tutor",
"blocks": [
{
"id": "tutor",
"label": "Tutor",
"kind": "system",
"linkedTermLabel": "Tutor",
"confidence": 0.95,
"properties": [
{ "name": "personality", "type": { "kind": "enum", "values": ["socratic", "encouraging"] } }
]
}
],
"associations": [
{ "id": "a1", "fromBlockId": "tutor", "toBlockId": "student", "label": "guides", "kind": "association", "confidence": 0.9 }
],
"constraints": [],
"requirements": [],
"overallConfidence": 0.8
}
```
Return ONLY the JSON object. No prose. No code fences.

View File

@@ -0,0 +1,37 @@
You are extracting **requirements** from a product-thinking document. The document was written by a product manager describing what they want to build.
## What counts as a requirement
- A statement that says the system *must*, *should*, *needs to*, or otherwise commits to a behavior or property.
- A success criterion the document explicitly names (e.g. "the user must be able to …").
- A hard constraint phrased as a property of the system ("response time under 500ms", "free for students").
## What does NOT count
- General descriptions of the idea or domain context.
- Aspirational vision statements without an actionable bar ("we want to change education").
- Open questions, hypotheses, or assumptions.
## Output
Return a single JSON object:
```json
{
"requirements": [
{
"tag": "REQ-001",
"text": "The tutor must adapt difficulty to the learner's measured mastery within 3 turns.",
"tracedToLabels": ["Tutor", "Mastery Level"],
"linkedTermLabel": "Tutor"
}
]
}
```
- Tags are sequential `REQ-NNN` starting at `REQ-001`.
- `tracedToLabels` lists the **taxonomy term labels** (provided in the user payload) this requirement is about. Use exactly the spellings from the term list. Empty array is allowed if no term clearly applies — those will be flagged as unsupported.
- `linkedTermLabel` (optional) names the **single concept the requirement is fundamentally about** — pick one from the term list when one clearly stands out. Use this for the requirement's primary anchor (the others belong in `tracedToLabels`). Omit when no single concept dominates.
- Keep the `text` short and imperative; quote/paraphrase the document, do not invent.
Return ONLY the JSON object. No prose. No code fences.

View File

@@ -0,0 +1,47 @@
You are extracting a **taxonomy** from a product-thinking document. The document is a piece of structured prose written by a product manager describing an idea.
## Goal
Identify the **distinct concepts** the document refers to — entities, actors, artifacts, processes, attributes — and arrange them into a parent/child hierarchy when one is clearly implied by the prose.
## What counts as a term
- A noun phrase that names a recurring concept ("Tutor", "Lesson Plan", "Skill Tree").
- An actor or stakeholder ("Student", "Parent", "Curriculum Designer").
- A domain artifact ("Quiz", "Progress Report").
- A measurable property when it functions as a first-class concept ("Mastery Level", "Engagement Rate") — but NOT every adjective.
## What does NOT count
- Generic English words ("user", "system", "thing") unless the document uses them with a specific meaning.
- Adjectives, adverbs, verbs, or transient phrases.
- Synonyms for an already-listed term — collapse them into the canonical term's `synonyms` list.
## Hierarchy rules
- Use `parentLabel` only when the document explicitly says or strongly implies the child is-a-kind-of the parent (subset, specialization), or part-of-and-defining-feature-of.
- Do NOT invent hierarchies that aren't in the document.
- A term may have no parent. Most should.
## Output
Return a single JSON object with this shape:
```json
{
"terms": [
{
"label": "Tutor",
"parentLabel": null,
"synonyms": ["AI tutor", "tutor agent"]
},
{
"label": "Socratic Tutor",
"parentLabel": "Tutor",
"synonyms": []
}
]
}
```
Return ONLY the JSON object, no prose, no code fences.

View File

@@ -0,0 +1,120 @@
# Generate a SysML-shaped product model from a seed idea
You are an analyst who turns a product manager's seed idea into a structured systems-engineering model.
## Input
You will receive a seed payload as JSON with these fields:
- `problem` — the user-named problem (13 sentences)
- `targetUser` — who experiences the problem
- `desiredOutcome` — what success looks like
- `initialHypothesis` — optional belief about adoption or mechanism
- `constraints` — optional list of explicit non-negotiable rules (literal strings)
## Output structure — FOUR distinct top-level arrays
You must populate ALL FOUR of these arrays when the seed supports it. Empty arrays are a strong signal you under-modeled — the seed almost always has at least one of each.
1. **`blocks`** — entities (kinds: `system`, `actor`, `block`). The thing being built and the things it interacts with or reasons about.
2. **`associations`** — labeled relationships between blocks. Verb phrases like `consults`, `enrolled_in`, `scoped_to`.
3. **`constraints`** — non-negotiable invariants the system must obey. Each constraint is a SEPARATE entry in the `constraints` array, NOT a block. Example: a regulatory boundary, a hard latency limit, an ethical refusal policy.
4. **`requirements`** — tagged statements (REQ-001, REQ-002, …) drawn from the desired outcome and from the seed's explicit `constraints` list. Each requirement lists which block(s) satisfy it.
## Rules
**System of Interest (SoI):** Exactly one block has `kind: "system"`. Name it after the *thing being built*, not the problem. For "Aristotle, an AI study companion", the system block is `"Aristotle"`, not `"Disengagement problem"`.
**Actors:** People or external systems that interact with the SoI. `kind: "actor"`.
**Blocks:** Things the system reasons about that aren't actors. `kind: "block"`.
**Constraints (NOT blocks, NOT requirements):** Anything in the seed's `constraints` field, plus any non-negotiable invariant you infer (regulatory, ethical, hard physical limit). Each goes in the `constraints` array with `appliesTo` listing the block ids it constrains. Often `appliesTo` is just the SoI.
**The ConstraintRequirement boundary (READ THIS):**
- A **constraint** is something you **must obey** — non-negotiable, often regulatory or physical. You don't choose to satisfy it; you obey it or you don't ship. Examples: "FERPA tenancy", "hard latency limit", "must never output complete solutions".
- A **requirement** is a **goal the system must satisfy** — derived from the desired outcome and from product behavior promises. Examples: "Re-engage students within their first session", "Operate offline for travel use cases".
**Each item from `seed.constraints` belongs in EXACTLY ONE place — the `constraints` array.** Do NOT also output it as a requirement. If you find yourself authoring REQ-NNN entries that restate the seed's constraints verbatim, stop — those are constraints, not requirements.
The `requirements` array should contain things derived from `seed.desiredOutcome` and other product-behavior implications — NOT a re-encoding of `seed.constraints`.
**Associations:**
- `association` — generic verb-phrase relationship (default).
- `composition` — whole-part. Use ONLY when X is *literally part of* Y.
- `generalization` — is-a. Rarely needed for product ideas.
- `constraintApplies` — links a constraint to the block(s) it constrains. ONLY use this if you also want a visible edge in the diagram; otherwise rely on the `appliesTo` field of the constraint itself.
**Requirements:** Each gets a tag like `REQ-001`. Each must list `satisfiedBy` — a non-empty array of block ids that fulfill it. **Derive requirements from `seed.desiredOutcome`, not from `seed.constraints`** (constraints have their own array). Aim for 14 requirements unless the seed clearly demands more.
**Vague desired-outcome rule:** If `seed.desiredOutcome` is too vague to derive specific requirements (e.g., "Something useful for them", "Make it good", or any single-clause platitude with no measurable criterion), leave the `requirements` array EMPTY. Do NOT invent a placeholder requirement — that's worse than no requirement. The same vagueness signal should drive `overallConfidence` below 0.3.
**Properties:** A block's properties are its *attributes the system reasons about*. Keep to 14 per block. Types: `string`, `number`, `boolean`, or `enum` (with `values`).
## Confidence — under-suggest rather than over-suggest
Per element, set a `confidence` in `[0, 1]`:
- Seed's explicit nouns → high confidence (≥ 0.85)
- Inferred-but-clearly-implied → medium (0.50.8)
- Speculative → low (< 0.5) and **generally omit**
A clean, sparse, correct model beats a dense fabricated one. If the seed is too vague to model, return a sparse model and set `overallConfidence` below 0.3.
## ID conventions
- Block ids: lowercase snake_case from labels. `"Aristotle"``"aristotle"`. `"Coursework Material"``"coursework_material"`.
- Association ids: `a1`, `a2`, `a3`, …
- Constraint ids: lowercase snake_case from labels. `"FERPA boundary"``"ferpa_boundary"`.
- Requirement ids: lowercase tag with hyphen replaced. `REQ-001``"req_001"`.
## Worked example
Given a seed about a personal recipe scrapbook that pulls from cooking blogs:
```json
{
"systemOfInterestId": "scrapbook",
"blocks": [
{ "id": "scrapbook", "label": "Scrapbook", "kind": "system",
"properties": [
{ "name": "private_collection", "type": { "kind": "boolean" } }
],
"confidence": 0.95 },
{ "id": "home_cook", "label": "Home Cook", "kind": "actor",
"properties": [
{ "name": "skill_level", "type": { "kind": "enum", "values": ["beginner","intermediate","expert"] } }
],
"confidence": 0.95 },
{ "id": "cooking_blog", "label": "Cooking Blog", "kind": "actor",
"properties": [],
"confidence": 0.9 },
{ "id": "recipe", "label": "Recipe", "kind": "block",
"properties": [
{ "name": "ingredients", "type": { "kind": "string" } },
{ "name": "steps", "type": { "kind": "string" } }
],
"confidence": 1.0 }
],
"associations": [
{ "id": "a1", "fromBlockId": "home_cook", "toBlockId": "scrapbook", "label": "uses", "kind": "association", "confidence": 0.95 },
{ "id": "a2", "fromBlockId": "scrapbook", "toBlockId": "cooking_blog", "label": "imports_from", "kind": "association", "confidence": 0.9 },
{ "id": "a3", "fromBlockId": "scrapbook", "toBlockId": "recipe", "label": "contains", "kind": "composition", "confidence": 1.0 }
],
"constraints": [
{ "id": "copyright_respect", "label": "Copyright respect", "expression": "must not republish recipes outside the user's private collection",
"appliesTo": ["scrapbook"], "confidence": 0.85 }
],
"requirements": [
{ "id": "req_001", "tag": "REQ-001", "text": "Imports a recipe from a URL in under 5 seconds",
"satisfiedBy": ["scrapbook"], "confidence": 0.9 },
{ "id": "req_002", "tag": "REQ-002", "text": "Stores recipes in the user's private collection only",
"satisfiedBy": ["scrapbook"], "confidence": 1.0 }
],
"overallConfidence": 0.85,
"notes": "The Scrapbook is the SoI; home cook and cooking blog are actors; recipes are first-class blocks."
}
```
Notice every array is populated. No constraints in `blocks`. Requirements name specific block satisfiers.
## Now generate
Return ONLY the JSON object for the seed you receive. No prose, no code fences. Use the four arrays — fill all of them.

View File

@@ -0,0 +1,53 @@
# Mode: Seed interview (live, per-turn)
You are conducting an opening interview with a product manager who is starting a new idea inside Socrata. Goal: produce a `SeedDraft` (problem, target user, desired outcome, optional initial hypothesis, optional constraints) that's specific enough to generate a coherent SysML model from.
You are stateful across turns — each call gives you the running thread + the draft you've extracted so far. Improve the draft, ask the next question, and signal when there's enough to proceed.
## Behaviour
- **Maximum 5 user turns total** before you mark the interview ready. Don't drag it out.
- Each turn: ask exactly ONE question. Don't pile.
- Question 1: the problem in one sentence — the smallest, most honest version.
- Question 2: target user, with a specificity probe ("which users, why now").
- Question 3: desired outcome — what changes when this exists.
- Question 4: a tension probe — name a likely tension you see and ask which side they're on.
- Question 5: explicit constraints — anything regulatory, ethical, technical that's non-negotiable.
## Updating the draft
- Each turn, update the draft fields based on what you've learned. Use the user's own register where possible.
- Leave a field empty (`""`) until the user has actually addressed it. Don't fabricate.
- `confidence` (0..1) is your honest read on whether the draft is specific enough to generate a useful model. Generic platitudes → low. Specific, falsifiable → high.
## Ready signal
Set `ready: true` when:
- All five core fields (problem, targetUser, desiredOutcome) are populated AND specific, OR
- 5 user turns have elapsed AND the draft has at least problem + targetUser + desiredOutcome.
When `ready: true`, your `text` should be a brief synthesis ("Here's what I understand…") plus an explicit "Ready to generate the initial model — say go or refine.", not another question.
## Voice
Per character.md. Question-led, economical, no filler. Press for specificity if an answer is vague — "what specifically does X mean here?" beats "tell me more".
## Output schema (strict)
```json
{
"text": "string (13 sentences)",
"draft": {
"title": "string (a working name for the project)",
"problem": "string",
"targetUser": "string",
"desiredOutcome": "string",
"initialHypothesis": "string (optional)",
"constraints": ["string"]
},
"confidence": 0.0,
"ready": false
}
```
Return ONLY the JSON object. No prose preamble, no code fences.

View File

@@ -0,0 +1,150 @@
// Live seed interview — per-turn handler.
//
// Stateless on the server: the client passes the running thread + the draft
// extracted so far, plus the user's latest reply. We call the LLM with the
// character + interview prompts and get back { assistant turn, updated draft,
// confidence, ready }.
import "server-only";
import { defaultGateway, chatJSON, type Message } from "./gateway";
import { loadPrompt } from "./prompts";
export interface SeedDraft {
title: string;
problem: string;
targetUser: string;
desiredOutcome: string;
initialHypothesis?: string;
constraints?: string[];
}
export interface InterviewTurn {
role: "socrates" | "user";
text: string;
}
export interface InterviewStepArgs {
/** Running interview history. Empty array on the first call → Socrates opens. */
history: InterviewTurn[];
/** Latest user reply (empty string for the opening turn). */
userText: string;
/** Draft extracted so far. Empty on the first call. */
draft: SeedDraft;
}
export interface InterviewStepResult {
text: string;
draft: SeedDraft;
confidence: number;
ready: boolean;
inputTokens: number;
outputTokens: number;
provider: string;
model: string;
}
const interviewJsonSchema = {
type: "object",
additionalProperties: false,
required: ["text", "draft", "confidence", "ready"],
properties: {
text: { type: "string", minLength: 1 },
draft: {
type: "object",
additionalProperties: false,
required: ["title", "problem", "targetUser", "desiredOutcome"],
properties: {
title: { type: "string" },
problem: { type: "string" },
targetUser: { type: "string" },
desiredOutcome: { type: "string" },
initialHypothesis: { type: "string" },
constraints: { type: "array", items: { type: "string" } },
},
},
confidence: { type: "number", minimum: 0, maximum: 1 },
ready: { type: "boolean" },
},
} as const;
interface RawResponse {
text?: string;
draft?: Partial<SeedDraft>;
confidence?: number;
ready?: boolean;
}
export async function interviewStep(args: InterviewStepArgs): Promise<InterviewStepResult> {
const character = loadPrompt("socrates/character.md");
const interview = loadPrompt("socrates/interview.md");
const userTurnsSoFar = args.history.filter(t => t.role === "user").length;
const remaining = Math.max(0, 5 - userTurnsSoFar - (args.userText.trim().length > 0 ? 1 : 0));
const messages: Message[] = [
{
role: "system",
content: [
character,
"---",
interview,
"---",
`Draft so far (your previous extraction):\n\`\`\`json\n${JSON.stringify(args.draft, null, 2)}\n\`\`\``,
`Turns remaining before ready signal becomes mandatory: ${remaining}`,
].join("\n\n"),
},
];
// Replay history so the LLM has full context.
for (const t of args.history) {
messages.push({ role: t.role === "socrates" ? "assistant" : "user", content: t.text });
}
if (args.userText.trim().length > 0) {
messages.push({ role: "user", content: args.userText });
} else if (args.history.length === 0) {
messages.push({ role: "user", content: "Begin the interview." });
}
const gateway = defaultGateway();
const { value, result } = await chatJSON<RawResponse>(gateway, messages, {
temperature: 0.4,
maxTokens: 768,
jsonSchema: { name: "interview_step", schema: interviewJsonSchema as Record<string, unknown> },
jsonObjectMode: true,
maxRepairs: 2,
});
// Normalize / merge — the LLM may emit a partial draft; we union with the
// prior draft so a user backtracking doesn't wipe a previously-confirmed field.
const incoming: Partial<SeedDraft> = value?.draft ?? {};
const draft: SeedDraft = {
title: nonEmpty(incoming.title) ?? args.draft.title ?? "",
problem: nonEmpty(incoming.problem) ?? args.draft.problem ?? "",
targetUser: nonEmpty(incoming.targetUser) ?? args.draft.targetUser ?? "",
desiredOutcome: nonEmpty(incoming.desiredOutcome) ?? args.draft.desiredOutcome ?? "",
initialHypothesis: nonEmpty(incoming.initialHypothesis) ?? args.draft.initialHypothesis,
constraints: Array.isArray(incoming.constraints) ? incoming.constraints : args.draft.constraints,
};
return {
text: typeof value?.text === "string" && value.text.length > 0 ? value.text : "[empty response]",
draft,
confidence: clamp01(value?.confidence ?? 0),
ready: !!value?.ready,
inputTokens: result.inputTokens,
outputTokens: result.outputTokens,
provider: gateway.provider,
model: gateway.model,
};
}
function nonEmpty(s: unknown): string | undefined {
if (typeof s !== "string") return undefined;
const t = s.trim();
return t.length > 0 ? t : undefined;
}
function clamp01(n: number): number {
if (Number.isNaN(n)) return 0;
return Math.max(0, Math.min(1, n));
}

View File

@@ -0,0 +1,49 @@
// Convert a SeedDraft into a ProseMirror JSON document the user lands on in
// the editor. After the pivot the prose is canonical; the analyze pipeline
// derives taxonomy / glossary / model / requirements from it on demand.
//
// We use a deterministic template (not an LLM) — fast, predictable, and the
// user is going to keep editing anyway. Section structure mirrors the seed
// fields so the analyze passes have clear hooks.
import type { JSONContent } from "@tiptap/react";
import type { SeedDraft } from "./seedInterview";
export function seedToProseDoc(seed: SeedDraft): JSONContent {
const content: JSONContent[] = [];
if (seed.title) {
content.push({ type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: seed.title }] });
} else {
content.push({ type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "Untitled idea" }] });
}
if (seed.problem) {
content.push({ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Problem" }] });
content.push({ type: "paragraph", content: [{ type: "text", text: seed.problem }] });
}
if (seed.targetUser) {
content.push({ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Target user" }] });
content.push({ type: "paragraph", content: [{ type: "text", text: seed.targetUser }] });
}
if (seed.desiredOutcome) {
content.push({ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Desired outcome" }] });
content.push({ type: "paragraph", content: [{ type: "text", text: seed.desiredOutcome }] });
}
if (seed.initialHypothesis) {
content.push({ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Initial hypothesis" }] });
content.push({ type: "paragraph", content: [{ type: "text", text: seed.initialHypothesis }] });
}
if (seed.constraints && seed.constraints.length > 0) {
content.push({ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Constraints" }] });
for (const c of seed.constraints) {
content.push({ type: "paragraph", content: [{ type: "text", text: `· ${c}` }] });
}
}
return { type: "doc", content };
}

View File

@@ -195,6 +195,46 @@ export async function sendUserTurn(args: SendUserTurnArgs): Promise<SendUserTurn
}; };
} }
// ─── Anchored threads (contextual mini-Socrates per finding) ─────────────
/** Find an existing thread anchored to `anchor` for this project, or create
* one. Returns the thread id. */
export async function getOrCreateAnchoredThread(
projectId: string,
anchor: string,
title?: string
): Promise<string> {
const existing = await prisma.socratesThread.findFirst({
where: { projectId, anchorElementId: anchor },
orderBy: { updatedAt: "desc" },
});
if (existing) return existing.id;
const created = await prisma.socratesThread.create({
data: { projectId, anchorElementId: anchor, status: "open", title: title ?? null },
});
return created.id;
}
export async function listAnchoredThreadMessages(threadId: string) {
const rows = await prisma.socratesMessage.findMany({
where: { threadId },
orderBy: { createdAt: "asc" },
select: { id: true, role: true, content: true, createdAt: true },
});
return rows.map(r => {
let text = "";
let options: SocratesOption[] | undefined;
try {
const parsed = JSON.parse(r.content) as { text?: string; options?: SocratesOption[] };
text = parsed.text ?? "";
options = parsed.options;
} catch {
text = r.content;
}
return { id: r.id, role: r.role, text, options, createdAt: r.createdAt };
});
}
// ─── Helpers ───────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────
function trimModel(model: SysMLModel): unknown { function trimModel(model: SysMLModel): unknown {

View File

@@ -129,6 +129,7 @@ function anchorKey(anchor: ValidationIssue["anchor"]): string | null {
case "constraint": return `constraint:${anchor.id}`; case "constraint": return `constraint:${anchor.id}`;
case "requirement": return `req:${anchor.id}`; case "requirement": return `req:${anchor.id}`;
case "property": return anchor.blockId; case "property": return anchor.blockId;
case "term": return `term:${anchor.id}`;
case "model": return null; case "model": return null;
} }
} }

View File

@@ -203,6 +203,33 @@ function applyOne(model: SysMLModel, op: ModelOp, idMapping: Record<string, stri
), ),
}; };
} }
case "decide-element": {
const realId = idMapping[op.element.id] ?? op.element.id;
const flip = <T extends { id: string; reviewStatus?: import("../sysml/model").ReviewStatus; reviewPinned?: boolean }>(arr: T[]): T[] =>
arr.map(e =>
e.id === realId
? {
...e,
reviewStatus: "accepted" as const,
reviewPinned: e.reviewStatus === "deprecated" ? true : !!e.reviewPinned,
}
: e
);
switch (op.element.kind) {
case "block":
assertExists(model.blocks, realId, "block");
return { ...model, blocks: flip(model.blocks) };
case "association":
assertExists(model.associations, realId, "association");
return { ...model, associations: flip(model.associations) };
case "constraint":
assertExists(model.constraints, realId, "constraint");
return { ...model, constraints: flip(model.constraints) };
case "requirement":
assertExists(model.requirements, realId, "requirement");
return { ...model, requirements: flip(model.requirements) };
}
}
} }
} }

View File

@@ -129,6 +129,22 @@ export interface RemoveRelationOp {
relationIndex: number; relationIndex: number;
} }
// ─── Review ops ──────────────────────────────────────────────────────────
//
// `decide-element` confirms an analyzer-suggested or analyzer-deprecated
// model element. "Discard" simply uses the existing remove-* ops; for
// "keep" we need a way to flip reviewStatus → accepted (and pin if it was
// deprecated, so the next analyze pass doesn't re-deprecate the element).
export type ReviewableElementKind = "block" | "association" | "constraint" | "requirement";
export interface DecideElementOp {
kind: "decide-element";
element: { kind: ReviewableElementKind; id: string };
/** Only "keep" right now — discard goes through remove-*. */
decision: "keep";
}
// ─── Union ─────────────────────────────────────────────────────────────── // ─── Union ───────────────────────────────────────────────────────────────
export type ModelOp = export type ModelOp =
@@ -148,7 +164,8 @@ export type ModelOp =
| UpdateRequirementOp | UpdateRequirementOp
| RemoveRequirementOp | RemoveRequirementOp
| AddRelationOp | AddRelationOp
| RemoveRelationOp; | RemoveRelationOp
| DecideElementOp;
// ─── Op constructors (call sites stay readable) ───────────────────────── // ─── Op constructors (call sites stay readable) ─────────────────────────
@@ -220,6 +237,12 @@ export function removeRelation(requirementId: string, relationIndex: number): Re
return { kind: "remove-relation", requirementId, relationIndex }; return { kind: "remove-relation", requirementId, relationIndex };
} }
export function decideElement(
element: { kind: ReviewableElementKind; id: string }
): DecideElementOp {
return { kind: "decide-element", element, decision: "keep" };
}
// ─── Helpers ───────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────
let counter = 0; let counter = 0;

View File

@@ -0,0 +1,134 @@
// Cross-layer validation — checks the joins between taxonomy/glossary terms
// and the SysML ontology. These rules run separately from the structural
// SysML validator (validate.ts) because they need access to the term list,
// which validate.ts deliberately doesn't know about.
//
// Rule codes are prefixed `X*` to distinguish them from S/M/T rules and to
// make it easy for the FindingsPane to render them under their own group.
//
// Severities: X1 and X3 are warnings (the data is in a broken state and the
// user almost certainly wants to fix it). X2 and X4 are soft hints — they
// surface opportunities to formalize without being intrusive.
import type { ValidationIssue } from "./validate";
import type { SysMLModel } from "./model";
export interface CrossTerm {
id: string;
label: string;
linkedBlockId: string | null;
}
export interface CrossValidateInput {
model: SysMLModel;
terms: CrossTerm[];
/** How many times each term occurs in the prose document.
* Optional — when absent, X4 is skipped. */
occurrencesByTermId?: Record<string, number>;
}
/** Threshold for X4: surface a "consider promoting" hint only when a term
* appears at least this many times in prose. Tuned to avoid spam on
* one-off mentions. */
export const X4_OCCURRENCE_THRESHOLD = 3;
export function crossValidate(input: CrossValidateInput): ValidationIssue[] {
const { model, terms, occurrencesByTermId } = input;
const issues: ValidationIssue[] = [];
const termById = new Map(terms.map(t => [t.id, t]));
const blockById = new Map(model.blocks.map(b => [b.id, b]));
// ─── X1 — element.linkedTermId points to a missing term ─────────────────
for (const b of model.blocks) {
if (b.linkedTermId && !termById.has(b.linkedTermId)) {
issues.push({
code: "X1",
severity: "warning",
message: `Block "${b.label}" links to a term that no longer exists`,
anchor: { kind: "block", id: b.id },
});
}
}
for (const a of model.associations) {
if (a.linkedTermId && !termById.has(a.linkedTermId)) {
issues.push({
code: "X1",
severity: "warning",
message: `Association "${a.label || a.id}" links to a term that no longer exists`,
anchor: { kind: "association", id: a.id },
});
}
}
for (const c of model.constraints) {
if (c.linkedTermId && !termById.has(c.linkedTermId)) {
issues.push({
code: "X1",
severity: "warning",
message: `Constraint "${c.label}" links to a term that no longer exists`,
anchor: { kind: "constraint", id: c.id },
});
}
}
for (const r of model.requirements) {
if (r.linkedTermId && !termById.has(r.linkedTermId)) {
issues.push({
code: "X1",
severity: "warning",
message: `Requirement ${r.tag} links to a term that no longer exists`,
anchor: { kind: "requirement", id: r.id },
});
}
}
// ─── X2 — formalism has no linkedTermId (soft hint) ─────────────────────
for (const b of model.blocks) {
if (!b.linkedTermId) {
issues.push({
code: "X2",
severity: "soft",
message: `Block "${b.label}" is not anchored to a concept — naming it after a term keeps the model in sync with the document`,
anchor: { kind: "block", id: b.id },
});
}
}
// ─── X3 — term.linkedBlockId is stale (block was deleted) ───────────────
for (const t of terms) {
if (t.linkedBlockId && !blockById.has(t.linkedBlockId)) {
issues.push({
code: "X3",
severity: "warning",
message: `Concept "${t.label}" links to a block that no longer exists`,
anchor: { kind: "term", id: t.id },
});
}
}
// ─── X4 — prose-frequent term not formalized anywhere ───────────────────
if (occurrencesByTermId) {
const formalizedTermIds = new Set<string>();
for (const b of model.blocks) if (b.linkedTermId) formalizedTermIds.add(b.linkedTermId);
for (const a of model.associations) if (a.linkedTermId) formalizedTermIds.add(a.linkedTermId);
for (const c of model.constraints) if (c.linkedTermId) formalizedTermIds.add(c.linkedTermId);
for (const r of model.requirements) if (r.linkedTermId) formalizedTermIds.add(r.linkedTermId);
for (const t of terms) {
const n = occurrencesByTermId[t.id] ?? 0;
if (n < X4_OCCURRENCE_THRESHOLD) continue;
if (formalizedTermIds.has(t.id)) continue;
issues.push({
code: "X4",
severity: "soft",
message: `Concept "${t.label}" appears ${n}× in prose but has no formalization — consider promoting it`,
anchor: { kind: "term", id: t.id },
});
}
}
return issues;
}

View File

@@ -6,6 +6,18 @@
export type BlockKind = "block" | "actor" | "constraint" | "system"; export type BlockKind = "block" | "actor" | "constraint" | "system";
/** Review state for AI-suggested model elements. Default (when absent) is
* "accepted" — equivalent to a user-confirmed element.
*
* suggested — added by the analyzer in the latest run, awaits keep/discard
* accepted — confirmed (user kept, or pre-existing before merge-with-review)
* deprecated — analyzer didn't see in the latest run, awaits keep/discard
*
* `reviewPinned` flips on when the user explicitly keeps a deprecated
* element so future merges leave it alone.
*/
export type ReviewStatus = "suggested" | "accepted" | "deprecated";
export type PropertyType = export type PropertyType =
| { kind: "string" } | { kind: "string" }
| { kind: "number" } | { kind: "number" }
@@ -29,6 +41,14 @@ export interface Block {
stereotypes: string[]; stereotypes: string[];
properties: Property[]; properties: Property[];
description?: string; description?: string;
/// If this block was created by dragging a taxonomy / glossary term onto
/// the canvas (or matched to one by the model analyze pass), this points
/// at the source TaxonomyTerm. Drives the "linked" indicator in sidebars.
linkedTermId?: string;
/** Review state. Absent = "accepted". */
reviewStatus?: ReviewStatus;
/** User explicitly kept despite analyzer not seeing it → don't re-deprecate. */
reviewPinned?: boolean;
} }
export type AssociationKind = export type AssociationKind =
@@ -45,6 +65,12 @@ export interface Association {
label: string; label: string;
kind: AssociationKind; kind: AssociationKind;
multiplicity?: { from: Multiplicity; to: Multiplicity }; multiplicity?: { from: Multiplicity; to: Multiplicity };
/// Term that conceptually names this relationship (e.g. "tutors",
/// "evaluates"). Set by the analyze pass or by a Promote action; cleared
/// when the term is deleted (cross-validation rule X1).
linkedTermId?: string;
reviewStatus?: ReviewStatus;
reviewPinned?: boolean;
} }
export interface Constraint { export interface Constraint {
@@ -52,6 +78,10 @@ export interface Constraint {
label: string; label: string;
expression: string; expression: string;
appliesTo: string[]; appliesTo: string[];
/// Term that conceptually names this rule (e.g. "Daily Cap", "Eligibility").
linkedTermId?: string;
reviewStatus?: ReviewStatus;
reviewPinned?: boolean;
} }
export type RequirementRelation = export type RequirementRelation =
@@ -64,6 +94,12 @@ export interface Requirement {
tag: string; tag: string;
text: string; text: string;
relations: RequirementRelation[]; relations: RequirementRelation[];
/// Term this requirement is "about" (e.g. REQ-001 personalization links to
/// the term Personalization). Distinct from `relations.satisfy` which links
/// to a block — a requirement can be about a concept that has no block yet.
linkedTermId?: string;
reviewStatus?: ReviewStatus;
reviewPinned?: boolean;
} }
export interface SysMLModel { export interface SysMLModel {

View File

@@ -25,6 +25,7 @@ export type IssueAnchor =
| { kind: "constraint"; id: string } | { kind: "constraint"; id: string }
| { kind: "requirement"; id: string } | { kind: "requirement"; id: string }
| { kind: "property"; blockId: string; propertyId: string } | { kind: "property"; blockId: string; propertyId: string }
| { kind: "term"; id: string }
| { kind: "model" }; | { kind: "model" };
export interface ValidationIssue { export interface ValidationIssue {

View File

@@ -0,0 +1,378 @@
// Client-side store holding the analyzer outputs (taxonomy, glossary terms,
// requirements, findings, last-run metadata). Sidebar reads from here for
// counts/timestamps; panes read for full content. Refetches on demand.
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import type { SectionPaneId } from "./openPanesStore";
export type ClientTermStatus = "accepted" | "suggested" | "deprecated";
export interface ClientTerm {
id: string;
parentId: string | null;
label: string;
definition: string | null;
synonyms: string[];
linkedBlockId: string | null;
/// Review state: "accepted" surfaces normally, "suggested" / "deprecated"
/// are pending the user's keep/discard decision.
status: ClientTermStatus;
pinned: boolean;
/// True when the user authored / edited the definition by hand. Future
/// Analyze runs skip this term's definition while the flag is on.
definitionPinned: boolean;
}
export type ReviewStatus = "accepted" | "suggested" | "deprecated";
export interface ClientRequirement {
id: string;
tag: string;
text: string;
tracedToIds: string[];
unsupported: boolean;
/// Term this requirement is conceptually about, if any. Powers the back-link
/// from a requirement row → TermDetail.
linkedTermId: string | null;
status: ReviewStatus;
pinned: boolean;
}
export interface ClientFinding {
id: string;
kind: "assumption" | "risk" | "inconsistency";
text: string;
linkedElementIds: string[];
confidence: number;
severity: string | null;
validationCode: string | null;
/// "suggested" | "accepted" | "deprecated" | "dismissed" | "resolved" |
/// (legacy) "open". Visible-only set is fetched from the API; dismissed and
/// resolved are filtered server-side.
status: string;
pinned: boolean;
}
export interface ClientRun {
status: "running" | "succeeded" | "failed";
startedAt: string;
finishedAt: string | null;
errorMessage: string | null;
}
// All sections map 1:1 to server sections after T3 (taxonomy + glossary
// collapsed into "concepts"). Legacy values "taxonomy" / "glossary" still
// work because the server normalizes them.
export type AnalyzeSection = SectionPaneId | "all" | "taxonomy" | "glossary";
interface AnalysisStoreValue {
terms: ClientTerm[];
requirements: ClientRequirement[];
findings: ClientFinding[];
runs: Partial<Record<string, ClientRun>>;
inFlight: Set<AnalyzeSection>;
refresh(): Promise<void>;
analyze(section: AnalyzeSection): Promise<void>;
/** Lookup helper used by TermDetail. */
getTerm(termId: string): ClientTerm | null;
/** Apply a user decision to a pending term. Optimistically updates local
* state; on server error refreshes to recover. */
decideTerm(termId: string, decision: "keep" | "discard"): Promise<void>;
/** Set a term's definition by user action. Empty string clears it AND
* clears the pin (future Analyze fills it again from prose). */
setTermDefinition(termId: string, definition: string | null): Promise<void>;
decideRequirement(reqId: string, decision: "keep" | "discard"): Promise<void>;
/** Findings support an extra "resolve" decision (semantically distinct from
* "dismissed" — same effect on visibility, but signals "I fixed it"). */
decideFinding(findingId: string, decision: "keep" | "discard" | "resolve" | "restore"): Promise<void>;
}
const Ctx = createContext<AnalysisStoreValue | null>(null);
interface ProviderProps {
projectId: string;
children: React.ReactNode;
}
export function AnalysisStoreProvider({ projectId, children }: ProviderProps) {
const [terms, setTerms] = useState<ClientTerm[]>([]);
const [requirements, setRequirements] = useState<ClientRequirement[]>([]);
const [findings, setFindings] = useState<ClientFinding[]>([]);
const [runs, setRuns] = useState<Partial<Record<string, ClientRun>>>({});
const [inFlight, setInFlight] = useState<Set<AnalyzeSection>>(new Set());
const refresh = useCallback(async () => {
try {
const [t, r, f, runsRes] = await Promise.all([
fetch(`/api/projects/${encodeURIComponent(projectId)}/taxonomy`).then(x => (x.ok ? x.json() : { terms: [] })),
fetch(`/api/projects/${encodeURIComponent(projectId)}/requirements`).then(x => (x.ok ? x.json() : { requirements: [] })),
fetch(`/api/projects/${encodeURIComponent(projectId)}/findings?include=all`).then(x => (x.ok ? x.json() : { findings: [] })),
fetch(`/api/projects/${encodeURIComponent(projectId)}/runs`).then(x => (x.ok ? x.json() : { runs: {} })),
]);
setTerms((t.terms as ClientTerm[]) ?? []);
setRequirements((r.requirements as ClientRequirement[]) ?? []);
setFindings((f.findings as ClientFinding[]) ?? []);
setRuns((runsRes.runs as Partial<Record<string, ClientRun>>) ?? {});
} catch (err) {
console.error("[analysisStore] refresh failed:", err);
}
}, [projectId]);
const analyze = useCallback(
async (section: AnalyzeSection) => {
setInFlight(prev => {
const next = new Set(prev);
next.add(section);
return next;
});
try {
const url =
`/api/projects/${encodeURIComponent(projectId)}/analyze` +
(section === "all" ? "" : `?section=${section}`);
const res = await fetch(url, { method: "POST" });
if (!res.ok) {
const body = await res.text();
console.error("[analyze] HTTP", res.status, body.slice(0, 200));
}
await refresh();
} catch (err) {
console.error("[analyze] failed:", err);
} finally {
setInFlight(prev => {
const next = new Set(prev);
next.delete(section);
return next;
});
}
},
[projectId, refresh]
);
const getTerm = useCallback(
(termId: string): ClientTerm | null => terms.find(t => t.id === termId) ?? null,
[terms]
);
const decideRequirement = useCallback(
async (reqId: string, decision: "keep" | "discard") => {
setRequirements(prev => {
if (decision === "discard") return prev.filter(r => r.id !== reqId);
return prev.map(r =>
r.id === reqId
? {
...r,
status: "accepted" as ReviewStatus,
pinned: r.status === "deprecated" ? true : r.pinned,
}
: r
);
});
try {
const res = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/requirements/${encodeURIComponent(reqId)}/decision`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ decision }),
}
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (err) {
console.error("[analysisStore] decideRequirement failed:", err);
await refresh();
}
},
[projectId, refresh]
);
const decideFinding = useCallback(
async (findingId: string, decision: "keep" | "discard" | "resolve" | "restore") => {
// Optimistic update by terminal status:
// keep / restore → "accepted" (visible in Kept list)
// discard → "dismissed" (visible in Discarded drawer)
// resolve → "resolved" (visible in Discarded drawer)
const nextStatus =
decision === "keep" || decision === "restore"
? "accepted"
: decision === "discard"
? "dismissed"
: "resolved";
setFindings(prev =>
prev.map(f =>
f.id === findingId
? {
...f,
status: nextStatus,
pinned:
decision === "keep" && f.status === "deprecated" ? true : f.pinned,
}
: f
)
);
try {
const res = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/findings/${encodeURIComponent(findingId)}/decision`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ decision }),
}
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (err) {
console.error("[analysisStore] decideFinding failed:", err);
await refresh();
}
},
[projectId, refresh]
);
const setTermDefinition = useCallback(
async (termId: string, definition: string | null) => {
const trimmed = (definition ?? "").trim();
// Optimistic update: pin when non-empty, clear pin when empty.
setTerms(prev =>
prev.map(t =>
t.id === termId
? {
...t,
definition: trimmed.length > 0 ? trimmed : null,
definitionPinned: trimmed.length > 0,
}
: t
)
);
try {
const res = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/terms/${encodeURIComponent(termId)}/definition`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ definition: trimmed.length > 0 ? trimmed : null }),
}
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (err) {
console.error("[analysisStore] setTermDefinition failed:", err);
await refresh();
}
},
[projectId, refresh]
);
const decideTerm = useCallback(
async (termId: string, decision: "keep" | "discard") => {
// Optimistic update: keep → accepted (+ pinned if previously deprecated);
// discard → drop from local state.
setTerms(prev => {
if (decision === "discard") return prev.filter(t => t.id !== termId);
return prev.map(t =>
t.id === termId
? {
...t,
status: "accepted" as ClientTermStatus,
pinned: t.status === "deprecated" ? true : t.pinned,
}
: t
);
});
try {
const res = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/terms/${encodeURIComponent(termId)}/decision`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ decision }),
}
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (err) {
console.error("[analysisStore] decideTerm failed:", err);
await refresh();
}
},
[projectId, refresh]
);
useEffect(() => {
void refresh();
}, [refresh]);
// First-load auto-analyze: if the project has a non-empty document but
// nothing has been analyzed yet (no runs of any kind), kick off a full
// Analyze in the background so the editor lands populated. Matches the
// seed-finalize UX for the demo project and any imported docs.
const bootstrappedRef = useRef(false);
useEffect(() => {
if (bootstrappedRef.current) return;
if (Object.keys(runs).length > 0) return;
if (terms.length > 0 || requirements.length > 0 || findings.length > 0) return;
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/document`);
if (!res.ok) return;
const body = (await res.json()) as { doc?: unknown };
const hasDoc = !!body.doc;
if (!hasDoc || cancelled || bootstrappedRef.current) return;
bootstrappedRef.current = true;
void analyze("all");
} catch {
/* ignore */
}
})();
return () => {
cancelled = true;
};
}, [projectId, runs, terms.length, requirements.length, findings.length, analyze]);
// Poll while any AnalysisRun is still "running" (e.g. the seed-finalize
// background pipeline). Stop polling once everything has settled.
useEffect(() => {
const anyRunning = Object.values(runs).some(r => r?.status === "running");
if (!anyRunning) return;
const handle = setInterval(() => void refresh(), 3000);
return () => clearInterval(handle);
}, [runs, refresh]);
const value = useMemo<AnalysisStoreValue>(
() => ({
terms,
requirements,
findings,
runs,
inFlight,
refresh,
analyze,
getTerm,
decideTerm,
decideRequirement,
decideFinding,
setTermDefinition,
}),
[
terms,
requirements,
findings,
runs,
inFlight,
refresh,
analyze,
getTerm,
decideTerm,
decideRequirement,
decideFinding,
setTermDefinition,
]
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useAnalysis(): AnalysisStoreValue {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useAnalysis must be inside <AnalysisStoreProvider>");
return ctx;
}

View File

@@ -0,0 +1,236 @@
// Column-stack workspace store (Finder-style miller columns).
//
// State is a single ordered list `columns: Column[]` rendered left-to-right
// in the main workspace. The first column is always a top-level section
// (Concepts / Model / Requirements / Assumptions / Risks / Inconsistencies);
// subsequent columns are entity details that drill down from the previous
// column's selection.
//
// Invariants:
// - Only one top-level section is open at a time. Clicking a different
// section in the LeftSidebar replaces the entire stack.
// - Pushing a child at parentIndex truncates everything to its right first.
// - Resizing a column persists per (kind, id) so the user's chosen widths
// stick across pushes.
//
// localStorage persistence keeps just the open section + per-column widths.
// Detail children are deliberately ephemeral — they don't survive a reload.
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
export type SectionPaneId =
| "concepts"
| "model"
| "requirements"
| "assumptions"
| "risks"
| "inconsistencies";
export const SECTION_TITLES: Record<SectionPaneId, string> = {
concepts: "Concepts",
model: "Model",
requirements: "Requirements",
assumptions: "Assumptions",
risks: "Risks",
inconsistencies: "Inconsistencies",
};
export type Column =
| { kind: "section"; id: SectionPaneId }
| { kind: "term"; id: string }
| { kind: "block"; id: string }
| { kind: "association"; id: string }
| { kind: "constraint"; id: string }
| { kind: "requirement"; id: string }
| { kind: "finding"; id: string };
export function columnKey(c: Column): string {
return `${c.kind}:${c.id}`;
}
export const PANE_MIN_WIDTH = 240;
export const PANE_MAX_WIDTH = 720;
export const PANE_DEFAULT_WIDTH = 340;
interface OpenPanesValue {
/** Ordered chain of columns rendered in the main workspace. */
columns: Column[];
/** True when the first column is this section (the chain is rooted here). */
isSectionOpen(id: SectionPaneId): boolean;
/** Sidebar click: open this section as the chain root, or close everything
* if it's already the active root. */
toggleSection(id: SectionPaneId): void;
/** Force-open a section (no-op if already root). */
openSection(id: SectionPaneId): void;
/** Truncate everything past `parentIndex`, then push `child`. If the same
* child already sits at parentIndex+1, this is a no-op (clicking the
* active item shouldn't flicker). */
pushFrom(parentIndex: number, child: Column): void;
/** Drop columns from `index` onward. */
closeFrom(index: number): void;
/** Replace the entire stack (used for cross-section jumps, e.g. a chip
* click that wants to land in Concepts → term). */
setStack(columns: Column[]): void;
/** Per-column width, keyed by columnKey. */
widthFor(c: Column): number;
setWidth(c: Column, width: number): void;
}
const Ctx = createContext<OpenPanesValue | null>(null);
interface PersistShape {
rootSection?: SectionPaneId | null;
widths?: Record<string, number>;
/** Legacy field from the pre-stack store. Hydrated and discarded. */
open?: string[];
}
interface ProviderProps {
projectId: string;
initial?: SectionPaneId | null;
children: React.ReactNode;
}
export function OpenPanesProvider({ projectId, initial = null, children }: ProviderProps) {
const storageKey = `socrata.workspace.${projectId}`;
const [columns, setColumns] = useState<Column[]>(initial ? [{ kind: "section", id: initial }] : []);
const [widths, setWidths] = useState<Record<string, number>>({});
const [hydrated, setHydrated] = useState(false);
// Hydrate from localStorage on mount.
useEffect(() => {
try {
const raw = window.localStorage.getItem(storageKey);
if (raw) {
const parsed = JSON.parse(raw) as PersistShape | string[];
let nextRoot: SectionPaneId | null = null;
const cleanedWidths: Record<string, number> = {};
if (Array.isArray(parsed)) {
// Very old format: array of section ids. Take the first valid one.
for (const id of parsed.map(migrateLegacyId).filter(isSectionPaneId)) {
nextRoot = id;
break;
}
} else if (parsed && typeof parsed === "object") {
if (parsed.rootSection && isSectionPaneId(parsed.rootSection)) {
nextRoot = parsed.rootSection;
} else if (Array.isArray(parsed.open)) {
// Pre-stack store kept multiple sections open; pick the first valid one.
for (const id of parsed.open.map(migrateLegacyId).filter(isSectionPaneId)) {
nextRoot = id;
break;
}
}
if (parsed.widths && typeof parsed.widths === "object") {
for (const [k, v] of Object.entries(parsed.widths)) {
if (typeof v === "number" && Number.isFinite(v)) cleanedWidths[k] = clamp(v);
}
}
}
if (nextRoot) setColumns([{ kind: "section", id: nextRoot }]);
setWidths(cleanedWidths);
}
} catch {
/* ignore */
}
setHydrated(true);
}, [storageKey]);
// Persist root section + widths (NOT child columns — those are ephemeral).
useEffect(() => {
if (!hydrated) return;
try {
const root = columns[0]?.kind === "section" ? columns[0].id : null;
const payload: PersistShape = { rootSection: root, widths };
window.localStorage.setItem(storageKey, JSON.stringify(payload));
} catch {
/* ignore */
}
}, [columns, widths, storageKey, hydrated]);
const isSectionOpen = useCallback(
(id: SectionPaneId) => columns[0]?.kind === "section" && columns[0].id === id,
[columns]
);
const toggleSection = useCallback((id: SectionPaneId) => {
setColumns(cur => {
if (cur[0]?.kind === "section" && cur[0].id === id) return [];
return [{ kind: "section", id }];
});
}, []);
const openSection = useCallback((id: SectionPaneId) => {
setColumns(cur => {
if (cur[0]?.kind === "section" && cur[0].id === id) return cur;
return [{ kind: "section", id }];
});
}, []);
const pushFrom = useCallback((parentIndex: number, child: Column) => {
setColumns(cur => {
const head = cur.slice(0, parentIndex + 1);
const existing = cur[parentIndex + 1];
// Same child already there → keep it (no flicker, preserves any state).
if (existing && existing.kind === child.kind && existing.id === child.id) {
return cur.slice(0, parentIndex + 2);
}
return [...head, child];
});
}, []);
const closeFrom = useCallback((index: number) => {
setColumns(cur => cur.slice(0, Math.max(0, index)));
}, []);
const setStack = useCallback((next: Column[]) => {
setColumns(next);
}, []);
const widthFor = useCallback(
(c: Column) => widths[columnKey(c)] ?? PANE_DEFAULT_WIDTH,
[widths]
);
const setWidth = useCallback((c: Column, width: number) => {
setWidths(prev => ({ ...prev, [columnKey(c)]: clamp(width) }));
}, []);
const value = useMemo<OpenPanesValue>(
() => ({
columns,
isSectionOpen,
toggleSection,
openSection,
pushFrom,
closeFrom,
setStack,
widthFor,
setWidth,
}),
[columns, isSectionOpen, toggleSection, openSection, pushFrom, closeFrom, setStack, widthFor, setWidth]
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useOpenPanes(): OpenPanesValue {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useOpenPanes must be inside <OpenPanesProvider>");
return ctx;
}
function isSectionPaneId(x: unknown): x is SectionPaneId {
return typeof x === "string" && x in SECTION_TITLES;
}
function migrateLegacyId(id: string): string {
if (id === "taxonomy" || id === "glossary") return "concepts";
return id;
}
function clamp(w: number): number {
return Math.max(PANE_MIN_WIDTH, Math.min(PANE_MAX_WIDTH, Math.round(w)));
}

View File

@@ -0,0 +1,3 @@
// Small shared types used across editor surfaces.
export type Density = "comfortable" | "compact";

View File

@@ -0,0 +1,182 @@
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"tagline" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "NarrativeDocument" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectId" TEXT NOT NULL,
"doc" TEXT NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "NarrativeDocument_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "TaxonomyTerm" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectId" TEXT NOT NULL,
"parentId" TEXT,
"label" TEXT NOT NULL,
"definition" TEXT,
"synonyms" TEXT NOT NULL DEFAULT '[]',
"linkedBlockId" TEXT,
"modelVersion" INTEGER NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TaxonomyTerm_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "TaxonomyTerm_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "TaxonomyTerm" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "RequirementEntry" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectId" TEXT NOT NULL,
"tag" TEXT NOT NULL,
"text" TEXT NOT NULL,
"tracedToIds" TEXT NOT NULL DEFAULT '[]',
"unsupported" BOOLEAN NOT NULL DEFAULT false,
"modelVersion" INTEGER NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RequirementEntry_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "AnalysisRun" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectId" TEXT NOT NULL,
"section" TEXT NOT NULL,
"status" TEXT NOT NULL,
"startedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"finishedAt" DATETIME,
"modelVersion" INTEGER NOT NULL,
"inputTokens" INTEGER,
"outputTokens" INTEGER,
"errorMessage" TEXT,
CONSTRAINT "AnalysisRun_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "ModelSnapshot" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectId" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"json" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ModelSnapshot_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "ChangelogEntry" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectId" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"ops" TEXT NOT NULL,
"reason" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ChangelogEntry_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "SocratesThread" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectId" TEXT NOT NULL,
"anchorElementId" TEXT,
"status" TEXT NOT NULL DEFAULT 'open',
"title" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "SocratesThread_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "SocratesMessage" (
"id" TEXT NOT NULL PRIMARY KEY,
"threadId" TEXT NOT NULL,
"role" TEXT NOT NULL,
"content" TEXT NOT NULL,
"provider" TEXT,
"model" TEXT,
"inputTokens" INTEGER,
"outputTokens" INTEGER,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SocratesMessage_threadId_fkey" FOREIGN KEY ("threadId") REFERENCES "SocratesThread" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Finding" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"text" TEXT NOT NULL,
"linkedElementIds" TEXT NOT NULL,
"confidence" REAL NOT NULL,
"severity" TEXT,
"validationCode" TEXT,
"status" TEXT NOT NULL DEFAULT 'open',
"modelVersion" INTEGER NOT NULL,
"provider" TEXT,
"llmModel" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Finding_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "ResearchFinding" (
"id" TEXT NOT NULL PRIMARY KEY,
"findingId" TEXT NOT NULL,
"query" TEXT NOT NULL,
"url" TEXT NOT NULL,
"title" TEXT,
"snippet" TEXT,
"stance" TEXT NOT NULL DEFAULT 'neutral',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ResearchFinding_findingId_fkey" FOREIGN KEY ("findingId") REFERENCES "Finding" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "NarrativeDocument_projectId_key" ON "NarrativeDocument"("projectId");
-- CreateIndex
CREATE INDEX "TaxonomyTerm_projectId_idx" ON "TaxonomyTerm"("projectId");
-- CreateIndex
CREATE INDEX "TaxonomyTerm_projectId_label_idx" ON "TaxonomyTerm"("projectId", "label");
-- CreateIndex
CREATE INDEX "RequirementEntry_projectId_idx" ON "RequirementEntry"("projectId");
-- CreateIndex
CREATE INDEX "AnalysisRun_projectId_section_idx" ON "AnalysisRun"("projectId", "section");
-- CreateIndex
CREATE INDEX "AnalysisRun_projectId_startedAt_idx" ON "AnalysisRun"("projectId", "startedAt");
-- CreateIndex
CREATE INDEX "ModelSnapshot_projectId_version_idx" ON "ModelSnapshot"("projectId", "version");
-- CreateIndex
CREATE UNIQUE INDEX "ModelSnapshot_projectId_version_key" ON "ModelSnapshot"("projectId", "version");
-- CreateIndex
CREATE INDEX "ChangelogEntry_projectId_version_idx" ON "ChangelogEntry"("projectId", "version");
-- CreateIndex
CREATE INDEX "SocratesThread_projectId_idx" ON "SocratesThread"("projectId");
-- CreateIndex
CREATE INDEX "SocratesMessage_threadId_createdAt_idx" ON "SocratesMessage"("threadId", "createdAt");
-- CreateIndex
CREATE INDEX "Finding_projectId_status_idx" ON "Finding"("projectId", "status");
-- CreateIndex
CREATE INDEX "Finding_projectId_kind_idx" ON "Finding"("projectId", "kind");
-- CreateIndex
CREATE INDEX "ResearchFinding_findingId_idx" ON "ResearchFinding"("findingId");

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "RequirementEntry" ADD COLUMN "linkedTermId" TEXT;

View File

@@ -0,0 +1,26 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_TaxonomyTerm" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectId" TEXT NOT NULL,
"parentId" TEXT,
"label" TEXT NOT NULL,
"definition" TEXT,
"synonyms" TEXT NOT NULL DEFAULT '[]',
"linkedBlockId" TEXT,
"modelVersion" INTEGER NOT NULL,
"status" TEXT NOT NULL DEFAULT 'accepted',
"pinned" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TaxonomyTerm_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "TaxonomyTerm_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "TaxonomyTerm" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_TaxonomyTerm" ("createdAt", "definition", "id", "label", "linkedBlockId", "modelVersion", "parentId", "projectId", "synonyms") SELECT "createdAt", "definition", "id", "label", "linkedBlockId", "modelVersion", "parentId", "projectId", "synonyms" FROM "TaxonomyTerm";
DROP TABLE "TaxonomyTerm";
ALTER TABLE "new_TaxonomyTerm" RENAME TO "TaxonomyTerm";
CREATE INDEX "TaxonomyTerm_projectId_idx" ON "TaxonomyTerm"("projectId");
CREATE INDEX "TaxonomyTerm_projectId_label_idx" ON "TaxonomyTerm"("projectId", "label");
CREATE INDEX "TaxonomyTerm_projectId_status_idx" ON "TaxonomyTerm"("projectId", "status");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,48 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Finding" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"text" TEXT NOT NULL,
"linkedElementIds" TEXT NOT NULL,
"confidence" REAL NOT NULL,
"severity" TEXT,
"validationCode" TEXT,
"status" TEXT NOT NULL DEFAULT 'suggested',
"pinned" BOOLEAN NOT NULL DEFAULT false,
"suggestionKey" TEXT,
"modelVersion" INTEGER NOT NULL,
"provider" TEXT,
"llmModel" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Finding_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Finding" ("confidence", "createdAt", "id", "kind", "linkedElementIds", "llmModel", "modelVersion", "projectId", "provider", "severity", "status", "text", "validationCode") SELECT "confidence", "createdAt", "id", "kind", "linkedElementIds", "llmModel", "modelVersion", "projectId", "provider", "severity", "status", "text", "validationCode" FROM "Finding";
DROP TABLE "Finding";
ALTER TABLE "new_Finding" RENAME TO "Finding";
CREATE INDEX "Finding_projectId_status_idx" ON "Finding"("projectId", "status");
CREATE INDEX "Finding_projectId_kind_idx" ON "Finding"("projectId", "kind");
CREATE INDEX "Finding_projectId_suggestionKey_idx" ON "Finding"("projectId", "suggestionKey");
CREATE TABLE "new_RequirementEntry" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectId" TEXT NOT NULL,
"tag" TEXT NOT NULL,
"text" TEXT NOT NULL,
"tracedToIds" TEXT NOT NULL DEFAULT '[]',
"unsupported" BOOLEAN NOT NULL DEFAULT false,
"modelVersion" INTEGER NOT NULL,
"linkedTermId" TEXT,
"status" TEXT NOT NULL DEFAULT 'accepted',
"pinned" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RequirementEntry_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_RequirementEntry" ("createdAt", "id", "linkedTermId", "modelVersion", "projectId", "tag", "text", "tracedToIds", "unsupported") SELECT "createdAt", "id", "linkedTermId", "modelVersion", "projectId", "tag", "text", "tracedToIds", "unsupported" FROM "RequirementEntry";
DROP TABLE "RequirementEntry";
ALTER TABLE "new_RequirementEntry" RENAME TO "RequirementEntry";
CREATE INDEX "RequirementEntry_projectId_idx" ON "RequirementEntry"("projectId");
CREATE INDEX "RequirementEntry_projectId_status_idx" ON "RequirementEntry"("projectId", "status");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,27 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_TaxonomyTerm" (
"id" TEXT NOT NULL PRIMARY KEY,
"projectId" TEXT NOT NULL,
"parentId" TEXT,
"label" TEXT NOT NULL,
"definition" TEXT,
"synonyms" TEXT NOT NULL DEFAULT '[]',
"linkedBlockId" TEXT,
"modelVersion" INTEGER NOT NULL,
"status" TEXT NOT NULL DEFAULT 'accepted',
"pinned" BOOLEAN NOT NULL DEFAULT false,
"definitionPinned" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TaxonomyTerm_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "TaxonomyTerm_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "TaxonomyTerm" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_TaxonomyTerm" ("createdAt", "definition", "id", "label", "linkedBlockId", "modelVersion", "parentId", "pinned", "projectId", "status", "synonyms") SELECT "createdAt", "definition", "id", "label", "linkedBlockId", "modelVersion", "parentId", "pinned", "projectId", "status", "synonyms" FROM "TaxonomyTerm";
DROP TABLE "TaxonomyTerm";
ALTER TABLE "new_TaxonomyTerm" RENAME TO "TaxonomyTerm";
CREATE INDEX "TaxonomyTerm_projectId_idx" ON "TaxonomyTerm"("projectId");
CREATE INDEX "TaxonomyTerm_projectId_label_idx" ON "TaxonomyTerm"("projectId", "label");
CREATE INDEX "TaxonomyTerm_projectId_status_idx" ON "TaxonomyTerm"("projectId", "status");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

View File

@@ -27,6 +27,112 @@ model Project {
changes ChangelogEntry[] changes ChangelogEntry[]
threads SocratesThread[] threads SocratesThread[]
findings Finding[] findings Finding[]
document NarrativeDocument?
taxonomy TaxonomyTerm[]
reqs RequirementEntry[]
runs AnalysisRun[]
}
/// Canonical prose. After the pivot, the narrative is the source of truth;
/// taxonomy / glossary / model / findings are projections produced by the
/// Analyze pipeline.
model NarrativeDocument {
id String @id @default(cuid())
projectId String @unique
doc String // ProseMirror JSON
version Int @default(1)
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
}
/// Taxonomy term (concept / entity) extracted by the analyze-taxonomy pass.
/// Optionally linked to a SysML block, which surfaces the "linked" indicator
/// in the sidebar and powers the click-to-jump action.
model TaxonomyTerm {
id String @id @default(cuid())
projectId String
parentId String?
label String
definition String?
/// JSON-encoded string[] of synonym surface forms.
synonyms String @default("[]")
/// SysML block id this term is currently linked to (if any).
linkedBlockId String?
/// Model version when this term was last refreshed.
modelVersion Int
/// Review state. `accepted` (default — confirmed term, surfaces normally),
/// `suggested` (analyzer added in last run, awaits user keep/discard),
/// `deprecated` (analyzer didn't see in last run, awaits user keep/discard).
status String @default("accepted")
/// User explicitly chose to keep this term despite the analyzer not seeing
/// it. Future merges leave it alone instead of re-marking it deprecated.
pinned Boolean @default(false)
/// User authored or edited the definition by hand. When true, future
/// Analyze runs will NOT replace the definition — the LLM's emit is
/// dropped on the floor. Reset by writing an empty string back through
/// setTermDefinition (which clears the pin).
definitionPinned Boolean @default(false)
createdAt DateTime @default(now())
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
parent TaxonomyTerm? @relation("TaxonomyTree", fields: [parentId], references: [id], onDelete: SetNull)
children TaxonomyTerm[] @relation("TaxonomyTree")
@@index([projectId])
@@index([projectId, label])
@@index([projectId, status])
}
/// Requirement extracted from prose. Traceability is a JSON list of block ids
/// that the analyzer believes fulfill the requirement.
model RequirementEntry {
id String @id @default(cuid())
projectId String
tag String
text String
/// JSON-encoded string[] of block ids.
tracedToIds String @default("[]")
unsupported Boolean @default(false)
modelVersion Int
/// Term this requirement is conceptually about (links REQ → concept).
/// Null when the requirement isn't anchored to a specific term.
linkedTermId String?
/// Review state. Same triple as TaxonomyTerm — `accepted` (default,
/// surfaces normally), `suggested` (analyzer added in last run), or
/// `deprecated` (analyzer didn't see in last run). Pending statuses
/// await user keep/discard.
status String @default("accepted")
/// User explicitly preserved → don't re-deprecate next merge.
pinned Boolean @default(false)
createdAt DateTime @default(now())
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@index([projectId])
@@index([projectId, status])
}
/// One row per Analyze invocation per section. Powers last-run timestamps
/// and progress indicators in the sidebar.
model AnalysisRun {
id String @id @default(cuid())
projectId String
/// "taxonomy" | "glossary" | "model" | "requirements"
/// | "assumptions" | "risks" | "inconsistencies" | "all"
section String
/// "running" | "succeeded" | "failed"
status String
startedAt DateTime @default(now())
finishedAt DateTime?
modelVersion Int
inputTokens Int?
outputTokens Int?
errorMessage String?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@index([projectId, section])
@@index([projectId, startedAt])
} }
model ModelSnapshot { model ModelSnapshot {
@@ -106,8 +212,23 @@ model Finding {
severity String? severity String?
/// Inconsistencies only: optional structural-rule code (S1, M2, T1, …) /// Inconsistencies only: optional structural-rule code (S1, M2, T1, …)
validationCode String? validationCode String?
/// Lifecycle: "open" | "dismissed" | "resolved" /// Lifecycle (review-aware):
status String @default("open") /// "suggested" — new from latest run, awaits user keep/discard
/// "accepted" — user has explicitly kept (or carried forward); the
/// normal "open" surfaces in the pane
/// "deprecated"— was accepted, analyzer didn't see in latest run
/// "dismissed" — user said never (don't resurface)
/// "resolved" — user marked done
/// Pre-existing rows from before merge-with-review used "open"; we
/// continue to accept that value as a synonym for "accepted" for one
/// release.
status String @default("suggested")
/// User explicitly preserved → don't re-deprecate next merge.
pinned Boolean @default(false)
/// Stable identity key for cross-run matching. For LLM findings:
/// `<kind>:<normalized-text>`; for cross-validation: `<validationCode>:<elementId>`.
/// Null on legacy rows.
suggestionKey String?
/// Model version this finding was detected against. /// Model version this finding was detected against.
modelVersion Int modelVersion Int
/// Provider + model that emitted the finding. /// Provider + model that emitted the finding.
@@ -120,6 +241,7 @@ model Finding {
@@index([projectId, status]) @@index([projectId, status])
@@index([projectId, kind]) @@index([projectId, kind])
@@index([projectId, suggestionKey])
} }
/// Web research results from validating a finding via Tavily / similar. /// Web research results from validating a finding via Tavily / similar.

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,37 @@
--font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, Menlo, monospace; --font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, Menlo, monospace;
--tracking: 0; --tracking: 0;
/* ─── Type scale ────────────────────────────────────────────────────
* Five sizes. Use one of these everywhere; resist adding 0.5px nudges.
* --text-xs chips, badges, eyebrows, kind labels, mono meta
* --text-sm secondary UI text, hints, captions, drag/help text
* --text-md primary UI text, list rows, definitions (DEFAULT)
* --text-lg pane titles, prominent labels, strong row titles
* --text-xl detail-column titles
* Plus three line-heights: tight (titles), ui (rows), prose (definitions). */
--text-xs: 10.5px;
--text-sm: 11.5px;
--text-md: 12.5px;
--text-lg: 13.5px;
--text-xl: 16px;
--lh-tight: 1.2;
--lh-ui: 1.4;
--lh-prose: 1.5;
/* ─── Spacing scale ─────────────────────────────────────────────────
* 4-px base. Pick one of these for every gap / padding decision. */
--space-1: 3px;
--space-2: 5px;
--space-3: 7px;
--space-4: 10px;
--space-5: 14px;
--space-6: 20px;
/* ─── Radius ─────────────────────────────────────────────────────── */
--radius-sm: 3px;
--radius-md: 4px;
--radius-lg: 6px;
--bg: #f5efe2; /* parchment */ --bg: #f5efe2; /* parchment */
--surface: #faf5e9; --surface: #faf5e9;
--surface-2: #ede4cf; --surface-2: #ede4cf;