diff --git a/apps/web/app/api/projects/[projectId]/analyze/route.ts b/apps/web/app/api/projects/[projectId]/analyze/route.ts new file mode 100644 index 0000000..90ffe38 --- /dev/null +++ b/apps/web/app/api/projects/[projectId]/analyze/route.ts @@ -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 }); + } +} diff --git a/apps/web/app/api/projects/[projectId]/document/route.ts b/apps/web/app/api/projects/[projectId]/document/route.ts new file mode 100644 index 0000000..e4fae83 --- /dev/null +++ b/apps/web/app/api/projects/[projectId]/document/route.ts @@ -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 }); +} diff --git a/apps/web/app/api/projects/[projectId]/findings/[findingId]/decision/route.ts b/apps/web/app/api/projects/[projectId]/findings/[findingId]/decision/route.ts new file mode 100644 index 0000000..aac3244 --- /dev/null +++ b/apps/web/app/api/projects/[projectId]/findings/[findingId]/decision/route.ts @@ -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 }); + } +} diff --git a/apps/web/app/api/projects/[projectId]/findings/[findingId]/socrates/route.ts b/apps/web/app/api/projects/[projectId]/findings/[findingId]/socrates/route.ts new file mode 100644 index 0000000..6fd0882 --- /dev/null +++ b/apps/web/app/api/projects/[projectId]/findings/[findingId]/socrates/route.ts @@ -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 }); +} diff --git a/apps/web/app/api/projects/[projectId]/findings/route.ts b/apps/web/app/api/projects/[projectId]/findings/route.ts index 57c641e..a9ebd3b 100644 --- a/apps/web/app/api/projects/[projectId]/findings/route.ts +++ b/apps/web/app/api/projects/[projectId]/findings/route.ts @@ -1,27 +1,51 @@ -// GET /api/projects/[id]/findings — returns the project's open findings -// POST /api/projects/[id]/findings — runs detection, persists, returns the new set +// GET /api/projects/[id]/findings — returns the project's currently visible +// 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 { loadProject, listOpenFindings, replaceFindings } from "../../../../../lib/db/repo"; +import { + loadProject, + listVisibleFindings, + listDiscardedFindings, + mergeFindingsSuggestion, +} from "../../../../../lib/db/repo"; import { detectFindings } from "../../../../../lib/llm/detect"; -export async function GET(_req: Request, { params }: { params: Promise<{ projectId: string }> }) { +function toApi(f: Awaited>[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 findings = await listOpenFindings(projectId); - return NextResponse.json({ - findings: findings.map(f => ({ - 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, - })), - }); + // ?include=all merges in dismissed + resolved so the FindingsPane Discarded + // drawer can render them. Default stays visible-only for back-compat. + const url = new URL(req.url); + const include = (url.searchParams.get("include") ?? "").toLowerCase(); + if (include === "all") { + const [visible, discarded] = await Promise.all([ + listVisibleFindings(projectId), + listDiscardedFindings(projectId), + ]); + return NextResponse.json({ findings: [...visible, ...discarded].map(toApi) }); + } + const findings = await listVisibleFindings(projectId); + return NextResponse.json({ findings: findings.map(toApi) }); } 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 { const { model, version } = await loadProject(projectId); 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({ - findings: stored.map(f => ({ - 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, - })), + findings: stored.map(toApi), meta: { provider: result.provider, model: result.model, diff --git a/apps/web/app/api/projects/[projectId]/requirements/[reqId]/decision/route.ts b/apps/web/app/api/projects/[projectId]/requirements/[reqId]/decision/route.ts new file mode 100644 index 0000000..b0744df --- /dev/null +++ b/apps/web/app/api/projects/[projectId]/requirements/[reqId]/decision/route.ts @@ -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 }); + } +} diff --git a/apps/web/app/api/projects/[projectId]/requirements/route.ts b/apps/web/app/api/projects/[projectId]/requirements/route.ts new file mode 100644 index 0000000..8cd0c7d --- /dev/null +++ b/apps/web/app/api/projects/[projectId]/requirements/route.ts @@ -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 }); +} diff --git a/apps/web/app/api/projects/[projectId]/runs/route.ts b/apps/web/app/api/projects/[projectId]/runs/route.ts new file mode 100644 index 0000000..ea44a0a --- /dev/null +++ b/apps/web/app/api/projects/[projectId]/runs/route.ts @@ -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 }); +} diff --git a/apps/web/app/api/projects/[projectId]/taxonomy/route.ts b/apps/web/app/api/projects/[projectId]/taxonomy/route.ts new file mode 100644 index 0000000..5d0bb73 --- /dev/null +++ b/apps/web/app/api/projects/[projectId]/taxonomy/route.ts @@ -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 }); +} diff --git a/apps/web/app/api/projects/[projectId]/term-link/route.ts b/apps/web/app/api/projects/[projectId]/term-link/route.ts new file mode 100644 index 0000000..c2dba0c --- /dev/null +++ b/apps/web/app/api/projects/[projectId]/term-link/route.ts @@ -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 }); +} diff --git a/apps/web/app/api/projects/[projectId]/terms/[termId]/decision/route.ts b/apps/web/app/api/projects/[projectId]/terms/[termId]/decision/route.ts new file mode 100644 index 0000000..ac38958 --- /dev/null +++ b/apps/web/app/api/projects/[projectId]/terms/[termId]/decision/route.ts @@ -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 }); + } +} diff --git a/apps/web/app/api/projects/[projectId]/terms/[termId]/definition/route.ts b/apps/web/app/api/projects/[projectId]/terms/[termId]/definition/route.ts new file mode 100644 index 0000000..18e11e4 --- /dev/null +++ b/apps/web/app/api/projects/[projectId]/terms/[termId]/definition/route.ts @@ -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 }); + } +} diff --git a/apps/web/app/api/projects/route.ts b/apps/web/app/api/projects/route.ts new file mode 100644 index 0000000..d7b5096 --- /dev/null +++ b/apps/web/app/api/projects/route.ts @@ -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, + })), + }); +} diff --git a/apps/web/app/api/seed/finalize/route.ts b/apps/web/app/api/seed/finalize/route.ts new file mode 100644 index 0000000..1f8a75e --- /dev/null +++ b/apps/web/app/api/seed/finalize/route.ts @@ -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 }); + } +} diff --git a/apps/web/app/api/seed/turn/route.ts b/apps/web/app/api/seed/turn/route.ts new file mode 100644 index 0000000..4f0e9b4 --- /dev/null +++ b/apps/web/app/api/seed/turn/route.ts @@ -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 }); + } +} diff --git a/apps/web/app/editor/[projectId]/page.tsx b/apps/web/app/editor/[projectId]/page.tsx index b580ef5..f979ada 100644 --- a/apps/web/app/editor/[projectId]/page.tsx +++ b/apps/web/app/editor/[projectId]/page.tsx @@ -1,10 +1,67 @@ +import { notFound } from "next/navigation"; +import { prisma } from "../../../lib/db/client"; import { EditorShell } from "../../../components/editor/EditorShell"; import { aristotleFixture } from "../../../lib/fixtures/aristotle"; +import { loadProject, ensureAristotleSeeded, ProjectNotFoundError } from "../../../lib/db/repo"; -// M1: every projectId resolves to the Aristotle fixture. -// M4–M5 wire this to a real database lookup. -// (EditorShell wraps itself in Suspense for useSearchParams; no boundary -// needed at this level.) -export default async function EditorPage(_props: { params: Promise<{ projectId: string }> }) { - return ; +// Server component: loads the latest model + project metadata. Auto-seeds the +// Aristotle demo for the literal "aristotle" id; any other unknown id 404s. +export default async function EditorPage(props: { params: Promise<{ projectId: string }> }) { + const { projectId } = await props.params; + + 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 ( + + ); } diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 491bc37..1e2fae3 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -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 (

@@ -9,42 +14,108 @@ export default function HomePage() {

Structured thinking and validation platform for product managers.

-
    -
  • - - → Open editor (Aristotle fixture) - -
  • -
  • - - → Seed screen (coming next) - -
  • -
+ +
+ + → Start a new idea + +
+ +
+

+ {projects.length === 0 ? "no projects yet" : `${projects.length} project${projects.length === 1 ? "" : "s"}`} +

+ + {projects.length === 0 ? ( +

+ Click "Start a new idea" above to begin a Socratic interview that bootstraps a fresh project. +

+ ) : ( +
    + {projects.map(p => ( +
  • + +
    + {p.name} + + {p.scope} · v{p.latestVersion} + + + {timeAgo(new Date(p.updatedAt))} + +
    + {p.tagline && ( +
    + {p.tagline} +
    + )} + +
  • + ))} +
+ )} +

); } + +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`; +} diff --git a/apps/web/components/diagram-canvas/DiagramCanvas.tsx b/apps/web/components/diagram-canvas/DiagramCanvas.tsx index ada9e13..e435eef 100644 --- a/apps/web/components/diagram-canvas/DiagramCanvas.tsx +++ b/apps/web/components/diagram-canvas/DiagramCanvas.tsx @@ -56,7 +56,7 @@ import { type ModelOp, } from "../../lib/sync/ops"; 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 { Block, Property, PropertyType, SysMLModel } from "../../lib/sysml/model"; @@ -69,6 +69,8 @@ interface DiagramCanvasProps { focusBlockId: string | null; onSelect?: (id: string | null) => void; issuesByElement?: Map; + /** When set, term-drop on the canvas links the chosen term server-side. */ + projectId?: string; } 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 apply = useApply(); const wrapperRef = useRef(null); @@ -130,14 +132,29 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram const dataChanged = existing.data?.label !== b.label || existing.data?.kind !== b.kind || - !sameStringArray(existing.data?.properties ?? [], propNames); + !sameStringArray(existing.data?.properties ?? [], propNames) || + existing.data?.reviewStatus !== b.reviewStatus; 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 { next.push(existing); } } 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) { @@ -146,14 +163,30 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram const expr = c.expression || "{ }"; const dataChanged = existing.data?.label !== c.label || - existing.data?.expression !== expr; + existing.data?.expression !== expr || + existing.data?.reviewStatus !== c.reviewStatus; 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 { next.push(existing); } } else { - next.push({ ...makeNodeForConstraint(c), position: placeNew() }); + const created = makeNodeForConstraint(c); + next.push({ + ...created, + position: placeNew(), + data: { ...created.data, reviewStatus: c.reviewStatus }, + }); } } return next; @@ -295,9 +328,47 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram const onDrop = useCallback( (event: React.DragEvent) => { 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"); if (!kindRaw) return; - const position = screenToFlowPosition({ x: event.clientX, y: event.clientY }); if (kindRaw === "constraint") { const cid = tempId("c"); @@ -332,7 +403,7 @@ function DiagramInner({ data, focusBlockId, onSelect, issuesByElement }: Diagram onSelect?.(final); } }, - [apply, onSelect, screenToFlowPosition, setNodes] + [apply, onSelect, screenToFlowPosition, setNodes, projectId] ); function patchSelected(patch: Partial) { diff --git a/apps/web/components/diagram-canvas/nodes/BlockNode.tsx b/apps/web/components/diagram-canvas/nodes/BlockNode.tsx index 7e21960..90a46fb 100644 --- a/apps/web/components/diagram-canvas/nodes/BlockNode.tsx +++ b/apps/web/components/diagram-canvas/nodes/BlockNode.tsx @@ -14,6 +14,9 @@ export interface BlockNodeData extends Record { expression?: string; /** Highest severity of any validation issue anchored to this block. */ 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 = { @@ -33,6 +36,7 @@ export function BlockNode({ data, selected }: NodeProps) { `sysml-node-${kind}`, selected ? "sysml-node-selected" : "", d.issueSeverity ? `sysml-node-issue-${d.issueSeverity}` : "", + d.reviewStatus && d.reviewStatus !== "accepted" ? `sysml-node-review-${d.reviewStatus}` : "", ] .filter(Boolean) .join(" "); diff --git a/apps/web/components/editor/CanvasHeader.tsx b/apps/web/components/editor/CanvasHeader.tsx deleted file mode 100644 index 6217d28..0000000 --- a/apps/web/components/editor/CanvasHeader.tsx +++ /dev/null @@ -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 ( -
-
-
{title}
-
{subtitle}
-
-
{right}
-
- ); -} diff --git a/apps/web/components/editor/EditorShell.tsx b/apps/web/components/editor/EditorShell.tsx index b8afeec..a350468 100644 --- a/apps/web/components/editor/EditorShell.tsx +++ b/apps/web/components/editor/EditorShell.tsx @@ -1,38 +1,36 @@ -// The dual-canvas workspace shell. -// M5: state lives in ModelStoreProvider; both canvases consume the canonical -// SysMLModel and emit ModelOps back through useApply(). +// The pivoted workspace shell. +// +// 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"; -import { useMemo, useState, Suspense } from "react"; +import { useMemo, Suspense } from "react"; import { useSearchParams } from "next/navigation"; import { TopBar } from "./TopBar"; -import { LeftRail } from "./LeftRail"; -import { CanvasHeader } from "./CanvasHeader"; +import { LeftSidebar } from "./LeftSidebar"; import { StatusBar } from "./StatusBar"; -import { IssuesPanel } from "./IssuesPanel"; -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 { MainWorkspace } from "./MainWorkspace"; import type { FixtureData } from "../../lib/fixtures/aristotle"; import { fromFixture } from "../../lib/sysml/fromFixture"; 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 { 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). */ initialModel?: import("../../lib/sysml/model").SysMLModel; initialVersion?: number; - /** When set, apply() POSTs to /api/projects/[projectId]/apply. */ projectId?: string; density?: Density; - markupStyle?: MarkupStyle; - diagramStyle?: DiagramVariant; - presence?: SocratesPresence; } export function EditorShell(props: EditorShellProps) { @@ -43,16 +41,7 @@ export function EditorShell(props: EditorShellProps) { ); } -function EditorShellInner({ - data, - initialModel, - initialVersion, - projectId, - density = "comfortable", - markupStyle = "color", - diagramStyle = "softened", - presence = "default", -}: EditorShellProps) { +function EditorShellInner({ data, initialModel, initialVersion, projectId, density = "comfortable" }: EditorShellProps) { const searchParams = useSearchParams(); const breaks = useMemo(() => { @@ -61,112 +50,29 @@ function EditorShellInner({ return raw.split(",").map(s => s.trim()).filter((s): s is BreakName => s in BREAKS); }, [searchParams]); - // Prefer server-loaded model; fall back to fixture derivation for legacy - // callers without DB persistence wiring. const startingModel = useMemo(() => { const base = initialModel ?? fromFixture(data); return breaks.length > 0 ? applyBreaks(base, breaks) : base; }, [initialModel, data, breaks]); + const id = projectId ?? "aristotle"; + return ( - - + + + + +
+ +
+ + +
+ +
+
+
+
); } - -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(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 ( -
- - -
- - - -
-
- -
- -
-
- -
- -
- - Fit - 100% - Layout -
- } - /> -
- -
- -
-
- - - - setFocusBlockId(id)} /> - setFocusBlockId(id)} - /> -
- ); -} diff --git a/apps/web/components/editor/FindingsPanel.tsx b/apps/web/components/editor/FindingsPanel.tsx deleted file mode 100644 index e178dae..0000000 --- a/apps/web/components/editor/FindingsPanel.tsx +++ /dev/null @@ -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 = { - assumption: "●", - risk: "▲", - inconsistency: "!", -}; - -const KIND_LABEL: Record = { - assumption: "asm", - risk: "risk", - inconsistency: "inc", -}; - -export function FindingsPanel({ projectId, modelVersion, onSelectAnchor }: FindingsPanelProps) { - const [findings, setFindings] = useState(null); - const [collapsed, setCollapsed] = useState(false); - const [running, setRunning] = useState(false); - const [error, setError] = useState(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 ( -
- - - {!collapsed && ( - <> -
- - {meta?.durationMs && ( - - {meta.provider} · {meta.model?.split("/").pop()} · {(meta.durationMs / 1000).toFixed(1)}s - - )} -
- - {error &&
⚠ {error}
} - - {findings && findings.length > 0 && ( -
    - {(["inconsistency", "risk", "assumption"] as const).flatMap(kind => - findings.filter(f => f.kind === kind).map(f => ( -
  • { - if (f.linkedElementIds.length > 0 && onSelectAnchor) { - onSelectAnchor(f.linkedElementIds[0]!); - } - }} - style={{ cursor: f.linkedElementIds.length > 0 ? "pointer" : "default" }} - > - {KIND_GLYPH[f.kind]} - {KIND_LABEL[f.kind]} - {f.severity && {f.severity}} - {f.validationCode && {f.validationCode}} - {f.text} - {f.confidence.toFixed(2)} - {f.linkedElementIds.length > 0 && ( - - [{f.linkedElementIds.slice(0, 3).join(", ")}{f.linkedElementIds.length > 3 ? "…" : ""}] - - )} -
  • - )) - )} -
- )} - - )} -
- ); -} diff --git a/apps/web/components/editor/IssuesPanel.tsx b/apps/web/components/editor/IssuesPanel.tsx deleted file mode 100644 index bc2fbec..0000000 --- a/apps/web/components/editor/IssuesPanel.tsx +++ /dev/null @@ -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 = { - 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 ( -
- -
- ); - } - - return ( -
- - - {!collapsed && ( -
    - {(["error", "warning", "soft"] as const).flatMap(sev => - grouped[sev].map((i, idx) => ( -
  • onSelectAnchor?.(i.anchor.kind === "property" ? i.anchor.blockId : (i.anchor as { id: string }).id ?? "")} - > - {SEV_GLYPH[i.severity]} - {i.code} - {i.message} -
  • - )) - )} -
- )} -
- ); -} diff --git a/apps/web/components/editor/LeftRail.tsx b/apps/web/components/editor/LeftRail.tsx deleted file mode 100644 index f3c56af..0000000 --- a/apps/web/components/editor/LeftRail.tsx +++ /dev/null @@ -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:`, `assoc:`, …) → issues. */ - issuesByElement?: Map; -} - -function maxSeverityForKey(map: Map | 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 ; -} - -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 ( - - ); - } - - // Combine blocks + constraints in the Model section, sorted by kind for a - // predictable order: system → block → actor → constraint. - const kindRank: Record = { 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 ( - - ); -} diff --git a/apps/web/components/editor/LeftSidebar.tsx b/apps/web/components/editor/LeftSidebar.tsx new file mode 100644 index 0000000..52c8b74 --- /dev/null +++ b/apps/web/components/editor/LeftSidebar.tsx @@ -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 ( + + ); + } + + return ( + + ); +} + +function Group({ label, sections }: { label: string; sections: SectionPaneId[] }) { + return ( +
+
{label}
+
    + {sections.map(s => ( + + ))} +
+
+ ); +} + +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 ( +
  • + + +
  • + ); +} + +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 ""; +} + diff --git a/apps/web/components/editor/MainWorkspace.tsx b/apps/web/components/editor/MainWorkspace.tsx new file mode 100644 index 0000000..dc086a5 --- /dev/null +++ b/apps/web/components/editor/MainWorkspace.tsx @@ -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 ( +
    +
    + {columns.map((col, index) => ( + setWidth(col, w)} + onClose={() => closeFrom(index)} + /> + ))} +
    + +
    + ); +} + +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 ( +
    + +
    +
    + ); +} + +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 ; + case "model": + return ; + case "requirements": + return ; + case "assumptions": + return ; + case "risks": + return ; + case "inconsistencies": + return ; + } + case "term": + return ; + case "block": + return ; + case "association": + return ; + case "constraint": + return ; + case "requirement": + return ; + case "finding": + return ( + + ); + } +} diff --git a/apps/web/components/editor/PaneControls.tsx b/apps/web/components/editor/PaneControls.tsx new file mode 100644 index 0000000..2fb7540 --- /dev/null +++ b/apps/web/components/editor/PaneControls.tsx @@ -0,0 +1,97 @@ +// Shared pane-header controls. Two primitives: +// +// - — segmented control for VIEW MODES that are mutually +// exclusive (Tree | A–Z, Diagram | Summary). Always exactly one active. +// +// - — 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 A–Z. + +"use client"; + +import type { ReactNode } from "react"; + +// ─── PaneViewTabs ──────────────────────────────────────────────────────── + +export interface PaneViewTab { + value: V; + label: ReactNode; + /** Optional tooltip. */ + title?: string; +} + +interface PaneViewTabsProps { + value: V; + onChange: (v: V) => void; + tabs: PaneViewTab[]; + /** ARIA label for the segmented group. */ + ariaLabel?: string; +} + +export function PaneViewTabs({ + value, + onChange, + tabs, + ariaLabel = "View mode", +}: PaneViewTabsProps) { + return ( +
    + {tabs.map(t => ( + + ))} +
    + ); +} + +// ─── 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 ( + + ); +} diff --git a/apps/web/components/editor/PaneDrawer.tsx b/apps/web/components/editor/PaneDrawer.tsx new file mode 100644 index 0000000..a961874 --- /dev/null +++ b/apps/web/components/editor/PaneDrawer.tsx @@ -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 ( +
    +
    + + {rightAction ?
    {rightAction}
    : null} +
    + {open ?
    {children}
    : null} +
    + ); +} diff --git a/apps/web/components/editor/PaneEmpty.tsx b/apps/web/components/editor/PaneEmpty.tsx new file mode 100644 index 0000000..f76a7cb --- /dev/null +++ b/apps/web/components/editor/PaneEmpty.tsx @@ -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 ( +
    + {icon ?
    {icon}
    : null} +
    {title}
    + {hint ?
    {hint}
    : null} + {action ? ( + + ) : null} +
    + ); +} diff --git a/apps/web/components/editor/PaneFrame.tsx b/apps/web/components/editor/PaneFrame.tsx new file mode 100644 index 0000000..1b6998f --- /dev/null +++ b/apps/web/components/editor/PaneFrame.tsx @@ -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 ( +
    +
    +
    + {title} + {subtitle ? {subtitle} : null} +
    +
    + {right} + {closable ? ( + + ) : null} +
    +
    +
    {children}
    +
    + ); +} diff --git a/apps/web/components/editor/PromoteToolbar.tsx b/apps/web/components/editor/PromoteToolbar.tsx new file mode 100644 index 0000000..135a9a1 --- /dev/null +++ b/apps/web/components/editor/PromoteToolbar.tsx @@ -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(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 ( + 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 ( + 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 ( + 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 ( +
    + + + + +
    + ); +} + +// ─── 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 ( +
    { + e.preventDefault(); + if (!canSubmit) return; + onSubmit(from, to, label.trim() || term.label); + }} + > + + + + + setLabel(e.target.value)} + placeholder="verb phrase" + /> + + + + ); +} + +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([]); + const canSubmit = !!label.trim(); + return ( +
    { + e.preventDefault(); + if (!canSubmit) return; + onSubmit(label.trim(), expression.trim(), appliesTo); + }} + > + + + setLabel(e.target.value)} /> + + + setExpression(e.target.value)} + placeholder="e.g. sessions_per_day <= 3" + /> + + + + + ); +} + +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( + term.linkedBlockId ? [term.linkedBlockId] : [] + ); + const canSubmit = !!tag.trim() && !!text.trim(); + return ( +
    { + e.preventDefault(); + if (!canSubmit) return; + onSubmit(tag.trim(), text.trim(), satisfiedBy); + }} + > + + + setTag(e.target.value)} /> + + +