diff --git a/kb-viz/frontend/src/components/AppShell.tsx b/kb-viz/frontend/src/components/AppShell.tsx index 7dc7869..bdf8531 100644 --- a/kb-viz/frontend/src/components/AppShell.tsx +++ b/kb-viz/frontend/src/components/AppShell.tsx @@ -1,9 +1,9 @@ import { useCallback } from 'react'; -import { Mosaic, MosaicWindow, type MosaicNode } from 'react-mosaic-component'; +import { Mosaic, MosaicWindow, type MosaicNode, type MosaicPath, updateTree, createRemoveUpdate } from 'react-mosaic-component'; import 'react-mosaic-component/react-mosaic-component.css'; import { useStore } from '../lib/use-store'; -import { layoutStore, type FrameType } from '../state/layout-store'; +import { layoutStore, type FrameType, type PaneNode } from '../state/layout-store'; import { getFrame } from '../frames/registry'; import { NodeTooltip } from './NodeTooltip'; @@ -34,15 +34,16 @@ export function AppShell() { renderTile={(type, path) => { const Frame = getFrame(type); + const paneId = path.length > 0 ? path.join(':') : type; return ( path={path} title={FRAME_LABELS[type] ?? type} - toolbarControls={} + toolbarControls={} createNode={() => 'text' as FrameType} > {/* Width/height passed as 0 — frames that use them fall back to el.clientWidth */} - + ); }} @@ -55,7 +56,7 @@ export function AppShell() { ); } -function FrameControls({ type }: { type: FrameType }) { +function FrameControls({ type, path }: { type: FrameType; path: MosaicPath }) { return (
diff --git a/kb-viz/frontend/src/frames/TextFrame.tsx b/kb-viz/frontend/src/frames/TextFrame.tsx index a59263e..6d71730 100644 --- a/kb-viz/frontend/src/frames/TextFrame.tsx +++ b/kb-viz/frontend/src/frames/TextFrame.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import { type ReactNode, useEffect, useRef } from 'react'; import { useStore } from '../lib/use-store'; import { dataStore, getAncestors } from '../state/data-store'; import { selectionStore } from '../state/selection-store'; @@ -10,36 +10,78 @@ import { isTemporal, type Annotation, type Node, + type NodeId, type PropertyValue, } from '../types/manifest'; import { deriveLabel } from '../lib/derive-label'; import type { FrameProps } from './registry'; -export function TextFrame(_props: FrameProps) { - const nodesById = useStore(dataStore, (s) => s.nodes); - const focused = useStore(selectionStore, (s) => s.focused); + +export function TextFrame({ paneId }: FrameProps) { + const nodesById = useStore(dataStore, (s) => s.nodes); + const focused = useStore(selectionStore, (s) => s.focused); + const pinnedDocId = useStore(viewStore, (s) => s.textPinned[paneId] ?? null); + + if (pinnedDocId) { + return ; + } + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Unpinned (follow-selection) mode +// --------------------------------------------------------------------------- + +function UnpinnedView({ + paneId, + nodesById, + focused, +}: { + paneId: string; + nodesById: Map; + focused: string | null; +}) { + const containerRef = useRef(null); + + // #41: scroll focused node into view when selection changes externally + useEffect(() => { + if (!focused || !containerRef.current) return; + const el = containerRef.current.querySelector(`[data-node-id="${CSS.escape(focused)}"]`); + el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, [focused]); if (!focused) { return ( -
+
+
Select a node from any frame to see its text and annotations
); } + const node = nodesById.get(focused); if (!node) { return ( -
+
+
Node not found: {focused}
); } return ( -
-

{deriveLabel(node)}

+
+ +

{deriveLabel(node)}

{node.id} {' · '}type: {node.type} @@ -57,6 +99,131 @@ export function TextFrame(_props: FrameProps) { ); } +// --------------------------------------------------------------------------- +// Pinned (comparison) mode — shows all chunks of a fixed document +// --------------------------------------------------------------------------- + +function PinnedDocView({ docId, paneId }: { docId: NodeId; paneId: string }) { + const nodesById = useStore(dataStore, (s) => s.nodes); + const focused = useStore(selectionStore, (s) => s.focused); + const containerRef = useRef(null); + + const doc = nodesById.get(docId); + const chunks = (doc?.child_ids ?? []) + .map((id) => nodesById.get(id)) + .filter((n): n is Node => n != null); + + // #41: scroll to focused chunk within the pinned panel + useEffect(() => { + if (!focused || !containerRef.current) return; + const el = containerRef.current.querySelector(`[data-node-id="${CSS.escape(focused)}"]`); + el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, [focused]); + + if (!doc) { + return ( +
+ +
Pinned document not found
+
+ ); + } + + return ( +
+ + +
+ {doc.type} +

{deriveLabel(doc)}

+
+ + {chunks.length === 0 && ( +
+ No child nodes +
+ )} + + {chunks.map((chunk) => { + const isFocused = focused === chunk.id; + return ( +
selectionStore.getState().selectOnly(chunk.id)} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && selectionStore.getState().selectOnly(chunk.id)} + > +
{chunk.id}
+ {chunk.text ? ( +
{renderHighlighted(chunk)}
+ ) : ( +
(no text)
+ )} +
+ ); + })} +
+ ); +} + +// --------------------------------------------------------------------------- +// Pin toolbar — document picker + unpin +// --------------------------------------------------------------------------- + +function PinBar({ paneId, docLabel }: { paneId: string; docLabel?: string }) { + const byType = useStore(dataStore, (s) => s.byType); + const nodesById = useStore(dataStore, (s) => s.nodes); + const pinned = useStore(viewStore, (s) => s.textPinned[paneId] ?? null); + + const docIds = byType.get('document') ?? []; + const docs = (docIds + .map((id) => nodesById.get(id)) + .filter((n): n is Node => n != null) as Node[]) + .map((n) => ({ node: n, key: deriveLabel(n).replace(/^[^a-zA-Z0-9]+/, '').toLowerCase() })) + .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)) + .map(({ node }) => node); + + const handleChange = (e: React.ChangeEvent) => { + viewStore.getState().setPinnedDoc(paneId, e.target.value || null); + }; + + return ( +
+ + {pinned && ( + + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Shared sub-components +// --------------------------------------------------------------------------- + function NodeNav({ node, nodesById }: { node: Node; nodesById: Map }) { const manifest = useStore(dataStore, (s) => s.manifest); diff --git a/kb-viz/frontend/src/state/layout-store.ts b/kb-viz/frontend/src/state/layout-store.ts index e9b354a..efa4627 100644 --- a/kb-viz/frontend/src/state/layout-store.ts +++ b/kb-viz/frontend/src/state/layout-store.ts @@ -63,6 +63,12 @@ const PRESETS: Record = { second: { direction: 'column', first: 'semantic', second: 'map' }, splitPercentage: 55, }, + 'comparison': { + direction: 'row', + first: 'text', + second: 'text', + splitPercentage: 50, + }, single: 'semantic', }; diff --git a/kb-viz/frontend/src/state/view-store.ts b/kb-viz/frontend/src/state/view-store.ts index de28fe3..5a0e923 100644 --- a/kb-viz/frontend/src/state/view-store.ts +++ b/kb-viz/frontend/src/state/view-store.ts @@ -34,6 +34,10 @@ export interface PaneViewState { rotationOrbit?: number; } +export interface TextFrameConfig { + pinnedDocId: NodeId | null; +} + export interface ViewState { level: Level; colorBy: ColorBy; @@ -45,12 +49,16 @@ export interface ViewState { // Per-frame type config frameConfigs: Partial>; + // Per-pane text frame pinned document (keyed by mosaic path joined with ':') + textPinned: Record; + setLevel: (l: Level) => void; setColorBy: (c: ColorBy) => void; drillInto: (id: NodeId, childLevel: Level) => void; drillOut: () => void; setPaneViewState: (frame: FrameType, state: PaneViewState) => void; setFrameConfig: (frame: FrameType, config: Partial) => void; + setPinnedDoc: (paneId: string, docId: NodeId | null) => void; } const LEVEL_ORDER: Record = { document: 0, chunk: 1, expression: 2 }; @@ -68,6 +76,7 @@ export const viewStore = createStore()( scope: 'global', paneViewStates: {}, frameConfigs: { ...DEFAULT_FRAME_CONFIGS }, + textPinned: {}, setLevel: (newLevel) => { const currentLevel = get().level; @@ -124,6 +133,11 @@ export const viewStore = createStore()( [frame]: { ...(s.frameConfigs[frame] ?? {}), ...config }, }, })), + + setPinnedDoc: (paneId, docId) => + set((s) => ({ + textPinned: { ...s.textPinned, [paneId]: docId }, + })), }), { name: 'kb-viz:view', @@ -132,6 +146,7 @@ export const viewStore = createStore()( colorBy: s.colorBy, paneViewStates: s.paneViewStates, frameConfigs: s.frameConfigs, + textPinned: s.textPinned, }), }, ), diff --git a/kb-viz/frontend/src/styles.css b/kb-viz/frontend/src/styles.css index 1d868e4..0cbca3f 100644 --- a/kb-viz/frontend/src/styles.css +++ b/kb-viz/frontend/src/styles.css @@ -211,10 +211,12 @@ body { .text-frame { border-left: var(--border-width) solid var(--border); padding: 14px; + height: 100%; overflow-y: auto; background: var(--surface); display: flex; flex-direction: column; + box-sizing: border-box; } .text-frame h3 { margin: 0 0 4px; @@ -247,6 +249,47 @@ body { font-weight: 600; } +/* ── TextFrame pin bar & comparison mode ─────────────────────────────────── */ +.text-frame-pin-bar { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 10px; +} + +.text-frame-pin-doc-header { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: var(--border-width) solid var(--border); +} + +.text-frame-chunk { + padding: 10px 12px; + margin-bottom: 6px; + border: var(--border-width) solid var(--border); + border-radius: var(--radius); + cursor: pointer; + transition: border-color 120ms, background 120ms; +} +.text-frame-chunk:hover { + border-color: var(--border-light); + background: var(--accent-dim); +} +.text-frame-chunk-focused { + border-color: var(--selected); + background: rgba(240, 80, 40, 0.07); +} +.text-frame-chunk-id { + font-size: 9px; + color: var(--text-muted); + font-family: ui-monospace, monospace; + margin-bottom: 5px; + user-select: all; +} + /* ── Annotation span highlights ───────────────────────────────────────────── */ .span-geo { background: var(--loc-bg); color: var(--loc); border-radius: 3px; padding: 0 3px; } .span-time { background: var(--time-bg); color: var(--time); border-radius: 3px; padding: 0 3px; }