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:
320
apps/web/components/editor/sections/ConceptsPane.tsx
Normal file
320
apps/web/components/editor/sections/ConceptsPane.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
// Concepts pane — unified view of taxonomy + glossary. The two layers share a
|
||||
// single TaxonomyTerm table, so what looked like two panes was always one
|
||||
// dataset rendered two ways. This pane gives the user a Tree/Alphabetical
|
||||
// toggle and a single Analyze action that refreshes both layers.
|
||||
//
|
||||
// Re-running Analyze does NOT replace; it produces a *review*. Each row
|
||||
// carries one of three statuses:
|
||||
// accepted — confirmed, surfaces normally
|
||||
// suggested — analyzer added in last run, awaits keep/discard
|
||||
// deprecated — analyzer didn't see in last run, awaits keep/discard
|
||||
// Pending statuses show a NEW / DEPRECATED badge plus inline Keep / Discard
|
||||
// buttons. A Pending filter narrows the list to just the changes awaiting
|
||||
// review when there are many.
|
||||
//
|
||||
// Each row is also draggable onto the Model canvas (MIME
|
||||
// `application/x-socrata-term`), and clicking a row opens TermDetail.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { PaneFrame } from "../PaneFrame";
|
||||
import { PaneViewTabs } from "../PaneControls";
|
||||
import { PaneEmpty } from "../PaneEmpty";
|
||||
import { PaneDrawer } from "../PaneDrawer";
|
||||
import { StatusChip } from "../StatusChip";
|
||||
import { useAnalysis, type ClientTerm } from "../../../lib/workspace/analysisStore";
|
||||
import { useOpenPanes } from "../../../lib/workspace/openPanesStore";
|
||||
|
||||
type ViewMode = "tree" | "alpha";
|
||||
|
||||
interface ConceptsPaneProps {
|
||||
/** Position of this pane in the column stack (0 = root). */
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConceptsPane({ index, onClose }: ConceptsPaneProps) {
|
||||
const { terms, analyze, inFlight } = useAnalysis();
|
||||
const [mode, setMode] = useState<ViewMode>("tree");
|
||||
const running = inFlight.has("concepts") || inFlight.has("all");
|
||||
|
||||
const kept = useMemo(() => terms.filter(t => t.status === "accepted"), [terms]);
|
||||
const pending = useMemo(() => terms.filter(t => t.status !== "accepted"), [terms]);
|
||||
const definedCount = kept.filter(t => t.definition && t.definition.length > 0).length;
|
||||
|
||||
const subtitle =
|
||||
pending.length > 0
|
||||
? `${kept.length} kept · ${definedCount} defined · ${pending.length} pending`
|
||||
: `${kept.length} terms · ${definedCount} defined`;
|
||||
|
||||
const right = (
|
||||
<div className="pane-controls">
|
||||
<PaneViewTabs<ViewMode>
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
ariaLabel="Concepts view mode"
|
||||
tabs={[
|
||||
{ value: "tree", label: "Tree", title: "Hierarchical view" },
|
||||
{ value: "alpha", label: "A–Z", 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 A–Z views in Concepts. The
|
||||
* leading column is ALWAYS the chevron slot — Tree passes a real toggle
|
||||
* button, A–Z 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 A–Z 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";
|
||||
}
|
||||
231
apps/web/components/editor/sections/FindingsPane.tsx
Normal file
231
apps/web/components/editor/sections/FindingsPane.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
// Findings pane — three groups of rows for one kind (assumption / risk /
|
||||
// inconsistency):
|
||||
//
|
||||
// 1. Kept — status="accepted". Top of the pane, scrollable.
|
||||
// 2. Pending — status in {suggested, deprecated}. Drawer below kept.
|
||||
// 3. Discarded — status in {dismissed, resolved}. Collapsed drawer at the
|
||||
// bottom, hidden when empty.
|
||||
//
|
||||
// Clicking a row pushes a FindingColumn to the right (full detail + Socrates
|
||||
// thread + decision actions). Decisions also work inline from the row.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { PaneFrame } from "../PaneFrame";
|
||||
import { PaneEmpty } from "../PaneEmpty";
|
||||
import { PaneDrawer } from "../PaneDrawer";
|
||||
import { StatusChip } from "../StatusChip";
|
||||
import { useAnalysis, type ClientFinding } from "../../../lib/workspace/analysisStore";
|
||||
import { useOpenPanes } from "../../../lib/workspace/openPanesStore";
|
||||
|
||||
interface FindingsPaneProps {
|
||||
kind: "assumption" | "risk" | "inconsistency";
|
||||
index: number;
|
||||
projectId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TITLES: Record<FindingsPaneProps["kind"], string> = {
|
||||
assumption: "Assumptions",
|
||||
risk: "Risks",
|
||||
inconsistency: "Inconsistencies",
|
||||
};
|
||||
|
||||
const SECTION_TO_ANALYZE = {
|
||||
assumption: "assumptions",
|
||||
risk: "risks",
|
||||
inconsistency: "inconsistencies",
|
||||
} as const;
|
||||
|
||||
const PENDING_STATUSES = new Set(["suggested", "deprecated"]);
|
||||
const DISCARDED_STATUSES = new Set(["dismissed", "resolved"]);
|
||||
|
||||
export function FindingsPane({ kind, index, onClose }: FindingsPaneProps) {
|
||||
const { findings, analyze, inFlight } = useAnalysis();
|
||||
const { pushFrom, columns } = useOpenPanes();
|
||||
|
||||
const items = useMemo(() => findings.filter(f => f.kind === kind), [findings, kind]);
|
||||
const kept = useMemo(() => items.filter(f => f.status === "accepted"), [items]);
|
||||
const pending = useMemo(() => items.filter(f => PENDING_STATUSES.has(f.status)), [items]);
|
||||
const discarded = useMemo(() => items.filter(f => DISCARDED_STATUSES.has(f.status)), [items]);
|
||||
|
||||
const section = SECTION_TO_ANALYZE[kind];
|
||||
const running = inFlight.has(section) || inFlight.has("all");
|
||||
|
||||
const activeId =
|
||||
columns[index + 1]?.kind === "finding" ? columns[index + 1]!.id : null;
|
||||
|
||||
const subtitle =
|
||||
pending.length > 0
|
||||
? `${kept.length} kept · ${pending.length} pending`
|
||||
: `${kept.length} kept`;
|
||||
|
||||
const right = (
|
||||
<div className="pane-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="pane-action"
|
||||
onClick={() => void analyze(section)}
|
||||
disabled={running}
|
||||
>
|
||||
{running ? "Analyzing…" : "Analyze"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderRow = (f: ClientFinding) => (
|
||||
<FindingRow
|
||||
key={f.id}
|
||||
finding={f}
|
||||
active={activeId === f.id}
|
||||
onOpen={() => pushFrom(index, { kind: "finding", id: f.id })}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<PaneFrame title={TITLES[kind]} subtitle={subtitle} right={right} onClose={onClose}>
|
||||
<div className="pane-drawer-stack">
|
||||
<div className="pane-drawer-main">
|
||||
{items.length === 0 ? (
|
||||
<PaneEmpty
|
||||
title={`No ${TITLES[kind].toLowerCase()} yet`}
|
||||
hint={`${TITLES[kind]} are detected against the current model. Run Analyze to scan it for issues.`}
|
||||
action={{
|
||||
label: running ? "Analyzing…" : "Run Analyze →",
|
||||
onClick: () => void analyze(section),
|
||||
disabled: running,
|
||||
}}
|
||||
/>
|
||||
) : kept.length === 0 ? (
|
||||
<PaneEmpty
|
||||
title="Nothing kept yet"
|
||||
hint="Review the pending findings below to start tracking the ones that matter."
|
||||
/>
|
||||
) : (
|
||||
<ul className="finding-list">{kept.map(renderRow)}</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pending.length > 0 ? (
|
||||
<PaneDrawer title="Pending review" count={pending.length} tone="pending" defaultOpen>
|
||||
<ul className="finding-list">{pending.map(renderRow)}</ul>
|
||||
</PaneDrawer>
|
||||
) : null}
|
||||
|
||||
<PaneDrawer
|
||||
title="Discarded"
|
||||
count={discarded.length}
|
||||
tone="muted"
|
||||
hideWhenEmpty
|
||||
>
|
||||
<ul className="finding-list">{discarded.map(renderRow)}</ul>
|
||||
</PaneDrawer>
|
||||
</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function FindingRow({
|
||||
finding,
|
||||
active,
|
||||
onOpen,
|
||||
}: {
|
||||
finding: ClientFinding;
|
||||
active: boolean;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const { decideFinding } = useAnalysis();
|
||||
const isPending = PENDING_STATUSES.has(finding.status);
|
||||
const isDiscarded = DISCARDED_STATUSES.has(finding.status);
|
||||
|
||||
return (
|
||||
<li
|
||||
className={`finding-row finding-status-${finding.status} ${active ? "row-active" : ""}`}
|
||||
onClick={onOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="finding-row-head finding-row-head-static">
|
||||
<span className="finding-text">{finding.text}</span>
|
||||
<span className="finding-meta">
|
||||
{finding.status === "suggested" ? <StatusChip variant="suggested" /> : null}
|
||||
{finding.status === "deprecated" ? <StatusChip variant="deprecated" /> : null}
|
||||
{finding.status === "dismissed" ? <StatusChip variant="dismissed" /> : null}
|
||||
{finding.status === "resolved" ? <StatusChip variant="resolved" /> : null}
|
||||
{finding.validationCode ? (
|
||||
<StatusChip variant="code" value={finding.validationCode} title="Validation rule" />
|
||||
) : null}
|
||||
{finding.severity ? (
|
||||
<StatusChip
|
||||
variant="severity"
|
||||
value={finding.severity as "low" | "medium" | "high"}
|
||||
title="Severity"
|
||||
/>
|
||||
) : null}
|
||||
<StatusChip
|
||||
variant="confidence"
|
||||
value={finding.confidence}
|
||||
title={`${Math.round(finding.confidence * 100)}% confidence`}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
{isPending ? (
|
||||
<div className="term-decision-row" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-keep"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
void decideFinding(finding.id, "keep");
|
||||
}}
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-discard"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
void decideFinding(finding.id, "discard");
|
||||
}}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
{finding.status === "suggested" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-resolve"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
void decideFinding(finding.id, "resolve");
|
||||
}}
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : isDiscarded ? (
|
||||
<div className="term-decision-row" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-keep"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
void decideFinding(finding.id, "restore");
|
||||
}}
|
||||
title="Move back to Kept"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
340
apps/web/components/editor/sections/ModelPane.tsx
Normal file
340
apps/web/components/editor/sections/ModelPane.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
// Model pane — fully editable React Flow diagram + a Summary subtab listing
|
||||
// blocks/associations/constraints/requirements with a click-to-jump UX +
|
||||
// review controls (Keep / Discard) on analyzer-suggested or analyzer-deprecated
|
||||
// elements.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { PaneFrame } from "../PaneFrame";
|
||||
import { PaneViewTabs, PaneFilterChip } from "../PaneControls";
|
||||
import { StatusChip } from "../StatusChip";
|
||||
import { DiagramCanvas } from "../../diagram-canvas/DiagramCanvas";
|
||||
import { useModelStore } from "../../../lib/sync/ModelStore";
|
||||
import { useAnalysis } from "../../../lib/workspace/analysisStore";
|
||||
import { useEditorPaneContext } from "./paneContext";
|
||||
import { useOpenPanes } from "../../../lib/workspace/openPanesStore";
|
||||
import {
|
||||
decideElement,
|
||||
removeBlock,
|
||||
removeAssociation,
|
||||
removeConstraint,
|
||||
removeRequirement,
|
||||
type ReviewableElementKind,
|
||||
} from "../../../lib/sync/ops";
|
||||
import type { ReviewStatus, SysMLModel } from "../../../lib/sysml/model";
|
||||
|
||||
interface ModelPaneProps {
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Tab = "diagram" | "summary";
|
||||
|
||||
export function ModelPane({ index, onClose }: ModelPaneProps) {
|
||||
const [tab, setTab] = useState<Tab>("diagram");
|
||||
const [pendingOnly, setPendingOnly] = useState(false);
|
||||
const { model, issuesByElement } = useModelStore();
|
||||
const { analyze, inFlight } = useAnalysis();
|
||||
const { focusBlockId, setFocusBlockId, projectId } = useEditorPaneContext();
|
||||
const running = inFlight.has("model") || inFlight.has("all");
|
||||
|
||||
const pendingCount = countPending(model);
|
||||
|
||||
const right = (
|
||||
<div className="pane-controls">
|
||||
<PaneViewTabs<Tab>
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
ariaLabel="Model view mode"
|
||||
tabs={[
|
||||
{ value: "diagram", label: "Diagram" },
|
||||
{ value: "summary", label: "Summary" },
|
||||
]}
|
||||
/>
|
||||
<PaneFilterChip
|
||||
active={pendingOnly}
|
||||
onToggle={() => {
|
||||
// Pending now overlays the current view mode rather than replacing
|
||||
// it. If we're on Diagram and the user wants to triage pending
|
||||
// items, switching to Summary makes far more sense than rendering
|
||||
// a filtered diagram, so we still nudge to Summary on toggle-on.
|
||||
setPendingOnly(p => {
|
||||
if (!p && tab === "diagram") setTab("summary");
|
||||
return !p;
|
||||
});
|
||||
}}
|
||||
label="Pending"
|
||||
count={pendingCount}
|
||||
title="Show only suggestions and deprecated model elements"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="pane-action"
|
||||
onClick={() => void analyze("model")}
|
||||
disabled={running}
|
||||
title="Re-derive model from prose"
|
||||
>
|
||||
{running ? "Analyzing…" : "Analyze"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const subtitle =
|
||||
pendingCount > 0
|
||||
? `${model.blocks.length} blocks · ${model.associations.length} assocs · ${model.constraints.length} constraints · ${pendingCount} pending`
|
||||
: `${model.blocks.length} blocks · ${model.associations.length} assocs · ${model.constraints.length} constraints`;
|
||||
|
||||
return (
|
||||
<PaneFrame title="Model" subtitle={subtitle} right={right} onClose={onClose}>
|
||||
{tab === "diagram" && !pendingOnly ? (
|
||||
<div className="canvas-scroll canvas-scroll-diagram">
|
||||
<DiagramCanvas
|
||||
focusBlockId={focusBlockId}
|
||||
onSelect={setFocusBlockId}
|
||||
issuesByElement={issuesByElement}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ModelSummary pendingOnly={pendingOnly} parentIndex={index} />
|
||||
)}
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function countPending(model: SysMLModel): number {
|
||||
const isPending = (rs?: ReviewStatus) => rs === "suggested" || rs === "deprecated";
|
||||
return (
|
||||
model.blocks.filter(b => isPending(b.reviewStatus)).length +
|
||||
model.associations.filter(a => isPending(a.reviewStatus)).length +
|
||||
model.constraints.filter(c => isPending(c.reviewStatus)).length +
|
||||
model.requirements.filter(r => isPending(r.reviewStatus)).length
|
||||
);
|
||||
}
|
||||
|
||||
function ModelSummary({ pendingOnly, parentIndex }: { pendingOnly: boolean; parentIndex: number }) {
|
||||
const { model, apply } = useModelStore();
|
||||
const { pushFrom } = useOpenPanes();
|
||||
|
||||
const filterPending = <T extends { reviewStatus?: ReviewStatus }>(xs: T[]): T[] =>
|
||||
pendingOnly ? xs.filter(x => x.reviewStatus === "suggested" || x.reviewStatus === "deprecated") : xs;
|
||||
|
||||
const blocks = filterPending(model.blocks);
|
||||
const associations = filterPending(model.associations);
|
||||
const constraints = filterPending(model.constraints);
|
||||
const requirements = filterPending(model.requirements);
|
||||
|
||||
const totalShown = blocks.length + associations.length + constraints.length + requirements.length;
|
||||
|
||||
if (totalShown === 0) {
|
||||
return (
|
||||
<div className="pane-empty">
|
||||
{pendingOnly ? (
|
||||
<>No pending changes. Everything is up to date.</>
|
||||
) : (
|
||||
<>
|
||||
No model yet. Click <strong>Analyze</strong> to derive one from your prose, or open the
|
||||
diagram and start dragging.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const decideKeep = (kind: ReviewableElementKind, id: string) =>
|
||||
apply([decideElement({ kind, id })]);
|
||||
const decideDiscard = (kind: ReviewableElementKind, id: string) => {
|
||||
if (kind === "block") apply([removeBlock(id)]);
|
||||
else if (kind === "association") apply([removeAssociation(id)]);
|
||||
else if (kind === "constraint") apply([removeConstraint(id)]);
|
||||
else if (kind === "requirement") apply([removeRequirement(id)]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="model-summary">
|
||||
{blocks.length > 0 && (
|
||||
<>
|
||||
<h3 className="model-summary-h">Blocks ({blocks.length})</h3>
|
||||
<ul className="model-summary-list">
|
||||
{blocks.map(b => (
|
||||
<li key={b.id}>
|
||||
<ReviewRow status={b.reviewStatus}>
|
||||
<button
|
||||
type="button"
|
||||
className="model-summary-row"
|
||||
onClick={() => pushFrom(parentIndex, { kind: "block", id: b.id })}
|
||||
>
|
||||
<span className="model-summary-kind">{b.kind}</span>
|
||||
<span className="model-summary-label">{b.label}</span>
|
||||
</button>
|
||||
{b.linkedTermId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="model-summary-link"
|
||||
title="Open concept detail"
|
||||
onClick={() =>
|
||||
pushFrom(parentIndex, { kind: "term", id: b.linkedTermId! })
|
||||
}
|
||||
>
|
||||
↪ concept
|
||||
</button>
|
||||
) : null}
|
||||
<DecideButtons
|
||||
status={b.reviewStatus}
|
||||
onKeep={() => decideKeep("block", b.id)}
|
||||
onDiscard={() => decideDiscard("block", b.id)}
|
||||
/>
|
||||
</ReviewRow>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{associations.length > 0 && (
|
||||
<>
|
||||
<h3 className="model-summary-h">Associations ({associations.length})</h3>
|
||||
<ul className="model-summary-list">
|
||||
{associations.map(a => (
|
||||
<li key={a.id}>
|
||||
<ReviewRow status={a.reviewStatus}>
|
||||
<button
|
||||
type="button"
|
||||
className="model-summary-row"
|
||||
onClick={() => pushFrom(parentIndex, { kind: "association", id: a.id })}
|
||||
>
|
||||
<span className="model-summary-kind">{a.kind}</span>
|
||||
<span className="model-summary-label">
|
||||
{labelOf(model, a.fromBlockId)} <em>{a.label || "→"}</em>{" "}
|
||||
{labelOf(model, a.toBlockId)}
|
||||
</span>
|
||||
</button>
|
||||
<DecideButtons
|
||||
status={a.reviewStatus}
|
||||
onKeep={() => decideKeep("association", a.id)}
|
||||
onDiscard={() => decideDiscard("association", a.id)}
|
||||
/>
|
||||
</ReviewRow>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{constraints.length > 0 && (
|
||||
<>
|
||||
<h3 className="model-summary-h">Constraints ({constraints.length})</h3>
|
||||
<ul className="model-summary-list">
|
||||
{constraints.map(c => (
|
||||
<li key={c.id}>
|
||||
<ReviewRow status={c.reviewStatus}>
|
||||
<button
|
||||
type="button"
|
||||
className="model-summary-row"
|
||||
onClick={() => pushFrom(parentIndex, { kind: "constraint", id: c.id })}
|
||||
>
|
||||
<span className="model-summary-kind">constraint</span>
|
||||
<span className="model-summary-label">
|
||||
{c.label}
|
||||
{c.expression ? <> — <code>{c.expression}</code></> : null}
|
||||
</span>
|
||||
</button>
|
||||
<DecideButtons
|
||||
status={c.reviewStatus}
|
||||
onKeep={() => decideKeep("constraint", c.id)}
|
||||
onDiscard={() => decideDiscard("constraint", c.id)}
|
||||
/>
|
||||
</ReviewRow>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{requirements.length > 0 && (
|
||||
<>
|
||||
<h3 className="model-summary-h">Requirements ({requirements.length})</h3>
|
||||
<ul className="model-summary-list">
|
||||
{requirements.map(r => (
|
||||
<li key={r.id}>
|
||||
<ReviewRow status={r.reviewStatus}>
|
||||
<button
|
||||
type="button"
|
||||
className="model-summary-row"
|
||||
onClick={() => pushFrom(parentIndex, { kind: "requirement", id: r.id })}
|
||||
>
|
||||
<span className="model-summary-kind">{r.tag}</span>
|
||||
<span className="model-summary-label">{r.text}</span>
|
||||
</button>
|
||||
<DecideButtons
|
||||
status={r.reviewStatus}
|
||||
onKeep={() => decideKeep("requirement", r.id)}
|
||||
onDiscard={() => decideDiscard("requirement", r.id)}
|
||||
/>
|
||||
</ReviewRow>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({
|
||||
status,
|
||||
children,
|
||||
}: {
|
||||
status?: ReviewStatus;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={`model-summary-row-wrap model-status-${status ?? "accepted"}`}>
|
||||
{status === "suggested" ? <StatusChip variant="suggested" /> : null}
|
||||
{status === "deprecated" ? <StatusChip variant="deprecated" /> : null}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DecideButtons({
|
||||
status,
|
||||
onKeep,
|
||||
onDiscard,
|
||||
}: {
|
||||
status?: ReviewStatus;
|
||||
onKeep: () => void;
|
||||
onDiscard: () => void;
|
||||
}) {
|
||||
if (status !== "suggested" && status !== "deprecated") return null;
|
||||
return (
|
||||
<span className="model-decide-row">
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-keep"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onKeep();
|
||||
}}
|
||||
title={
|
||||
status === "suggested" ? "Accept this analyzer suggestion" : "Pin this element despite the analyzer dropping it"
|
||||
}
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-discard"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onDiscard();
|
||||
}}
|
||||
title={status === "suggested" ? "Reject this suggestion" : "Remove this element"}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function labelOf(model: SysMLModel, id: string): string {
|
||||
return model.blocks.find(b => b.id === id)?.label ?? id;
|
||||
}
|
||||
212
apps/web/components/editor/sections/RequirementsPane.tsx
Normal file
212
apps/web/components/editor/sections/RequirementsPane.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
// Requirements pane — list of extracted requirements with traceability +
|
||||
// unsupported flags. Click a requirement's traced block → focus that block
|
||||
// in Model. Re-running Analyze MERGES; new and deprecated requirements
|
||||
// surface for the user to keep or discard.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { PaneFrame } from "../PaneFrame";
|
||||
import { PaneEmpty } from "../PaneEmpty";
|
||||
import { PaneDrawer } from "../PaneDrawer";
|
||||
import { StatusChip } from "../StatusChip";
|
||||
import { useAnalysis, type ClientRequirement } from "../../../lib/workspace/analysisStore";
|
||||
import { useOpenPanes } from "../../../lib/workspace/openPanesStore";
|
||||
import { useModelStore } from "../../../lib/sync/ModelStore";
|
||||
|
||||
interface RequirementsPaneProps {
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RequirementsPane({ index, onClose }: RequirementsPaneProps) {
|
||||
const { requirements, analyze, inFlight } = useAnalysis();
|
||||
const { pushFrom } = useOpenPanes();
|
||||
const { model } = useModelStore();
|
||||
const running = inFlight.has("requirements") || inFlight.has("all");
|
||||
|
||||
const kept = useMemo(
|
||||
() => requirements.filter(r => r.status === "accepted"),
|
||||
[requirements]
|
||||
);
|
||||
const pending = useMemo(
|
||||
() => requirements.filter(r => r.status !== "accepted"),
|
||||
[requirements]
|
||||
);
|
||||
const unsupportedCount = kept.filter(r => r.unsupported).length;
|
||||
|
||||
const labelOf = (id: string) => model.blocks.find(b => b.id === id)?.label ?? id;
|
||||
|
||||
const subtitle =
|
||||
requirements.length === 0
|
||||
? "no requirements yet"
|
||||
: pending.length > 0
|
||||
? `${kept.length} kept · ${pending.length} pending`
|
||||
: unsupportedCount > 0
|
||||
? `${kept.length} total · ${unsupportedCount} unsupported`
|
||||
: `${kept.length} total · all traced`;
|
||||
|
||||
const right = (
|
||||
<div className="pane-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="pane-action"
|
||||
onClick={() => void analyze("requirements")}
|
||||
disabled={running}
|
||||
>
|
||||
{running ? "Analyzing…" : "Analyze"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderRow = (r: ClientRequirement) => (
|
||||
<RequirementItem
|
||||
key={r.id}
|
||||
req={r}
|
||||
labelOf={labelOf}
|
||||
onOpenSelf={() => pushFrom(index, { kind: "requirement", id: r.id })}
|
||||
onJumpToBlock={id => pushFrom(index, { kind: "block", id })}
|
||||
onJumpToTerm={id => pushFrom(index, { kind: "term", id })}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<PaneFrame title="Requirements" subtitle={subtitle} right={right} onClose={onClose}>
|
||||
<div className="pane-drawer-stack">
|
||||
<div className="pane-drawer-main">
|
||||
{kept.length === 0 && pending.length === 0 ? (
|
||||
<PaneEmpty
|
||||
title="No requirements yet"
|
||||
hint={
|
||||
<>
|
||||
Requirements are extracted from sentences in your prose that say the system
|
||||
<em> must / should / needs to</em>. Run <strong>Analyze</strong> to scan.
|
||||
</>
|
||||
}
|
||||
action={{
|
||||
label: running ? "Analyzing…" : "Run Analyze →",
|
||||
onClick: () => void analyze("requirements"),
|
||||
disabled: running,
|
||||
}}
|
||||
/>
|
||||
) : kept.length === 0 ? (
|
||||
<PaneEmpty
|
||||
title="Nothing kept yet"
|
||||
hint="Review the pending requirements below."
|
||||
/>
|
||||
) : (
|
||||
<ul className="req-list">{kept.map(renderRow)}</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pending.length > 0 ? (
|
||||
<PaneDrawer
|
||||
title="Pending review"
|
||||
count={pending.length}
|
||||
tone="pending"
|
||||
defaultOpen
|
||||
>
|
||||
<ul className="req-list">{pending.map(renderRow)}</ul>
|
||||
</PaneDrawer>
|
||||
) : null}
|
||||
</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
|
||||
interface RequirementItemProps {
|
||||
req: ClientRequirement;
|
||||
labelOf: (id: string) => string;
|
||||
onOpenSelf: () => void;
|
||||
onJumpToBlock: (id: string) => void;
|
||||
onJumpToTerm: (id: string) => void;
|
||||
}
|
||||
|
||||
function RequirementItem({ req, labelOf, onOpenSelf, onJumpToBlock, onJumpToTerm }: RequirementItemProps) {
|
||||
const { decideRequirement } = useAnalysis();
|
||||
return (
|
||||
<li
|
||||
className={`req-row req-status-${req.status} ${req.unsupported ? "req-row-unsupported" : ""}`}
|
||||
onClick={onOpenSelf}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpenSelf();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="req-row-head">
|
||||
<span className="req-tag">{req.tag}</span>
|
||||
<span className="req-row-meta">
|
||||
<ReqStatusBadge status={req.status} />
|
||||
{req.unsupported ? <StatusChip variant="warn" label="unsupported" /> : null}
|
||||
</span>
|
||||
</div>
|
||||
<div className="req-text">{req.text}</div>
|
||||
{req.linkedTermId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="req-term-link"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onJumpToTerm(req.linkedTermId!);
|
||||
}}
|
||||
title="Open concept detail"
|
||||
>
|
||||
↪ concept
|
||||
</button>
|
||||
) : null}
|
||||
{req.tracedToIds.length > 0 ? (
|
||||
<div className="req-traced" onClick={e => e.stopPropagation()}>
|
||||
Traced to:{" "}
|
||||
{req.tracedToIds.map((id, i) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className="req-traced-link"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onJumpToBlock(id);
|
||||
}}
|
||||
>
|
||||
{labelOf(id)}
|
||||
{i < req.tracedToIds.length - 1 ? ", " : ""}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{req.status !== "accepted" ? (
|
||||
<div className="term-decision-row" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-keep"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
void decideRequirement(req.id, "keep");
|
||||
}}
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="term-decide term-decide-discard"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
void decideRequirement(req.id, "discard");
|
||||
}}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function ReqStatusBadge({ status }: { status: ClientRequirement["status"] }) {
|
||||
if (status === "suggested") return <StatusChip variant="suggested" />;
|
||||
if (status === "deprecated") return <StatusChip variant="deprecated" />;
|
||||
return null;
|
||||
}
|
||||
22
apps/web/components/editor/sections/TextCanvasPane.tsx
Normal file
22
apps/web/components/editor/sections/TextCanvasPane.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
// Pinned text-editor pane. Always present in the workspace; not closable.
|
||||
|
||||
"use client";
|
||||
|
||||
import { TextCanvas } from "../../text-canvas/TextCanvas";
|
||||
import { PaneFrame } from "../PaneFrame";
|
||||
import type { FixtureData } from "../../../lib/fixtures/aristotle";
|
||||
|
||||
interface TextCanvasPaneProps {
|
||||
data: FixtureData;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function TextCanvasPane({ data, projectId }: TextCanvasPaneProps) {
|
||||
return (
|
||||
<PaneFrame title="Narrative" subtitle="Your document" closable={false}>
|
||||
<div className="canvas-scroll">
|
||||
<TextCanvas data={data} projectId={projectId} />
|
||||
</div>
|
||||
</PaneFrame>
|
||||
);
|
||||
}
|
||||
37
apps/web/components/editor/sections/paneContext.tsx
Normal file
37
apps/web/components/editor/sections/paneContext.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
// Shared context for cross-pane focus state and projectId plumbing.
|
||||
//
|
||||
// `focusBlockId` drives diagram selection / chip hover styling. The richer
|
||||
// term/finding/etc detail navigation now lives in the column-stack
|
||||
// (openPanesStore), so this context is intentionally minimal.
|
||||
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
interface PaneContextValue {
|
||||
projectId: string;
|
||||
focusBlockId: string | null;
|
||||
setFocusBlockId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<PaneContextValue | null>(null);
|
||||
|
||||
interface ProviderProps {
|
||||
projectId: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function EditorPaneContextProvider({ projectId, children }: ProviderProps) {
|
||||
const [focusBlockId, setFocusBlockId] = useState<string | null>(null);
|
||||
const value = useMemo<PaneContextValue>(
|
||||
() => ({ projectId, focusBlockId, setFocusBlockId }),
|
||||
[projectId, focusBlockId]
|
||||
);
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useEditorPaneContext(): PaneContextValue {
|
||||
const ctx = useContext(Ctx);
|
||||
if (!ctx) throw new Error("useEditorPaneContext must be inside <EditorPaneContextProvider>");
|
||||
return ctx;
|
||||
}
|
||||
Reference in New Issue
Block a user