From f2930db9d593f3375bf0e162d8f8084653ecdfc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 03:58:03 +0000 Subject: [PATCH 1/4] feat(#40,#41): TextFrame comparison mode and scrollTo focused node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #41 - scrollTo focused node: - Add data-node-id attributes to rendered nodes in TextFrame - useEffect on selectionStore.focused to smooth-scroll to the focused element within the panel; no-ops if the node isn't in scope Issue #40 - comparison mode / pin TextFrame to a document: - Add textPinned: Record to viewStore, keyed by mosaic path (unique per pane, enabling two TextFrames with separate pins) - Add setPinnedDoc(paneId, docId) action, persisted in localStorage - TextFrame: PinBar shows a document picker select; when pinned, renders PinnedDocView — scrollable list of all child chunks of the pinned doc, with the focused chunk highlighted via .text-frame-chunk-focused - Add comparison layout preset { direction: 'row', first: 'text', second: 'text' } - AppShell: pass path-based paneId to each frame for per-pane uniqueness; use path-aware frame removal (updateTree + createRemoveUpdate) so closing one text pane in comparison mode doesn't close both https://claude.ai/code/session_01EefsLVhEiLxKsJzAbgC1CQ --- kb-viz/frontend/src/components/AppShell.tsx | 17 +- kb-viz/frontend/src/frames/TextFrame.tsx | 178 +++++++++++++++++++- kb-viz/frontend/src/state/layout-store.ts | 6 + kb-viz/frontend/src/state/view-store.ts | 15 ++ kb-viz/frontend/src/styles.css | 41 +++++ 5 files changed, 243 insertions(+), 14 deletions(-) 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..7257b25 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,126 @@ 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); + + 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..6ceaf43 100644 --- a/kb-viz/frontend/src/styles.css +++ b/kb-viz/frontend/src/styles.css @@ -247,6 +247,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; } From c5aa423ab0b463bf9c4afaf7ed5675ae712f3c61 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 04:07:47 +0000 Subject: [PATCH 2/4] fix: make TextFrame scrollable by constraining height to 100% overflow-y: auto only activates when the element has a constrained height. Without height: 100%, the flex container grows to fit all content and never scrolls. https://claude.ai/code/session_01EefsLVhEiLxKsJzAbgC1CQ --- kb-viz/frontend/src/styles.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/kb-viz/frontend/src/styles.css b/kb-viz/frontend/src/styles.css index 6ceaf43..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; From 54531282e753e175a0707180375b4bb34b303895 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 04:10:54 +0000 Subject: [PATCH 3/4] fix(textframe): clearer pin placeholder text and alphabetical doc sort https://claude.ai/code/session_01EefsLVhEiLxKsJzAbgC1CQ --- kb-viz/frontend/src/frames/TextFrame.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/kb-viz/frontend/src/frames/TextFrame.tsx b/kb-viz/frontend/src/frames/TextFrame.tsx index 7257b25..e4176e3 100644 --- a/kb-viz/frontend/src/frames/TextFrame.tsx +++ b/kb-viz/frontend/src/frames/TextFrame.tsx @@ -179,7 +179,10 @@ function PinBar({ paneId, docLabel }: { paneId: string; docLabel?: string }) { 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); + const docs = docIds + .map((id) => nodesById.get(id)) + .filter((n): n is Node => n != null) + .sort((a, b) => deriveLabel(a).localeCompare(deriveLabel(b))); const handleChange = (e: React.ChangeEvent) => { viewStore.getState().setPinnedDoc(paneId, e.target.value || null); @@ -194,7 +197,7 @@ function PinBar({ paneId, docLabel }: { paneId: string; docLabel?: string }) { onChange={handleChange} title="Pin this panel to a document" > - + {docs.map((doc) => (