diff --git a/src/components/common/ConfirmDialog.tsx b/src/components/common/ConfirmDialog.tsx index a66e62d..2b7d02b 100644 --- a/src/components/common/ConfirmDialog.tsx +++ b/src/components/common/ConfirmDialog.tsx @@ -30,8 +30,8 @@ export default function ConfirmDialog({ left: 0, right: 0, bottom: 0, - background: 'rgba(0, 0, 0, 0.85)', - backdropFilter: 'blur(4px)', + background: 'var(--rah-backdrop)', + backdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -42,19 +42,19 @@ export default function ConfirmDialog({
{ - e.currentTarget.style.background = '#0f0f0f'; - e.currentTarget.style.borderColor = '#2a2a2a'; - e.currentTarget.style.color = '#cbd5f5'; + e.currentTarget.style.background = 'var(--rah-bg-base)'; + e.currentTarget.style.borderColor = 'var(--rah-border-strong)'; + e.currentTarget.style.color = 'var(--rah-text-secondary)'; }} onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; - e.currentTarget.style.borderColor = '#1f1f1f'; - e.currentTarget.style.color = '#94a3b8'; + e.currentTarget.style.borderColor = 'var(--rah-border)'; + e.currentTarget.style.color = 'var(--rah-text-soft)'; }} > {cancelLabel} @@ -108,10 +106,8 @@ export default function ConfirmDialog({ border: '1px solid #dc2626', background: '#7f1d1d', color: '#fca5a5', - textTransform: 'uppercase', - letterSpacing: '0.05em', - fontSize: '11px', - fontWeight: 500, + fontSize: '12px', + fontWeight: 600, cursor: 'pointer', transition: 'all 0.2s' }} diff --git a/src/components/focus/FocusPanel.tsx b/src/components/focus/FocusPanel.tsx index 4290705..6d16f16 100644 --- a/src/components/focus/FocusPanel.tsx +++ b/src/components/focus/FocusPanel.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useRef, useState, type DragEvent } from 'react'; -import { Trash2, Loader, Database, RefreshCw, Pencil, X, Save, Plus, Link2, Tag, Share2, AlignLeft, ChevronDown, ChevronRight, Check, Folder } from 'lucide-react'; +import { Trash2, Loader, Database, RefreshCw, Pencil, X, Save, Plus, Link2, Tag, Share2, AlignLeft, ChevronDown, ChevronRight, Check } from 'lucide-react'; import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer'; import { Node, NodeConnection } from '@/types/database'; import { getNodeIcon } from '@/utils/nodeIcons'; @@ -51,7 +51,7 @@ export default function FocusPanel({ const [edgesExpanded, setEdgesExpanded] = useState>({}); const [edgeSearchOpen, setEdgeSearchOpen] = useState(false); const [hoveredConnectionId, setHoveredConnectionId] = useState(null); - const [propsCollapsed, setPropsCollapsed] = useState(false); + const [metadataCollapsed, setMetadataCollapsed] = useState(true); const [titleEditMode, setTitleEditMode] = useState(false); const [titleEditValue, setTitleEditValue] = useState(''); @@ -168,6 +168,7 @@ export default function FocusPanel({ setEdgeEditingId(null); setEdgeEditingValue(''); setHoveredSection(null); + setMetadataCollapsed(true); }, [activeTab]); const fetchNodeData = async (id: number) => { @@ -242,23 +243,6 @@ export default function FocusPanel({ throw new Error('Missing updated node in response'); }; - const toggleProcessedState = async () => { - if (activeTab === null || !currentNode) return; - - const nextState = currentProcessedState === 'processed' ? 'not_processed' : 'processed'; - - try { - await updateNode(activeTab, { - metadata: { - state: nextState, - }, - }); - } catch (error) { - console.error('Error updating processed state:', error); - window.alert('Failed to update processed state. Please try again.'); - } - }; - const renderMetadataSection = () => { const metadataEntries = Object.entries(currentNodeMetadata).filter(([, value]) => value !== undefined); const rawJson = metadataEntries.length > 0 ? JSON.stringify(currentNodeMetadata, null, 2) : ''; @@ -266,11 +250,19 @@ export default function FocusPanel({ return (
- Metadata +
- {metadataEntries.length === 0 ? ( + {metadataCollapsed ? null : metadataEntries.length === 0 ? (
No metadata stored for this node.
@@ -1062,52 +1054,34 @@ export default function FocusPanel({ ) : !currentNode ? (
Node not found.
) : ( -
+
{/* ── Title ── */}
- {titleEditMode ? ( - setTitleEditValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { e.preventDefault(); void saveTitle(); } - if (e.key === 'Escape') { e.preventDefault(); skipTitleBlurRef.current = true; setTitleEditMode(false); setTitleEditValue(''); } - }} - onBlur={() => { - if (skipTitleBlurRef.current) { skipTitleBlurRef.current = false; return; } - void saveTitle(); - }} - disabled={titleSaving} - className="title-input" - placeholder="Enter title..." - /> - ) : ( - - )} - -
- - - {currentProcessedState === 'processed' && ( - processed +
+ {titleEditMode ? ( + setTitleEditValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); void saveTitle(); } + if (e.key === 'Escape') { e.preventDefault(); skipTitleBlurRef.current = true; setTitleEditMode(false); setTitleEditValue(''); } + }} + onBlur={() => { + if (skipTitleBlurRef.current) { skipTitleBlurRef.current = false; return; } + void saveTitle(); + }} + disabled={titleSaving} + className="title-input" + placeholder="Enter title..." + /> + ) : ( + )}
@@ -1116,27 +1090,26 @@ export default function FocusPanel({ {/* ── Properties block ── */}
- {/* Node identity row — with collapse toggle + delete */} -
-
node
-
- - {getNodeIcon(currentNode, 12)} - #{currentNode.id} - + {/* Status indicator row (only when relevant) */} + {renderStatusIndicator() !== null && ( +
+
embed
+
{renderStatusIndicator()}
-
+ )} + +
+
node
+
+ #{currentNode.id} + {currentProcessedState === 'processed' ? ( + + + + ) : null} -
- {!propsCollapsed && (<> - - {/* Status indicator row (only when relevant) */} - {renderStatusIndicator() !== null && ( -
-
embed
-
{renderStatusIndicator()}
-
- )} - {/* Link */}
link
@@ -1178,30 +1141,41 @@ export default function FocusPanel({ className="prop-input" placeholder="https://..." /> - ) : currentNode.link ? ( - normalizedCurrentLink ? ( - { - if (e.metaKey || e.ctrlKey) { e.preventDefault(); startLinkEdit(); return; } - if (!shouldOpenExternally(normalizedCurrentLink)) return; - e.preventDefault(); - void openExternalUrl(normalizedCurrentLink).catch(() => window.alert(`Unable to open ${currentNode.link}`)); - }} - title={`${normalizedCurrentLink} (Cmd+Click to edit)`} - > - {currentNode.link} - - ) : ( - - ) ) : ( - + )}
@@ -1210,25 +1184,23 @@ export default function FocusPanel({
context
- + +
{contextMenuOpen ? (
@@ -1315,9 +1287,12 @@ export default function FocusPanel({ {currentEdgesExpanded ? '↑ Show less' : `+ ${currentEdges.length - 3} more`} )} - +
+ Add connection + +
)}
@@ -1377,8 +1352,6 @@ export default function FocusPanel({
- )} - {/* Drag handle / ID (hidden, for drag-to-chat) */} e.stopPropagation()} style={{ width: '100%', - maxWidth: '560px', + maxWidth: selectedNode ? '480px' : '560px', maxHeight: '70vh', display: 'flex', flexDirection: 'column', @@ -174,52 +174,53 @@ export default function NodeSearchModal({ alignSelf: 'flex-start', }} > - {/* Search input */} -
- - - - { - setSearchQuery(e.target.value); - setSelectedNode(null); - setExplanation(''); - }} - onKeyDown={(e) => { void handleSearchKeyDown(e); }} - placeholder="Search nodes to connect..." - style={{ - flex: 1, - background: 'none', - border: 'none', - outline: 'none', - color: '#f0f0f0', - fontSize: '15px', - fontFamily: 'inherit', - }} - /> - esc -
+ gap: '12px', + background: '#141414', + border: '1px solid #2a2a2a', + borderRadius: '12px', + padding: '14px 18px', + boxShadow: '0 24px 48px -12px rgba(0,0,0,0.6)', + }}> + + + + { + setSearchQuery(e.target.value); + setSelectedNode(null); + setExplanation(''); + }} + onKeyDown={(e) => { void handleSearchKeyDown(e); }} + placeholder="Search nodes to connect..." + style={{ + flex: 1, + background: 'none', + border: 'none', + outline: 'none', + color: '#f0f0f0', + fontSize: '15px', + fontFamily: 'inherit', + }} + /> + esc +
+ )} {/* Results */} {!selectedNode && suggestions.length > 0 && ( @@ -285,8 +286,8 @@ export default function NodeSearchModal({ marginBottom: '14px', }}>
-
- connecting to +
+ Connecting to
{selectedNode.title}
diff --git a/src/components/layout/LeftToolbar.tsx b/src/components/layout/LeftToolbar.tsx index b4b9876..f01a81f 100644 --- a/src/components/layout/LeftToolbar.tsx +++ b/src/components/layout/LeftToolbar.tsx @@ -27,6 +27,8 @@ interface LeftToolbarProps { onSearchClick: () => void; onAddStuffClick: () => void; onRefreshClick: () => void; + visiblePaneCount: 1 | 2 | 3; + onVisiblePaneCountChange: (count: 1 | 2 | 3) => void; onSettingsClick: () => void; onPaneTypeClick: (paneType: PaneType) => void; isExpanded: boolean; @@ -111,6 +113,8 @@ export default function LeftToolbar({ onSearchClick, onAddStuffClick, onRefreshClick, + visiblePaneCount, + onVisiblePaneCountChange, onSettingsClick, onPaneTypeClick, isExpanded, @@ -124,6 +128,122 @@ export default function LeftToolbar({ onContextQuickSelect, }: LeftToolbarProps) { const [contextsExpanded, setContextsExpanded] = useState(false); + const [paneSelectorOpen, setPaneSelectorOpen] = useState(false); + + const renderPaneGlyph = (count: 1 | 2 | 3, active: boolean) => ( + + {Array.from({ length: count }).map((_, index) => ( + + ))} + + ); + + const renderPaneCountButton = (count: 1 | 2 | 3) => { + const active = visiblePaneCount === count; + + return ( + + ); + }; + + const renderPaneSelector = () => ( +
+ + + {paneSelectorOpen ? ( +
+ {([1, 2, 3] as const).map((count) => renderPaneCountButton(count))} +
+ ) : null} +
+ ); const renderActionButtons = () => (
@@ -157,7 +277,11 @@ export default function LeftToolbar({ onClick={onToggleExpanded} /> + {renderPaneSelector()} + +
{renderActionButtons()} +
diff --git a/src/components/layout/ThreePanelLayout.tsx b/src/components/layout/ThreePanelLayout.tsx index a715341..fdb4275 100644 --- a/src/components/layout/ThreePanelLayout.tsx +++ b/src/components/layout/ThreePanelLayout.tsx @@ -1,7 +1,6 @@ "use client"; import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; -import { PanelLeftOpen, GripVertical, X } from 'lucide-react'; import SettingsModal, { SettingsTab } from '../settings/SettingsModal'; import SearchModal from '../nodes/SearchModal'; import type { ContextSummary, Node } from '@/types/database'; @@ -29,6 +28,7 @@ export interface PendingNode { const SLOT_A_KEY = 'ui.slotA.v7'; const SLOT_B_KEY = 'ui.slotB.v7'; const SLOT_C_KEY = 'ui.slotC.v7'; +const VISIBLE_PANE_COUNT_KEY = 'ui.visiblePaneCount.v1'; const PANEL_A_EXPANDED_KEY = 'ui.panelA.expanded.v1'; const PANEL_B_EXPANDED_KEY = 'ui.panelB.expanded.v1'; const PANEL_C_EXPANDED_KEY = 'ui.panelC.expanded.v1'; @@ -46,6 +46,7 @@ const DEFAULT_SLOT_A: SlotState = { }; const EMPTY_SEARCH_FILTERS: Array<{ type: 'context' | 'title' | 'tag'; value: string }> = []; +const SLOT_ORDER: SlotId[] = ['A', 'B', 'C']; function createSingletonState(type: Exclude): SlotState { return { @@ -144,6 +145,7 @@ export default function ThreePanelLayout() { const [slotA, setSlotA] = usePersistentState(SLOT_A_KEY, DEFAULT_SLOT_A); const [slotB, setSlotB] = usePersistentState(SLOT_B_KEY, null); const [slotC, setSlotC] = usePersistentState(SLOT_C_KEY, null); + const [visiblePaneCount, setVisiblePaneCount] = usePersistentState(VISIBLE_PANE_COUNT_KEY, 2); const [panelAExpanded, setPanelAExpanded] = usePersistentState(PANEL_A_EXPANDED_KEY, true); const [panelBExpanded, setPanelBExpanded] = usePersistentState(PANEL_B_EXPANDED_KEY, false); @@ -233,30 +235,23 @@ export default function ThreePanelLayout() { } }, [setSlotA, setSlotB, setSlotC]); + const visibleSlots = useMemo( + () => SLOT_ORDER.slice(0, Math.max(1, Math.min(3, visiblePaneCount))), + [visiblePaneCount] + ); + const isPanelExpanded = useCallback((slot: SlotId) => { - switch (slot) { - case 'A': - return panelAExpanded; - case 'B': - return panelBExpanded; - case 'C': - return panelCExpanded; - } - }, [panelAExpanded, panelBExpanded, panelCExpanded]); + return visibleSlots.includes(slot); + }, [visibleSlots]); const setPanelExpanded = useCallback((slot: SlotId, expanded: boolean) => { - switch (slot) { - case 'A': - setPanelAExpanded(expanded); - break; - case 'B': - setPanelBExpanded(expanded); - break; - case 'C': - setPanelCExpanded(expanded); - break; + if (!expanded) { + return; } - }, [setPanelAExpanded, setPanelBExpanded, setPanelCExpanded]); + + const requiredCount = SLOT_ORDER.indexOf(slot) + 1; + setVisiblePaneCount((current) => Math.max(current, requiredCount)); + }, [setVisiblePaneCount]); const getPanelWeight = useCallback((slot: SlotId) => { switch (slot) { @@ -300,6 +295,18 @@ export default function ThreePanelLayout() { openNodeIdsRef.current = allOpenNodeIds; }, [allOpenNodeIds]); + useEffect(() => { + setPanelAExpanded(true); + setPanelBExpanded(visiblePaneCount >= 2); + setPanelCExpanded(visiblePaneCount >= 3); + }, [setPanelAExpanded, setPanelBExpanded, setPanelCExpanded, visiblePaneCount]); + + useEffect(() => { + if (!visibleSlots.includes(activePane)) { + setActivePane(visibleSlots[0] ?? 'A'); + } + }, [activePane, visibleSlots]); + const activeNodeId = useMemo(() => { const activeSlotState = slotStates[activePane]; const activeTab = activeSlotState ? getActiveTab(activeSlotState) : undefined; @@ -427,14 +434,7 @@ export default function ThreePanelLayout() { const tabId = createTabId(paneType); const setter = getSlotSetter(slot); - setter((prev) => { - const current = sanitizeSlotState(prev); - const existingTabs = current?.tabs ?? []; - const tabs = existingTabs.some((tab) => tab.id === tabId) - ? existingTabs - : [...existingTabs.filter((tab) => tab.type !== paneType), { id: tabId, type: paneType }]; - return { tabs, activeTabId: tabId }; - }); + setter({ tabs: [{ id: tabId, type: paneType }], activeTabId: tabId }); setPanelExpanded(slot, true); setActivePane(slot); @@ -446,12 +446,12 @@ export default function ThreePanelLayout() { setter((prev) => { const current = sanitizeSlotState(prev); - const tabs = current?.tabs ?? []; - if (tabs.some((tab) => tab.id === nodeTabId)) { - return { tabs, activeTabId: nodeTabId }; + const nodeTabs = (current?.tabs ?? []).filter((tab) => tab.type === 'node'); + if (nodeTabs.some((tab) => tab.id === nodeTabId)) { + return { tabs: nodeTabs, activeTabId: nodeTabId }; } return { - tabs: [...tabs, { id: nodeTabId, type: 'node', nodeId }], + tabs: [...nodeTabs, { id: nodeTabId, type: 'node', nodeId }], activeTabId: nodeTabId, }; }); @@ -481,32 +481,48 @@ export default function ThreePanelLayout() { }, [getSlotSetter]); const openNodeFromSlot = useCallback((nodeId: number, fromSlot?: SlotId) => { - for (const slot of ['A', 'B', 'C'] as SlotId[]) { + const existingTabId = createTabId('node', nodeId); + + for (const slot of visibleSlots) { const state = getSlotState(slot); - if (state?.tabs.some((tab) => tab.type === 'node' && tab.nodeId === nodeId)) { - getSlotSetter(slot)({ tabs: state.tabs, activeTabId: createTabId('node', nodeId) }); + if (state?.tabs.some((tab) => tab.id === existingTabId)) { + getSlotSetter(slot)({ tabs: state.tabs, activeTabId: existingTabId }); setActivePane(slot); return; } } - const preferredOrder: SlotId[] = fromSlot === 'A' - ? ['B', 'C', 'A'] - : fromSlot === 'B' - ? ['C', 'A', 'B'] - : fromSlot === 'C' - ? ['B', 'A', 'C'] - : ['B', 'C', 'A']; + const visibleNodeSlots = visibleSlots.filter((slot) => { + const state = getSlotState(slot); + return state?.tabs.some((tab) => tab.type === 'node'); + }); - const target = preferredOrder.find((slot) => !getSlotState(slot)) - ?? preferredOrder.find((slot) => !isPanelExpanded(slot)) - ?? preferredOrder[0]; + const preferredNodeTarget = fromSlot && visibleNodeSlots.includes(fromSlot) + ? fromSlot + : visibleNodeSlots.includes(activePane) + ? activePane + : visibleNodeSlots[0]; + + if (preferredNodeTarget) { + addNodeTabToSlot(preferredNodeTarget, nodeId); + setActivePane(preferredNodeTarget); + return; + } + + const emptyTarget = visibleSlots.find((slot) => { + const state = getSlotState(slot); + return !state || state.tabs.length === 0; + }); + const target = emptyTarget + ?? (visibleSlots.includes(activePane) ? activePane : null) + ?? visibleSlots[visibleSlots.length - 1] + ?? 'A'; addNodeTabToSlot(target, nodeId); - }, [addNodeTabToSlot, getSlotSetter, getSlotState, isPanelExpanded]); + }, [activePane, addNodeTabToSlot, getSlotSetter, getSlotState, visibleSlots]); const openPaneSingleton = useCallback((paneType: Exclude, preferredSlot?: SlotId) => { - for (const slot of ['A', 'B', 'C'] as SlotId[]) { + for (const slot of visibleSlots) { const state = getSlotState(slot); if (state?.tabs.some((tab) => tab.type === paneType)) { getSlotSetter(slot)({ tabs: state.tabs, activeTabId: createTabId(paneType) }); @@ -516,17 +532,21 @@ export default function ThreePanelLayout() { } } - const orderedSlots: SlotId[] = preferredSlot - ? [preferredSlot, ...(['A', 'B', 'C'] as SlotId[]).filter((slot) => slot !== preferredSlot)] - : ['A', 'B', 'C']; - - const target = orderedSlots.find((slot) => !getSlotState(slot)) - ?? orderedSlots.find((slot) => !isPanelExpanded(slot)) - ?? activePane; + const orderedSlots = preferredSlot && visibleSlots.includes(preferredSlot) + ? [preferredSlot, ...visibleSlots.filter((slot) => slot !== preferredSlot)] + : visibleSlots; + const emptyTarget = orderedSlots.find((slot) => { + const state = getSlotState(slot); + return !state || state.tabs.length === 0; + }); + const target = emptyTarget + ?? (orderedSlots.includes(activePane) ? activePane : null) + ?? orderedSlots[orderedSlots.length - 1] + ?? 'A'; upsertSingletonTab(target, paneType); return target; - }, [activePane, getSlotSetter, getSlotState, isPanelExpanded, setPanelExpanded, upsertSingletonTab]); + }, [activePane, getSlotSetter, getSlotState, setPanelExpanded, upsertSingletonTab, visibleSlots]); const handleTabSelect = useCallback((slot: SlotId, tabId: string) => { const state = getSlotState(slot); @@ -632,12 +652,11 @@ export default function ThreePanelLayout() { const closeActiveSlot = useCallback((slot: SlotId) => { getSlotSetter(slot)(null); - setPanelExpanded(slot, false); if (activePane === slot) { - const fallback = (['A', 'B', 'C'] as SlotId[]).find((candidate) => candidate !== slot && isPanelExpanded(candidate)) ?? 'A'; + const fallback = visibleSlots.find((candidate) => candidate !== slot) ?? 'A'; setActivePane(fallback); } - }, [activePane, getSlotSetter, isPanelExpanded, setPanelExpanded]); + }, [activePane, getSlotSetter, visibleSlots]); const handleSwapPanes = useCallback((source: SlotId, target: SlotId) => { if (source === target) return; @@ -781,11 +800,9 @@ export default function ThreePanelLayout() { externalContextFilterId={browseContextFilters[slot]} onContextFilterSelect={(contextId) => { setBrowseContextFilters((prev) => ({ ...prev, [slot]: contextId })); - setActiveContextId(contextId); }} onClearExternalContextFilter={() => { setBrowseContextFilters((prev) => ({ ...prev, [slot]: null })); - setActiveContextId(null); }} /> ); @@ -819,89 +836,25 @@ export default function ThreePanelLayout() { const getSlotContainerStyle = (slot: SlotId) => { const state = slotStates[slot]; - const expanded = isPanelExpanded(slot); const weight = getPanelWeight(slot); return { - flex: expanded ? `${weight} ${weight} 0` : '0 0 44px', - minWidth: expanded ? 0 : '44px', + flex: `${weight} ${weight} 0`, + minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' as const, - background: expanded ? 'var(--rah-bg-surface)' : 'var(--rah-bg-subtle)', + background: 'var(--rah-bg-surface)', borderRadius: '10px', - border: expanded && state ? '1px solid transparent' : '1px dashed var(--rah-border)', + border: state ? '1px solid transparent' : '1px dashed var(--rah-border)', outline: dragOverSlot === slot ? '2px dashed var(--rah-accent-green)' : 'none', outlineOffset: '-4px', transition: 'outline 0.15s ease, background 0.15s ease', }; }; - const renderCollapsedPanel = (slot: SlotId) => ( -
- -
- ); - const renderExpandedEmptyPanel = (slot: SlotId) => (
-
{ - event.dataTransfer.setData('application/x-rah-pane', slot); - event.dataTransfer.effectAllowed = 'move'; - }} - style={{ - minHeight: '48px', - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - padding: '8px 12px', - cursor: 'grab', - }} - > - - -
Select a pane from the nav
@@ -923,6 +876,8 @@ export default function ThreePanelLayout() { onSearchClick={() => setShowSearchModal(true)} onAddStuffClick={() => setShowAddStuff(true)} onRefreshClick={handleRefreshAll} + visiblePaneCount={visiblePaneCount as 1 | 2 | 3} + onVisiblePaneCountChange={setVisiblePaneCount as (count: 1 | 2 | 3) => void} onSettingsClick={() => { setSettingsInitialTab(undefined); setShowSettings(true); @@ -947,9 +902,8 @@ export default function ThreePanelLayout() { />
- {(['A', 'B', 'C'] as SlotId[]).flatMap((slot, index, allSlots) => { + {visibleSlots.flatMap((slot, index) => { const state = slotStates[slot]; - const expanded = isPanelExpanded(slot); const items: React.ReactNode[] = []; items.push( @@ -964,12 +918,12 @@ export default function ThreePanelLayout() { onDrop={(event) => handleSlotDrop(event, slot)} style={getSlotContainerStyle(slot)} > - {!expanded ? renderCollapsedPanel(slot) : state ? renderSlot(slot, state) : renderExpandedEmptyPanel(slot)} + {state ? renderSlot(slot, state) : renderExpandedEmptyPanel(slot)}
); - const nextSlot = allSlots[index + 1]; - if (nextSlot && expanded && isPanelExpanded(nextSlot)) { + const nextSlot = visibleSlots[index + 1]; + if (nextSlot) { items.push( (null); const modalRef = useRef(null); const returnFocusRef = useRef(null); + const existingFiltersKey = useMemo( + () => JSON.stringify(existingFilters), + [existingFilters] + ); // Store the element that triggered the modal for return focus useEffect(() => { @@ -94,18 +98,24 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil // Generate suggestions based on search query useEffect(() => { - if (!searchQuery.trim()) { - setSuggestions((prev) => (prev.length === 0 ? prev : [])); - setSelectedIndex(0); + if (!isOpen) { return; } + if (!searchQuery.trim()) { + setSuggestions((current) => (current.length === 0 ? current : [])); + setSelectedIndex((current) => (current === 0 ? current : 0)); + return; + } + + let cancelled = false; + const fetchSuggestions = async () => { try { const response = await fetch(`/api/nodes/search?q=${encodeURIComponent(searchQuery)}&limit=20`); const result = await response.json(); - if (result.success) { + if (!cancelled && result.success) { const nodeSuggestions: NodeSuggestion[] = result.data.map((node: any) => ({ id: node.id, title: node.title, @@ -116,14 +126,19 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil setSelectedIndex(0); } } catch (error) { - console.error('Error fetching suggestions:', error); - setSuggestions([]); + if (!cancelled) { + console.error('Error fetching suggestions:', error); + setSuggestions([]); + } } }; const timeoutId = setTimeout(fetchSuggestions, 200); - return () => clearTimeout(timeoutId); - }, [searchQuery]); + return () => { + cancelled = true; + clearTimeout(timeoutId); + }; + }, [isOpen, searchQuery, existingFiltersKey]); // Handle keyboard navigation const handleKeyDown = (e: React.KeyboardEvent) => { @@ -204,9 +219,11 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil onMouseEnter={() => setSelectedIndex(index)} className={`search-result-item ${index === selectedIndex ? 'selected' : ''}`} > - {suggestion.id} {getNodeIcon(suggestion as any, 14)} - {suggestion.title} + + {suggestion.title} + {suggestion.id} + {index === selectedIndex && ( )} @@ -334,19 +351,11 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil .search-result-item.selected { background: var(--rah-bg-active); } - + .result-id { - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 10px; - font-weight: 600; + font-size: 11px; font-family: 'SF Mono', 'Fira Code', monospace; - color: var(--rah-text-inverse); - background: var(--rah-accent-green); - padding: 4px 8px; - border-radius: 6px; - min-width: 28px; + color: var(--rah-text-muted); flex-shrink: 0; } @@ -356,8 +365,15 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil flex-shrink: 0; } - .result-title { + .result-main { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; flex: 1; + } + + .result-title { color: var(--rah-text-base); font-size: 15px; overflow: hidden; diff --git a/src/components/panes/PaneHeader.tsx b/src/components/panes/PaneHeader.tsx index 14b1683..29cc87f 100644 --- a/src/components/panes/PaneHeader.tsx +++ b/src/components/panes/PaneHeader.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from 'react'; -import { X } from 'lucide-react'; +import { GripVertical, X } from 'lucide-react'; import { PaneHeaderProps } from './types'; export default function PaneHeader({ @@ -50,9 +50,6 @@ export default function PaneHeader({ return (
+ {slot && onSwapPanes ? ( +
+ +
+ ) : null} +
{tabBar ? (
@@ -96,6 +117,7 @@ export default function PaneHeader({ ) : null}
+ {/* Close button (when onCollapse is provided) */} {onCollapse && ( - )} -
- - -
- - {/* Sort dropdown */} -
- - - {showSortDropdown && ( -
- {(Object.keys(SORT_LABELS) as SortOrder[]).map(key => ( - - ))} -
+ /> + {activeSearch && ( + )}
+ - {/* Pagination */} -
- {total > 0 ? `${startItem}-${endItem} of ${total}` : '0 nodes'} - + + {showSortDropdown && ( +
- - - -
+ {(Object.keys(SORT_LABELS) as SortOrder[]).map((key) => ( + + ))} +
+ )}
+ +
+ {loading ? 'Loading…' : `${nodes.length} nodes`} +
+
); return (
{toolbarHost ? createPortal(toolbar, toolbarHost) : ( -
+
{toolbar}
)} - {/* Table */} -
- {loading ? ( -
Loading...
- ) : nodes.length === 0 ? ( -
- {activeSearch ? 'No nodes match your search.' : 'No nodes yet.'} -
- ) : ( - - - - - - - - - - - - - - - - - - - - - {nodes.map((node, i) => { - const metaStr = node.metadata - ? (typeof node.metadata === 'string' ? node.metadata : JSON.stringify(node.metadata)) - : ''; +
+
+ + Title + ID + Context + Edges + Description + Updated + Source +
+ +
setScrollTop(e.currentTarget.scrollTop)} + style={{ flex: 1, minHeight: 0, overflow: 'auto' }} + > + {loading ? ( +
Loading...
+ ) : nodes.length === 0 ? ( +
+ {activeSearch ? 'No nodes match your search.' : 'No nodes yet.'} +
+ ) : ( +
+ {visibleRows.map((node) => { + const processed = getNodeProcessedState(node.metadata) === 'processed'; + const sourceSignal = getSourceSignal(node); + const description = node.description?.replace(/\s+/g, ' ').trim() || '—'; + return ( -
onNodeClick(node.id)} onMouseEnter={() => setHoveredRow(node.id)} onMouseLeave={() => setHoveredRow(null)} style={{ - height: '44px', + width: '100%', + height: `${ROW_HEIGHT}px`, + display: 'grid', + gridTemplateColumns: '40px minmax(240px, 2fr) 72px 140px 64px minmax(220px, 1.3fr) 110px minmax(180px, 1fr)', + gap: '12px', + alignItems: 'center', + border: 'none', + borderBottom: '1px solid var(--rah-border)', + background: hoveredRow === node.id ? 'var(--rah-bg-panel)' : 'transparent', + color: 'inherit', + textAlign: 'left', + padding: '0 8px', cursor: 'pointer', - background: hoveredRow === node.id - ? 'var(--rah-bg-panel)' - : i % 2 === 0 ? 'var(--rah-bg-base)' : 'var(--rah-bg-subtle)', - transition: 'background 0.1s ease', }} > - {/* Title */} - + ) : ( + + )} + - {/* ID */} - + + {node.edge_count ?? 0} + - {/* Description */} - + + {description} + - {/* Source */} - + + {formatRelativeDate(node.updated_at)} + - {/* Link */} - - - {/* Context */} - - - {/* Edges */} - - - {/* Event Date */} - - - {/* Updated */} - - - {/* Created */} - - - {/* Metadata */} - - - {/* Chunk */} - - - {/* Chunk Status */} - - - {/* Embedding Updated */} - - + + {sourceSignal} + + ); })} - -
TITLEIDDESCRIPTIONSOURCELINKCONTEXTEDGESEVENTUPDATEDCREATEDMETADATACHUNKCHUNK STATUSEMB UPDATED
-
- - {node.title || 'Untitled'} + + {processed ? : null} + + + + + {node.title || 'Untitled'} + + + + + {node.id} + + + + {node.context?.name ? ( + + {node.context.name} -
-
- - {node.id} - - -
- - {node.description || '\u2014'} - -
-
-
- - {node.source ? node.source.slice(0, 120) : '\u2014'} - -
-
-
- {node.link ? ( - - - - {node.link.replace(/^https?:\/\/(www\.)?/, '')} - - - ) : ( - {'\u2014'} - )} -
-
-
- {node.context?.name ? ( - <> - - {node.context.name} - - - ) : ( - {'\u2014'} - )} -
-
- - {node.edge_count ?? 0} - - - - {formatDate(node.event_date)} - - - - {formatRelativeDate(node.updated_at)} - - - - {formatRelativeDate(node.created_at)} - - -
- - {metaStr || '\u2014'} - -
-
-
- - {node.source ? node.source.slice(0, 100) : '\u2014'} - -
-
- - {node.chunk_status || '\u2014'} - - - - {node.embedding_updated_at ? formatRelativeDate(node.embedding_updated_at) : '\u2014'} - -
- )} +
+ )} +
); } - -function thStyle(extra: React.CSSProperties = {}): React.CSSProperties { - return { - position: 'sticky' as const, - top: 0, - background: 'var(--rah-bg-base)', - padding: '8px 12px', - fontSize: '10px', - fontWeight: 500, - color: 'var(--rah-text-muted)', - textAlign: 'left', - letterSpacing: '0.05em', - whiteSpace: 'nowrap', - borderBottom: '1px solid var(--rah-border)', - zIndex: 1, - ...extra, - }; -} - -function tdStyle(extra: React.CSSProperties = {}): React.CSSProperties { - return { - padding: '0 12px', - verticalAlign: 'middle', - borderBottom: '1px solid var(--rah-border)', - overflow: 'hidden', - ...extra, - }; -} - -const truncCell: React.CSSProperties = { - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', -}; diff --git a/src/components/views/ViewsOverlay.tsx b/src/components/views/ViewsOverlay.tsx index 9c6f04a..ae82bf8 100644 --- a/src/components/views/ViewsOverlay.tsx +++ b/src/components/views/ViewsOverlay.tsx @@ -502,31 +502,6 @@ export default function ViewsOverlay({
)} -
+
+ {nodeIcon} +
+ + #{node.id} + {node.title || 'Untitled'} - {node.context?.name ? ( - - {node.context.name} - - ) : null}
@@ -589,50 +571,32 @@ export default function ViewsOverlay({ overflow: 'hidden', flexShrink: 0, }}> -
- {nodeIcon} -
- - #{node.id} - - {node.edge_count != null && node.edge_count > 0 ? ( - { + e.stopPropagation(); + void toggleNodeProcessed(node); + }} + title="Toggle processed" + aria-label="Toggle processed" + style={{ + width: '20px', + height: '20px', + borderRadius: '6px', + border: `1px solid ${isProcessed ? 'rgba(74, 222, 128, 0.7)' : 'var(--rah-border-strong)'}`, + background: isProcessed ? 'rgba(74, 222, 128, 0.16)' : 'var(--rah-bg-panel)', + color: isProcessed ? '#86efac' : 'var(--rah-text-muted)', + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, - fontSize: '11px', - fontWeight: 600, - }}> - {node.edge_count} - - ) : null} + cursor: 'pointer', + boxShadow: isProcessed ? 'inset 0 0 0 1px rgba(74, 222, 128, 0.12)' : 'none', + transition: 'all 0.15s ease', + }} + > + +