Files
Socrates/apps/web/components/editor/MainWorkspace.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

178 lines
5.6 KiB
TypeScript

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