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,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 { useChipFocus } from "./FocusContext";
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";
const KIND_LABEL: Record<ChipKind, string> = {
@@ -42,6 +44,8 @@ export function ChipView({ node, selected, editor }: NodeViewProps) {
const { focusBlockId, setFocusBlockId } = useChipFocus();
const { model, apply } = useModelStore();
const { getTerm } = useAnalysis();
const { setStack } = useOpenPanes();
// 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).
@@ -88,7 +92,18 @@ export function ChipView({ node, selected, editor }: NodeViewProps) {
e.preventDefault();
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.
// 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";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { ChipNode } from "./ChipNode";
import { SlashExtension } from "./SlashExtension";
import { ChipFocusContext } from "./FocusContext";
import { fixtureToDoc } from "./fixtureToDoc";
import type { Density } from "../socrates/SocratesDock";
import { ChipSuggestionExtension } from "./ChipSuggestionExtension";
import type { FixtureData } from "../../lib/fixtures/aristotle";
import type { MarkupStyle } from "./Chip";
import { useAnalysis } from "../../lib/workspace/analysisStore";
export type Density = "comfortable" | "compact";
interface TextCanvasProps {
data: FixtureData;
density: Density;
markupStyle: MarkupStyle;
focusBlockId: string | null;
setFocusBlockId: (id: string | null) => void;
projectId: string;
density?: Density;
markupStyle?: MarkupStyle;
}
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 padX = density === "compact" ? 22 : 36;
const focusValue = useMemo(
() => ({ focusBlockId, setFocusBlockId }),
[focusBlockId, setFocusBlockId]
);
const [focusBlockId, setFocusBlockId] = useState<string | null>(null);
const [initialDoc, setInitialDoc] = useState<unknown | null>(null);
const [loaded, setLoaded] = useState(false);
const focusValue = useMemo(() => ({ focusBlockId, setFocusBlockId }), [focusBlockId, setFocusBlockId]);
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2] },
// Drop features we don't need yet
codeBlock: false,
blockquote: false,
horizontalRule: false,
bulletList: false,
orderedList: false,
listItem: false,
strike: false,
code: false,
link: false,
}),
ChipNode,
SlashExtension,
],
content: fixtureToDoc(data),
editorProps: {
attributes: {
class: "text-canvas tiptap",
style: `padding: ${padY}px ${padX}px;`,
const { terms } = useAnalysis();
// Load the saved doc once on mount; fall back to the fixture if there is none.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/document`);
if (!res.ok) throw new Error(`load doc: ${res.status}`);
const body = (await res.json()) as { doc: unknown };
if (cancelled) return;
setInitialDoc(body.doc ?? fixtureToDoc(data));
} catch {
if (!cancelled) setInitialDoc(fixtureToDoc(data));
} finally {
if (!cancelled) setLoaded(true);
}
})();
return () => {
cancelled = true;
};
}, [projectId, data]);
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(() => {
if (!editor) return;
(editor.storage as unknown as Record<string, unknown>).markupStyle = markupStyle;
// Force a re-render of all chip node views so they pick up the new style.
const storage = editor.storage as unknown as Record<string, unknown>;
storage.markupStyle = markupStyle;
storage.terms = terms;
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 (
<div className="text-canvas" style={{ padding: `${padY}px ${padX}px` }}>
<div style={{ color: "var(--muted)", fontFamily: "var(--font-mono)", fontSize: 12 }}>
loading editor
</div>
<div style={{ color: "var(--muted)", fontFamily: "var(--font-mono)", fontSize: 12 }}>loading editor</div>
</div>
);
}
@@ -80,15 +131,6 @@ export function TextCanvas({ data, density, markupStyle, focusBlockId, setFocusB
return (
<ChipFocusContext.Provider value={focusValue}>
<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>
);
}

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;
},
};
},
};