Files
Socrates/apps/web/components/editor/sections/ConceptsPane.tsx
dtoro b55425cc68 Pivot to text-first column-stack workspace + merge-with-review across AI artifacts
Workspace
- Pivot from "set of open panes" to a Finder-style miller column stack:
  TopBar / LeftSidebar / [section → entity → entity ...] / pinned text editor.
  openPanesStore is now an ordered Column[] with pushFrom / closeFrom /
  setStack; only one top-level section is rooted at a time.
- New entity column panes: Term, Block, Association, Constraint,
  Requirement, Finding. Click-through navigation truncates deeper
  columns automatically.
- LeftSidebar surfaces a pending-count chip per section (single-glance
  navigation cue) and spins its analyze ↻ via SVG Spinner whenever the
  LLM is working — including server-initiated runs caught by the runs
  poll, not just user-triggered ones.

Analyze pipeline + persistence
- Unified `concepts` pass (taxonomy + glossary in one LLM call) replaces
  the two-pass setup. Server still accepts ?section=taxonomy|glossary
  and normalizes them for back-compat.
- model / requirements / detection (assumptions, risks, inconsistencies)
  + cross-layer validation rules (X1–X4: stale term link, unlinked
  formalism, undefined linked term, prose-only term).
- Persistence: NarrativeDocument, ModelSnapshot, ChangelogEntry,
  TaxonomyTerm, RequirementEntry, Finding, AnalysisRun. Re-runs MERGE
  instead of replace: gentle update on existing items, suggested on new,
  deprecated on missing — same idiom for every artifact kind. User pins
  preserve "kept" decisions across re-analyses.
- Migrations: pivot_text_first, add_requirement_linked_term,
  term_review_state, review_state_for_reqs_and_findings,
  add_term_definition_pinned.

Concept ↔ ontology integration
- linkedTermId on Block / Association / Constraint / Requirement.
  PromoteToolbar lets the user formalize a concept inline: + Block /
  + Association / + Constraint / + Requirement, all routed through
  applyOps so undo/redo and SSE work for free.
- decideElement op for in-canvas keep/discard on review-pending model
  elements.

User-authored definitions
- TermColumn definition is click-to-edit. Save (Cmd-Enter / blur),
  Cancel (Esc), Reset to AI suggestion when pinned.
- definitionPinned flag on TaxonomyTerm: future Analyze runs leave the
  user's text alone. setTermDefinition repo function + POST
  /api/projects/[id]/terms/[termId]/definition endpoint.
- mergeTaxonomySuggestion + applyGlossaryDefinitions both pin-aware.

UX/UI
- StatusChip: single component for all state idioms (suggested,
  deprecated, accepted, dismissed, resolved, severity, validation code,
  confidence, warn). Replaces 5+ ad-hoc badge classes.
- PaneControls (PaneViewTabs + PaneFilterChip): separates view-mode
  toggles from filter chips so toggling Pending no longer flips you off
  the current view.
- PaneEmpty: unified empty-state with title + hint + action.
- PaneDrawer: collapsible groups for Pending / Discarded review; cards
  group as Kept (top) → Pending (bottom drawer) → Discarded (Findings
  only, hidden when empty). Restore action recovers dismissed/resolved
  findings.
- ConceptCard unifies Tree and A–Z views in Concepts; only Tree parents
  carry the chevron (no empty placeholder offset).
- Type + spacing tokens (--text-xs..xl, --space-1..6, --lh-tight/ui/
  prose, --radius-*) replace every ad-hoc value.
- Buttons standardized to body sans 500 (was a mishmash of mono / display).
- Card shells unified across Concepts / Requirements / Findings.

Cleanup
- Removed: LeftRail, FindingsPanel, IssuesPanel, SocratesDock,
  ProposalCard, SlashMenu, SlashExtension, slashSuggestion,
  CanvasHeader, TaxonomyPane, GlossaryPane, TermDetail (popover; now
  TermColumn).
- Section ids in openPanesStore: dropped taxonomy/glossary, added
  concepts. localStorage migration runs on hydrate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 00:12:06 +02:00

321 lines
9.9 KiB
TypeScript
Raw Permalink Blame History

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