sync: Major UI updates from private repo (Jan 16-24)
Features synced: - UI Panels Refactor: flexible pane system with ChatPane, NodePane, DimensionsPane, WorkflowsPane, ViewsPane, MapPane - Source Content Reader: content type detection + 4 formatters (transcript, book, markdown, raw) - Source Content Search: Cmd+F search in Source Reader - Feed Layout Overhaul: LeftToolbar, SplitHandle, CollapsedRail - Map Panel: promoted to first-class pane - Edge policy simplification: auto-inference + direction correction New files: - src/components/panes/* (pane system) - src/components/layout/CollapsedRail.tsx - src/components/layout/LeftToolbar.tsx - src/components/layout/SplitHandle.tsx - src/components/focus/source/* (Source Reader + formatters) - src/components/views/ViewsOverlay.tsx Updated: - ThreePanelLayout.tsx (complete refactor) - FocusPanel.tsx (Source tab integration) - API routes (dimensions GET, edges auto-inference) - Database services (nodes, edges, dimensions) Dependencies added: - react-markdown - remark-gfm Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
cea1df7f1f
commit
05523b7cb2
@@ -12,16 +12,26 @@ interface QuickAddSubmitPayload {
|
||||
interface QuickAddInputProps {
|
||||
activeDelegations: AgentDelegation[];
|
||||
onSubmit: (payload: QuickAddSubmitPayload) => Promise<void>;
|
||||
// External control (optional - if provided, component is controlled)
|
||||
isOpen?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddInputProps) {
|
||||
export default function QuickAddInput({ activeDelegations, onSubmit, isOpen, onClose }: QuickAddInputProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const [isPosting, setIsPosting] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isExpandedInternal, setIsExpandedInternal] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
|
||||
// Support both controlled (isOpen/onClose) and uncontrolled (internal state) modes
|
||||
const isControlled = isOpen !== undefined;
|
||||
const isExpanded = isControlled ? isOpen : isExpandedInternal;
|
||||
const setIsExpanded = isControlled
|
||||
? (value: boolean) => { if (!value && onClose) onClose(); }
|
||||
: setIsExpandedInternal;
|
||||
|
||||
const maxConcurrent = 5;
|
||||
const activeCount = activeDelegations.filter(
|
||||
(d) => d.status === 'queued' || d.status === 'in_progress'
|
||||
@@ -149,8 +159,12 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
||||
|
||||
const hasContent = input.trim() || uploadedFile;
|
||||
|
||||
// Collapsed state - prominent "ADD STUFF" button
|
||||
// Collapsed state - only show button if NOT controlled externally
|
||||
if (!isExpanded) {
|
||||
// In controlled mode, don't render anything when closed
|
||||
if (isControlled) return null;
|
||||
|
||||
// Uncontrolled mode - show the "ADD STUFF" button
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsExpanded(true)}
|
||||
@@ -201,14 +215,10 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded state - full width overlay
|
||||
return (
|
||||
// Expanded state - modal overlay (centered if controlled, absolute if uncontrolled)
|
||||
const modalContent = (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '60px',
|
||||
left: '20px',
|
||||
right: '20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
@@ -216,13 +226,15 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
||||
padding: '16px',
|
||||
borderRadius: '12px',
|
||||
border: dragOver ? '1px solid #22c55e' : '1px solid #2a2a2a',
|
||||
zIndex: 100,
|
||||
animation: 'fadeIn 150ms ease-out',
|
||||
transition: 'border-color 0.15s ease'
|
||||
transition: 'border-color 0.15s ease',
|
||||
width: isControlled ? '500px' : 'auto',
|
||||
maxWidth: isControlled ? '90vw' : 'none',
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
@@ -534,4 +546,37 @@ export default function QuickAddInput({ activeDelegations, onSubmit }: QuickAddI
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
// In controlled mode, wrap with backdrop
|
||||
if (isControlled) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={() => setIsExpanded(false)}
|
||||
>
|
||||
{modalContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Uncontrolled mode - render with absolute positioning
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '60px',
|
||||
left: '20px',
|
||||
right: '20px',
|
||||
zIndex: 100,
|
||||
}}>
|
||||
{modalContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,10 +22,17 @@ const createVoiceRequestId = () =>
|
||||
? crypto.randomUUID()
|
||||
: `voice_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
interface HighlightedPassage {
|
||||
selectedText: string;
|
||||
nodeId: number;
|
||||
nodeTitle: string;
|
||||
}
|
||||
|
||||
interface RAHChatProps {
|
||||
openTabsData: Node[];
|
||||
activeTabId: number | null;
|
||||
activeDimension?: string | null;
|
||||
onClearDimension?: () => void;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
delegations?: AgentDelegation[];
|
||||
messages?: ChatMessage[];
|
||||
@@ -33,12 +40,16 @@ interface RAHChatProps {
|
||||
mode?: 'easy' | 'hard';
|
||||
delegationMode?: boolean;
|
||||
delegationSessionId?: string;
|
||||
onQuickAdd?: () => void;
|
||||
highlightedPassage?: HighlightedPassage | null;
|
||||
onClearPassage?: () => void;
|
||||
}
|
||||
|
||||
export default function RAHChat({
|
||||
openTabsData,
|
||||
activeTabId,
|
||||
activeDimension,
|
||||
onClearDimension: _onClearDimension,
|
||||
onNodeClick,
|
||||
delegations = [],
|
||||
messages: externalMessages,
|
||||
@@ -46,6 +57,9 @@ export default function RAHChat({
|
||||
mode = 'easy',
|
||||
delegationMode = false,
|
||||
delegationSessionId,
|
||||
onQuickAdd: _onQuickAdd,
|
||||
highlightedPassage: _highlightedPassage,
|
||||
onClearPassage: _onClearPassage,
|
||||
}: RAHChatProps) {
|
||||
// Use external state if provided (lifted state), otherwise use local state
|
||||
const [internalMessages, internalSetMessages] = useState<ChatMessage[]>([]);
|
||||
|
||||
+429
-384
@@ -10,6 +10,7 @@ import { Node, NodeConnection, Chunk } from '@/types/database';
|
||||
import DimensionTags from './dimensions/DimensionTags';
|
||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
import ConfirmDialog from '../common/ConfirmDialog';
|
||||
import { SourceReader } from './source';
|
||||
|
||||
interface PopularDimension {
|
||||
dimension: string;
|
||||
@@ -36,10 +37,17 @@ interface FocusPanelProps {
|
||||
onTabClose: (nodeId: number) => void;
|
||||
refreshTrigger?: number;
|
||||
onReorderTabs?: (fromIndex: number, toIndex: number) => void;
|
||||
onOpenInOtherSlot?: (nodeId: number) => void;
|
||||
hideTabBar?: boolean;
|
||||
onTextSelect?: (nodeId: number, nodeTitle: string, text: string) => void;
|
||||
highlightedPassage?: { nodeId: number; selectedText: string } | null;
|
||||
}
|
||||
|
||||
export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeClick, onTabClose, refreshTrigger }: FocusPanelProps) {
|
||||
const [nodesData, setNodesData] = useState<Record<number, Node>>({});
|
||||
export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeClick, onTabClose, refreshTrigger, onOpenInOtherSlot, hideTabBar, onTextSelect, highlightedPassage }: FocusPanelProps) {
|
||||
const [nodesData, setNodesData] = useState<Record<number, Node>>({});
|
||||
|
||||
// Context menu state
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tabId: number } | null>(null);
|
||||
const [loadingNodes, setLoadingNodes] = useState<Set<number>>(new Set());
|
||||
const [editingField, setEditingField] = useState<string | null>(null);
|
||||
const [editingValue, setEditingValue] = useState<string>('');
|
||||
@@ -62,6 +70,21 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
}
|
||||
}, [showReembedPrompt]);
|
||||
|
||||
// Close context menu when clicking outside
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
const handleClick = () => setContextMenu(null);
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setContextMenu(null);
|
||||
};
|
||||
document.addEventListener('click', handleClick);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [contextMenu]);
|
||||
|
||||
// Edges state management (following same patterns as nodes)
|
||||
const [edgesData, setEdgesData] = useState<{ [key: number]: NodeConnection[] }>({});
|
||||
const [loadingEdges, setLoadingEdges] = useState<Set<number>>(new Set());
|
||||
@@ -112,11 +135,40 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
const [sourceEditValue, setSourceEditValue] = useState('');
|
||||
const [sourceSaving, setSourceSaving] = useState(false);
|
||||
|
||||
// Source reader mode: 'raw' (monospace) or 'reader' (formatted typography)
|
||||
const [sourceReaderMode, setSourceReaderMode] = useState<'raw' | 'reader'>('reader');
|
||||
|
||||
// Embedded chunks state (actual chunks from chunks table)
|
||||
const [chunksData, setChunksData] = useState<Record<number, Chunk[]>>({});
|
||||
const [loadingChunks, setLoadingChunks] = useState<Set<number>>(new Set());
|
||||
const [chunksExpanded, setChunksExpanded] = useState<Record<number, boolean>>({});
|
||||
|
||||
// Helper: preview edge type based on heuristics (mirrors backend logic)
|
||||
const previewEdgeType = (explanation: string): { type: string; label: string } | null => {
|
||||
const norm = (explanation || '').trim().toLowerCase();
|
||||
if (!norm) return null;
|
||||
|
||||
const startsWithAny = (prefixes: string[]) => prefixes.some((p) => norm.startsWith(p));
|
||||
|
||||
if (startsWithAny(['created by', 'made by', 'authored by', 'written by', 'founded by'])) {
|
||||
return { type: 'created_by', label: 'created by' };
|
||||
}
|
||||
if (startsWithAny(['part of', 'episode of', 'belongs to', 'in the series', 'in this series'])) {
|
||||
return { type: 'part_of', label: 'part of' };
|
||||
}
|
||||
if (startsWithAny(['features', 'mentions', 'hosted by', 'guest:', 'host:'])) {
|
||||
return { type: 'part_of', label: 'part of' };
|
||||
}
|
||||
if (startsWithAny(['came from', 'inspired by', 'derived from', 'from'])) {
|
||||
return { type: 'source_of', label: 'source of' };
|
||||
}
|
||||
if (startsWithAny(['related to', 'related'])) {
|
||||
return { type: 'related_to', label: 'related to' };
|
||||
}
|
||||
// No heuristic match - will be AI-inferred
|
||||
return { type: 'inferred', label: 'will be inferred' };
|
||||
};
|
||||
|
||||
// Fetch priority dimensions on mount
|
||||
useEffect(() => {
|
||||
fetchPriorityDimensions();
|
||||
@@ -184,6 +236,20 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
}
|
||||
}, [refreshTrigger, activeTab]);
|
||||
|
||||
// Clear editing state when switching nodes
|
||||
useEffect(() => {
|
||||
// Clear all edit modes when switching to a different node
|
||||
setEditingField(null);
|
||||
setEditingValue('');
|
||||
setEditingNodeId(null);
|
||||
// Also clear notes/desc/source edit modes
|
||||
setNotesEditMode(false);
|
||||
setNotesEditValue('');
|
||||
setDescEditMode(false);
|
||||
setDescEditValue('');
|
||||
setSourceEditMode(false);
|
||||
setSourceEditValue('');
|
||||
}, [activeTab]);
|
||||
|
||||
const fetchPriorityDimensions = async () => {
|
||||
try {
|
||||
@@ -1024,9 +1090,9 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
|
||||
// Edge management functions (following same patterns as node functions)
|
||||
|
||||
const handleEdgeNodeSelect = (targetNodeId: number, _targetNodeTitle?: string) => {
|
||||
setPendingEdgeTarget({ id: targetNodeId, title: _targetNodeTitle || `Node ${targetNodeId}` });
|
||||
setEdgeExplanation('');
|
||||
const handleEdgeNodeSelect = async (targetNodeId: number, _targetNodeTitle?: string) => {
|
||||
// Immediately create edge - backend auto-infers explanation and type
|
||||
await createEdgeAuto(targetNodeId);
|
||||
};
|
||||
|
||||
// Handle node search keyboard navigation
|
||||
@@ -1043,19 +1109,16 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectNodeSuggestion = (suggestion: NodeSearchResult) => {
|
||||
setPendingEdgeTarget({ id: suggestion.id, title: suggestion.title });
|
||||
setEdgeExplanation('');
|
||||
const handleSelectNodeSuggestion = async (suggestion: NodeSearchResult) => {
|
||||
// Immediately create edge - backend auto-infers explanation and type
|
||||
await createEdgeAuto(suggestion.id);
|
||||
setNodeSearchSuggestions([]);
|
||||
setNodeSearchQuery('');
|
||||
};
|
||||
|
||||
const createEdgeWithExplanation = async (targetNodeId: number, explanation: string) => {
|
||||
// Auto-create edge - backend handles explanation generation and type inference
|
||||
const createEdgeAuto = async (targetNodeId: number) => {
|
||||
if (activeNodeId === null) return;
|
||||
const trimmed = (explanation || '').trim();
|
||||
if (!trimmed) {
|
||||
alert('Please add a short explanation for why this connection exists.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/edges', {
|
||||
method: 'POST',
|
||||
@@ -1066,7 +1129,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
from_node_id: activeNodeId,
|
||||
to_node_id: targetNodeId,
|
||||
source: 'user',
|
||||
explanation: trimmed
|
||||
explanation: '' // Empty - backend will auto-generate
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1076,7 +1139,43 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
|
||||
// Refresh edges data
|
||||
await fetchEdgesData(activeNodeId);
|
||||
|
||||
|
||||
// Reset state
|
||||
setAddingEdge(null);
|
||||
setEdgeExplanation('');
|
||||
setPendingEdgeTarget(null);
|
||||
setShowConnectionsModal(false);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating edge:', error);
|
||||
alert('Failed to create edge. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
// Legacy function for manual explanation (kept for compatibility)
|
||||
const createEdgeWithExplanation = async (targetNodeId: number, explanation: string) => {
|
||||
if (activeNodeId === null) return;
|
||||
try {
|
||||
const response = await fetch('/api/edges', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from_node_id: activeNodeId,
|
||||
to_node_id: targetNodeId,
|
||||
source: 'user',
|
||||
explanation: explanation || '' // Backend handles empty
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create edge');
|
||||
}
|
||||
|
||||
// Refresh edges data
|
||||
await fetchEdgesData(activeNodeId);
|
||||
|
||||
// Reset state
|
||||
setAddingEdge(null);
|
||||
setEdgeExplanation('');
|
||||
@@ -1084,7 +1183,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
setNodeSearchQuery('');
|
||||
setNodeSearchSuggestions([]);
|
||||
setShowConnectionsModal(false);
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating edge:', error);
|
||||
alert('Failed to create edge. Please try again.');
|
||||
@@ -1264,122 +1363,6 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Explanation (required) */}
|
||||
{pendingEdgeTarget && (
|
||||
<div style={{
|
||||
marginTop: '10px',
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #262626',
|
||||
borderRadius: '12px',
|
||||
padding: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px'
|
||||
}}>
|
||||
<div style={{ color: '#e5e5e5', fontSize: '13px', fontWeight: 500 }}>
|
||||
Connecting to: <span style={{ color: '#a3e635' }}>{pendingEdgeTarget.title}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ label: 'Made by', value: 'Created by ' },
|
||||
{ label: 'Part of', value: 'Part of ' },
|
||||
{ label: 'Came from', value: 'Came from ' },
|
||||
{ label: 'Related', value: 'Related to ' },
|
||||
].map((chip) => (
|
||||
<button
|
||||
key={chip.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEdgeExplanation((prev) => {
|
||||
const trimmed = (prev || '').trim();
|
||||
return trimmed.length > 0 ? prev : chip.value;
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
fontSize: '12px',
|
||||
borderRadius: '999px',
|
||||
border: '1px solid #262626',
|
||||
background: '#141414',
|
||||
color: '#e5e5e5',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = '#1a1a1a'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = '#141414'; }}
|
||||
title={`Prefill: ${chip.value.trim()}`}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
value={edgeExplanation}
|
||||
onChange={(e) => setEdgeExplanation(e.target.value)}
|
||||
placeholder="Why does this connect? (e.g., 'Author of this book', 'Inspired this insight')"
|
||||
rows={2}
|
||||
style={{
|
||||
width: '100%',
|
||||
resize: 'vertical',
|
||||
background: '#141414',
|
||||
border: '1px solid #1f1f1f',
|
||||
color: '#fafafa',
|
||||
borderRadius: '10px',
|
||||
padding: '10px',
|
||||
fontSize: '13px',
|
||||
outline: 'none',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
createEdgeWithExplanation(pendingEdgeTarget.id, edgeExplanation);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setPendingEdgeTarget(null);
|
||||
setEdgeExplanation('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setPendingEdgeTarget(null);
|
||||
setEdgeExplanation('');
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
background: 'transparent',
|
||||
border: '1px solid #262626',
|
||||
color: '#a3a3a3',
|
||||
borderRadius: '10px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => createEdgeWithExplanation(pendingEdgeTarget.id, edgeExplanation)}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
background: '#22c55e',
|
||||
border: '1px solid #16a34a',
|
||||
color: '#0a0a0a',
|
||||
borderRadius: '10px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600
|
||||
}}
|
||||
>
|
||||
Create connection
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ color: '#737373', fontSize: '11px' }}>
|
||||
Tip: press <span style={{ fontFamily: 'monospace' }}>⌘</span>+<span style={{ fontFamily: 'monospace' }}>Enter</span> to create.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Existing Connections */}
|
||||
@@ -1401,6 +1384,16 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
<div key={connection.id} style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
{/* Direction arrow: → if current node is FROM (outgoing), ← if current node is TO (incoming) */}
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
color: connection.edge.from_node_id === activeTab ? '#22c55e' : '#f59e0b',
|
||||
fontWeight: 600,
|
||||
width: '16px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{connection.edge.from_node_id === activeTab ? '→' : '←'}
|
||||
</span>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
fontSize: '10px',
|
||||
@@ -1622,86 +1615,100 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
return (
|
||||
<>
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: 'transparent' }}>
|
||||
{/* Tab Bar */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
background: '#0f0f0f',
|
||||
flexShrink: 0,
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden'
|
||||
}}>
|
||||
{openTabs.length === 0 ? (
|
||||
<div style={{
|
||||
padding: '10px 16px',
|
||||
fontSize: '12px',
|
||||
color: '#666'
|
||||
}}>
|
||||
No tabs open
|
||||
</div>
|
||||
) : (
|
||||
openTabs.map((tabId) => {
|
||||
const node = nodesData[tabId];
|
||||
const isActive = activeTab === tabId;
|
||||
const label = node ? truncateTitle(node.title || 'Untitled') : 'Loading...';
|
||||
{/* Tab Bar - hidden when rendered from NodePane which has its own tab bar */}
|
||||
{!hideTabBar && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
background: '#0f0f0f',
|
||||
flexShrink: 0,
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden'
|
||||
}}>
|
||||
{openTabs.length === 0 ? (
|
||||
<div style={{
|
||||
padding: '10px 16px',
|
||||
fontSize: '12px',
|
||||
color: '#666'
|
||||
}}>
|
||||
No tabs open
|
||||
</div>
|
||||
) : (
|
||||
openTabs.map((tabId) => {
|
||||
const node = nodesData[tabId];
|
||||
const isActive = activeTab === tabId;
|
||||
const label = node ? truncateTitle(node.title || 'Untitled') : 'Loading...';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tabId}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRight: '1px solid #1a1a1a',
|
||||
background: isActive ? '#121212' : '#0f0f0f',
|
||||
borderBottom: isActive ? '2px solid #666' : 'none',
|
||||
paddingBottom: isActive ? '0' : '2px',
|
||||
minWidth: '120px',
|
||||
maxWidth: '200px'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => onTabSelect(tabId)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 12px',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'inherit',
|
||||
color: isActive ? '#fff' : '#999',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
return (
|
||||
<div
|
||||
key={tabId}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
const title = node?.title || 'Untitled';
|
||||
e.dataTransfer.effectAllowed = 'copyMove';
|
||||
e.dataTransfer.setData('application/x-rah-tab', JSON.stringify({ id: tabId, title }));
|
||||
e.dataTransfer.setData('text/plain', `[NODE:${tabId}:"${title}"]`);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTabClose(tabId);
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, tabId });
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'color 0.2s'
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRight: '1px solid #1a1a1a',
|
||||
background: isActive ? '#121212' : '#0f0f0f',
|
||||
borderBottom: isActive ? '2px solid #666' : 'none',
|
||||
paddingBottom: isActive ? '0' : '2px',
|
||||
minWidth: '120px',
|
||||
maxWidth: '200px',
|
||||
cursor: 'grab'
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onTabSelect(tabId)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 12px',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'inherit',
|
||||
color: isActive ? '#fff' : '#999',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTabClose(tabId);
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'color 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Area */}
|
||||
<div style={{
|
||||
@@ -2237,30 +2244,6 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Action buttons for Source tab */}
|
||||
{activeContentTab === 'source' && !sourceEditMode && (
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '4px' }}>
|
||||
<button
|
||||
onClick={startSourceEdit}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '10px',
|
||||
color: '#888',
|
||||
background: 'transparent',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
title="Edit source"
|
||||
>
|
||||
<Pencil size={12} />
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desc Tab Content */}
|
||||
@@ -2820,46 +2803,133 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'auto' }}>
|
||||
{nodesData[activeTab]?.chunk ? (
|
||||
<div
|
||||
style={{
|
||||
color: '#ccc',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.5',
|
||||
padding: '12px',
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #1a1a1a',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
flex: 1,
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
{nodesData[activeTab].chunk}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{/* Mode toggle and edit button */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '8px',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{/* Raw / Reader toggle */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
background: '#111',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #222',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setSourceReaderMode('raw')}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
fontSize: '10px',
|
||||
fontWeight: 500,
|
||||
color: sourceReaderMode === 'raw' ? '#fff' : '#666',
|
||||
background: sourceReaderMode === 'raw' ? '#2a2a2a' : 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
Raw
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSourceReaderMode('reader')}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
fontSize: '10px',
|
||||
fontWeight: 500,
|
||||
color: sourceReaderMode === 'reader' ? '#fff' : '#666',
|
||||
background: sourceReaderMode === 'reader' ? '#2a2a2a' : 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
Reader
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
|
||||
{/* Edit button */}
|
||||
<button
|
||||
onClick={startSourceEdit}
|
||||
style={{
|
||||
color: '#555',
|
||||
fontSize: '12px',
|
||||
fontStyle: 'italic',
|
||||
cursor: 'pointer',
|
||||
padding: '12px',
|
||||
border: '1px dashed #1a1a1a',
|
||||
borderRadius: '4px',
|
||||
textAlign: 'center',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
gap: '4px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '10px',
|
||||
color: '#666',
|
||||
background: 'transparent',
|
||||
border: '1px solid #222',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#888';
|
||||
e.currentTarget.style.borderColor = '#333';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#666';
|
||||
e.currentTarget.style.borderColor = '#222';
|
||||
}}
|
||||
>
|
||||
No source content. Click to add.
|
||||
</div>
|
||||
)}
|
||||
<Pencil size={10} />
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content display */}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{nodesData[activeTab]?.chunk ? (
|
||||
sourceReaderMode === 'reader' ? (
|
||||
<SourceReader
|
||||
content={nodesData[activeTab].chunk}
|
||||
onTextSelect={onTextSelect ? (text) => onTextSelect(activeTab, nodesData[activeTab]?.title || 'Untitled', text) : undefined}
|
||||
highlightedText={highlightedPassage?.nodeId === activeTab ? highlightedPassage.selectedText : null}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
color: '#ccc',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.5',
|
||||
padding: '12px',
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #1a1a1a',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
minHeight: '100%',
|
||||
}}
|
||||
>
|
||||
{nodesData[activeTab].chunk}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
onClick={startSourceEdit}
|
||||
style={{
|
||||
color: '#555',
|
||||
fontSize: '12px',
|
||||
fontStyle: 'italic',
|
||||
cursor: 'pointer',
|
||||
padding: '12px',
|
||||
border: '1px dashed #1a1a1a',
|
||||
borderRadius: '4px',
|
||||
textAlign: 'center',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
No source content. Click to add.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3062,126 +3132,6 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Explanation prompt (required) */}
|
||||
{pendingEdgeTarget && (
|
||||
<div style={{
|
||||
marginTop: '10px',
|
||||
background: '#141414',
|
||||
border: '1px solid #262626',
|
||||
borderRadius: '16px',
|
||||
padding: '16px 18px',
|
||||
boxShadow: '0 0 0 1px rgba(255, 255, 255, 0.04), 0 24px 48px -12px rgba(0, 0, 0, 0.6)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px'
|
||||
}}>
|
||||
<div style={{ color: '#e5e5e5', fontSize: '13px', fontWeight: 600 }}>
|
||||
Create connection to: <span style={{ color: '#a3e635' }}>{pendingEdgeTarget.title}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ label: 'Made by', value: 'Created by ' },
|
||||
{ label: 'Part of', value: 'Part of ' },
|
||||
{ label: 'Came from', value: 'Came from ' },
|
||||
{ label: 'Related', value: 'Related to ' },
|
||||
].map((chip) => (
|
||||
<button
|
||||
key={chip.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEdgeExplanation((prev) => {
|
||||
const trimmed = (prev || '').trim();
|
||||
return trimmed.length > 0 ? prev : chip.value;
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
fontSize: '12px',
|
||||
borderRadius: '999px',
|
||||
border: '1px solid #262626',
|
||||
background: '#0f0f0f',
|
||||
color: '#e5e5e5',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = '#1a1a1a'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = '#0f0f0f'; }}
|
||||
title={`Prefill: ${chip.value.trim()}`}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
value={edgeExplanation}
|
||||
onChange={(e) => setEdgeExplanation(e.target.value)}
|
||||
placeholder="Why does this connect? (e.g., 'Author of this book', 'Inspired this insight')"
|
||||
rows={2}
|
||||
style={{
|
||||
width: '100%',
|
||||
resize: 'vertical',
|
||||
background: '#0f0f0f',
|
||||
border: '1px solid #333',
|
||||
color: '#fafafa',
|
||||
borderRadius: '12px',
|
||||
padding: '10px 12px',
|
||||
fontSize: '13px',
|
||||
outline: 'none',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
createEdgeWithExplanation(pendingEdgeTarget.id, edgeExplanation);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setPendingEdgeTarget(null);
|
||||
setEdgeExplanation('');
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setPendingEdgeTarget(null);
|
||||
setEdgeExplanation('');
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: '#262626',
|
||||
border: 'none',
|
||||
borderRadius: '10px',
|
||||
color: '#a3a3a3',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => createEdgeWithExplanation(pendingEdgeTarget.id, edgeExplanation)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: '#22c55e',
|
||||
border: '1px solid #16a34a',
|
||||
borderRadius: '10px',
|
||||
color: '#0a0a0a',
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ color: '#737373', fontSize: '11px' }}>
|
||||
Tip: press <span style={{ fontFamily: 'monospace' }}>⌘</span>+<span style={{ fontFamily: 'monospace' }}>Enter</span> to create.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Existing Connections */}
|
||||
{!nodeSearchQuery && (
|
||||
<div style={{
|
||||
@@ -3231,6 +3181,17 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
>
|
||||
{/* Connection header row */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
{/* Direction arrow: → if current node is FROM (outgoing), ← if current node is TO (incoming) */}
|
||||
<span style={{
|
||||
fontSize: '14px',
|
||||
color: connection.edge.from_node_id === activeTab ? '#22c55e' : '#f59e0b',
|
||||
fontWeight: 600,
|
||||
width: '18px',
|
||||
textAlign: 'center',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{connection.edge.from_node_id === activeTab ? '→' : '←'}
|
||||
</span>
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
@@ -3426,6 +3387,90 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab Context Menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: contextMenu.y,
|
||||
left: contextMenu.x,
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '6px',
|
||||
padding: '4px',
|
||||
zIndex: 9999,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
||||
minWidth: '160px',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{onOpenInOtherSlot && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onOpenInOtherSlot(contextMenu.tabId);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#ccc',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2a2a2a';
|
||||
e.currentTarget.style.color = '#fff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = '#ccc';
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '14px' }}>↗</span>
|
||||
Open in other panel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
onTabClose(contextMenu.tabId);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#ccc',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2a2a2a';
|
||||
e.currentTarget.style.color = '#fff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = '#ccc';
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '14px' }}>×</span>
|
||||
Close tab
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,14 +90,31 @@ export default function DimensionTags({
|
||||
}
|
||||
};
|
||||
|
||||
const addDimension = async (dimension: string) => {
|
||||
const addDimension = async (dimension: string, description?: string) => {
|
||||
if (!dimension.trim() || dimensions.includes(dimension.trim())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If description is provided, create/update the dimension in the database first
|
||||
if (description) {
|
||||
try {
|
||||
await fetch('/api/dimensions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: dimension.trim(),
|
||||
description: description.trim(),
|
||||
isPriority: false
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating dimension with description:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const newDimensions = [...dimensions, dimension.trim()];
|
||||
await onUpdate(newDimensions);
|
||||
|
||||
|
||||
setSearchQuery('');
|
||||
setSuggestions([]);
|
||||
setIsAdding(false);
|
||||
@@ -387,8 +404,8 @@ export default function DimensionTags({
|
||||
<DimensionSearchModal
|
||||
isOpen={isAdding}
|
||||
onClose={() => setIsAdding(false)}
|
||||
onDimensionSelect={(dim) => {
|
||||
addDimension(dim);
|
||||
onDimensionSelect={(dim, description) => {
|
||||
addDimension(dim, description);
|
||||
}}
|
||||
existingDimensions={dimensions}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Content type detection for Source Reader
|
||||
* Analyzes raw content to determine the best formatting approach
|
||||
*/
|
||||
|
||||
export type ContentType = 'transcript' | 'book' | 'markdown' | 'article' | 'raw';
|
||||
|
||||
// Timestamp patterns for transcript detection
|
||||
const TIMESTAMP_PATTERNS = [
|
||||
/^\[?\d{1,2}:\d{2}(:\d{2})?\]?\s*/, // [00:00] or [00:00:00]
|
||||
/^\d{1,2}:\d{2}(:\d{2})?\s*[-–—]\s*/, // 00:00 - or 00:00:00 -
|
||||
/^\(\d{1,2}:\d{2}(:\d{2})?\)\s*/, // (00:00) or (00:00:00)
|
||||
/^\[\d+(\.\d+)?s\]\s*/, // [0.1s] or [12.5s] - decimal seconds
|
||||
];
|
||||
|
||||
// Simple book detection - look for explicit chapter markers
|
||||
// We no longer try to parse numbered sections (too unreliable)
|
||||
const BOOK_INDICATORS = [
|
||||
/\b(CHAPTER|Chapter)\s+\d+/i,
|
||||
/\b(Part|PART)\s+(I|II|III|IV|V|VI|VII|VIII|IX|X|\d+)\b/i,
|
||||
/\bTable\s+of\s+Contents\b/i,
|
||||
/\bPREFACE\b/,
|
||||
/\bINTRODUCTION\b/,
|
||||
/\bAPPENDIX\b/,
|
||||
];
|
||||
|
||||
// Markdown syntax markers
|
||||
const MARKDOWN_MARKERS = [
|
||||
/^#{1,6}\s/m, // Headers
|
||||
/\*\*[^*]+\*\*/, // Bold
|
||||
/\*[^*]+\*/, // Italic (but not bold)
|
||||
/^[-*+]\s/m, // Unordered lists
|
||||
/^\d+\.\s/m, // Ordered lists
|
||||
/```[\s\S]*?```/, // Code blocks
|
||||
/`[^`]+`/, // Inline code
|
||||
/\[.+\]\(.+\)/, // Links
|
||||
];
|
||||
|
||||
/**
|
||||
* Detect the content type of raw source content
|
||||
*/
|
||||
export function detectContentType(content: string): ContentType {
|
||||
if (!content || content.length < 50) return 'raw';
|
||||
|
||||
const lines = content.split('\n').slice(0, 50); // Check first 50 lines
|
||||
|
||||
// 1. Check for transcript patterns (timestamps)
|
||||
const timestampLines = lines.filter(line =>
|
||||
TIMESTAMP_PATTERNS.some(pattern => pattern.test(line.trim()))
|
||||
);
|
||||
if (timestampLines.length >= 3) return 'transcript';
|
||||
|
||||
// 2. Check for book indicators (explicit markers like "Chapter", "Table of Contents")
|
||||
const bookIndicatorCount = BOOK_INDICATORS.filter(pattern => pattern.test(content)).length;
|
||||
if (bookIndicatorCount >= 2) return 'book';
|
||||
|
||||
// Also consider very long content as book-like (>20K chars)
|
||||
if (content.length > 20000 && bookIndicatorCount >= 1) return 'book';
|
||||
|
||||
// 3. Check for markdown patterns
|
||||
const markdownScore = calculateMarkdownScore(content);
|
||||
if (markdownScore > 0.3) return 'markdown';
|
||||
|
||||
// 4. Check for article structure (paragraphs with clear breaks)
|
||||
const paragraphs = content.split(/\n\n+/).filter(p => p.trim().length > 100);
|
||||
if (paragraphs.length >= 3) return 'article';
|
||||
|
||||
// 5. Default to raw
|
||||
return 'raw';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate a "markdown-ness" score for content
|
||||
* Returns a value between 0 and 1
|
||||
*/
|
||||
function calculateMarkdownScore(content: string): number {
|
||||
const matchCount = MARKDOWN_MARKERS.filter(m => m.test(content)).length;
|
||||
return matchCount / MARKDOWN_MARKERS.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable label for content type
|
||||
*/
|
||||
export function getContentTypeLabel(type: ContentType): string {
|
||||
switch (type) {
|
||||
case 'transcript': return 'Transcript';
|
||||
case 'book': return 'Book/Chapter';
|
||||
case 'markdown': return 'Markdown';
|
||||
case 'article': return 'Article';
|
||||
case 'raw': return 'Plain Text';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { detectContentType, getContentTypeLabel } from './ContentDetector';
|
||||
import RawFormatter from './formatters/RawFormatter';
|
||||
import TranscriptFormatter from './formatters/TranscriptFormatter';
|
||||
import BookFormatter from './formatters/BookFormatter';
|
||||
import MarkdownFormatter from './formatters/MarkdownFormatter';
|
||||
import SourceSearchBar from './SourceSearchBar';
|
||||
|
||||
interface SourceReaderProps {
|
||||
content: string;
|
||||
onTextSelect?: (text: string) => void;
|
||||
highlightedText?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source Reader - Read-only formatted view of source content
|
||||
*
|
||||
* This is a pure presentation component that NEVER modifies the source data.
|
||||
* It detects content type and applies appropriate formatting for comfortable reading.
|
||||
*/
|
||||
export default function SourceReader({
|
||||
content,
|
||||
onTextSelect,
|
||||
highlightedText,
|
||||
}: SourceReaderProps) {
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [searchHighlight, setSearchHighlight] = useState<string | null>(null);
|
||||
const [searchMatchIndex, setSearchMatchIndex] = useState(0);
|
||||
const [scrollTrigger, setScrollTrigger] = useState(0);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Detect content type (memoized for performance)
|
||||
const contentType = useMemo(() => detectContentType(content), [content]);
|
||||
const contentTypeLabel = getContentTypeLabel(contentType);
|
||||
|
||||
// Combined highlight: search takes precedence over external highlight
|
||||
const activeHighlight = searchHighlight || highlightedText;
|
||||
|
||||
// Keyboard shortcut for Cmd+F
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
|
||||
e.preventDefault();
|
||||
setShowSearch(true);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// Scroll to current match when search highlight changes
|
||||
useEffect(() => {
|
||||
if (!searchHighlight || !contentRef.current) return;
|
||||
|
||||
// Small delay to let React render the mark element
|
||||
const timer = setTimeout(() => {
|
||||
const currentMatch = contentRef.current?.querySelector('mark[data-search-match="current"]');
|
||||
if (currentMatch) {
|
||||
currentMatch.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchHighlight, scrollTrigger]);
|
||||
|
||||
const handleSearchClose = useCallback(() => {
|
||||
setShowSearch(false);
|
||||
setSearchHighlight(null);
|
||||
setSearchMatchIndex(0);
|
||||
}, []);
|
||||
|
||||
const handleHighlightChange = useCallback((text: string | null, matchIndex: number) => {
|
||||
setSearchHighlight(text);
|
||||
setSearchMatchIndex(matchIndex);
|
||||
// Trigger scroll even if same text (for navigating between matches)
|
||||
setScrollTrigger(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
// Render appropriate formatter based on content type
|
||||
const renderContent = () => {
|
||||
const highlightIndex = showSearch ? searchMatchIndex : undefined;
|
||||
switch (contentType) {
|
||||
case 'transcript':
|
||||
return <TranscriptFormatter content={content} onTextSelect={onTextSelect} highlightedText={activeHighlight} highlightMatchIndex={highlightIndex} />;
|
||||
case 'book':
|
||||
case 'article':
|
||||
return <BookFormatter content={content} onTextSelect={onTextSelect} highlightedText={activeHighlight} highlightMatchIndex={highlightIndex} />;
|
||||
case 'markdown':
|
||||
return <MarkdownFormatter content={content} onTextSelect={onTextSelect} highlightedText={activeHighlight} highlightMatchIndex={highlightIndex} />;
|
||||
default:
|
||||
return <RawFormatter content={content} onTextSelect={onTextSelect} highlightedText={activeHighlight} highlightMatchIndex={highlightIndex} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#0f0f0f',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Header with content type and search */}
|
||||
{content && content.length >= 50 && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 16px',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
color: '#555',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}>
|
||||
Detected: {contentTypeLabel}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowSearch(!showSearch)}
|
||||
title="Search content (⌘F)"
|
||||
style={{
|
||||
background: showSearch ? '#262626' : 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
color: showSearch ? '#fafafa' : '#555',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
fontSize: '10px',
|
||||
transition: 'all 150ms ease',
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Search</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search bar */}
|
||||
{showSearch && content && (
|
||||
<SourceSearchBar
|
||||
content={content}
|
||||
onClose={handleSearchClose}
|
||||
onHighlightChange={handleHighlightChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Formatted content */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface SourceSearchBarProps {
|
||||
content: string;
|
||||
onClose: () => void;
|
||||
onHighlightChange: (text: string | null, matchIndex: number) => void;
|
||||
}
|
||||
|
||||
interface SearchMatch {
|
||||
index: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export default function SourceSearchBar({
|
||||
content,
|
||||
onClose,
|
||||
onHighlightChange
|
||||
}: SourceSearchBarProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [matches, setMatches] = useState<SearchMatch[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Focus input on mount
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Search when query changes
|
||||
useEffect(() => {
|
||||
if (!query.trim()) {
|
||||
setMatches([]);
|
||||
setCurrentIndex(0);
|
||||
onHighlightChange(null, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const searchQuery = query.toLowerCase();
|
||||
const contentLower = content.toLowerCase();
|
||||
const foundMatches: SearchMatch[] = [];
|
||||
|
||||
let pos = 0;
|
||||
while (pos < contentLower.length) {
|
||||
const index = contentLower.indexOf(searchQuery, pos);
|
||||
if (index === -1) break;
|
||||
foundMatches.push({
|
||||
index,
|
||||
text: content.slice(index, index + query.length)
|
||||
});
|
||||
pos = index + 1;
|
||||
}
|
||||
|
||||
setMatches(foundMatches);
|
||||
setCurrentIndex(0);
|
||||
|
||||
if (foundMatches.length > 0) {
|
||||
onHighlightChange(foundMatches[0].text, 0);
|
||||
} else {
|
||||
onHighlightChange(null, 0);
|
||||
}
|
||||
}, [query, content, onHighlightChange]);
|
||||
|
||||
// Update highlight when navigating
|
||||
useEffect(() => {
|
||||
if (matches.length > 0 && matches[currentIndex]) {
|
||||
onHighlightChange(matches[currentIndex].text, currentIndex);
|
||||
}
|
||||
}, [currentIndex, matches, onHighlightChange]);
|
||||
|
||||
const goToNext = () => {
|
||||
if (matches.length === 0) return;
|
||||
setCurrentIndex((prev) => (prev + 1) % matches.length);
|
||||
};
|
||||
|
||||
const goToPrev = () => {
|
||||
if (matches.length === 0) return;
|
||||
setCurrentIndex((prev) => (prev - 1 + matches.length) % matches.length);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
goToPrev();
|
||||
} else {
|
||||
goToNext();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
background: '#141414',
|
||||
border: '1px solid #262626',
|
||||
borderRadius: '12px',
|
||||
padding: '10px 16px',
|
||||
margin: '8px 12px',
|
||||
}}>
|
||||
{/* Search icon */}
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 20 20"
|
||||
fill="#525252"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Input */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search in source..."
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: '#fafafa',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Match count */}
|
||||
{query && (
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
color: matches.length > 0 ? '#737373' : '#ef4444',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{matches.length > 0
|
||||
? `${currentIndex + 1} of ${matches.length}`
|
||||
: 'No matches'
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Navigation arrows */}
|
||||
{matches.length > 1 && (
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<button
|
||||
onClick={goToPrev}
|
||||
title="Previous (Shift+Enter)"
|
||||
style={{
|
||||
background: '#262626',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 6px',
|
||||
cursor: 'pointer',
|
||||
color: '#a3a3a3',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M14.77 12.79a.75.75 0 01-1.06-.02L10 8.832 6.29 12.77a.75.75 0 11-1.08-1.04l4.25-4.5a.75.75 0 011.08 0l4.25 4.5a.75.75 0 01-.02 1.06z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={goToNext}
|
||||
title="Next (Enter)"
|
||||
style={{
|
||||
background: '#262626',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 6px',
|
||||
cursor: 'pointer',
|
||||
color: '#a3a3a3',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
title="Close (Esc)"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: '4px',
|
||||
cursor: 'pointer',
|
||||
color: '#525252',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Keyboard hint */}
|
||||
<kbd style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '2px 6px',
|
||||
background: '#262626',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontFamily: 'inherit',
|
||||
color: '#525252',
|
||||
border: '1px solid #333',
|
||||
}}>
|
||||
esc
|
||||
</kbd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useMemo } from 'react';
|
||||
|
||||
interface BookFormatterProps {
|
||||
content: string;
|
||||
onTextSelect?: (text: string) => void;
|
||||
highlightedText?: string | null;
|
||||
highlightMatchIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Book Formatter - Clean typography for long-form text
|
||||
*
|
||||
* Focuses on readability without attempting to parse chapter structure.
|
||||
* Structure parsing can be done on-demand via AI.
|
||||
*/
|
||||
export default function BookFormatter({ content, onTextSelect, highlightedText, highlightMatchIndex = 0 }: BookFormatterProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle text selection
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (!onTextSelect) return;
|
||||
|
||||
const selection = window.getSelection();
|
||||
const text = selection?.toString().trim();
|
||||
|
||||
if (text && text.length > 10) {
|
||||
const truncatedText = text.length > 2000
|
||||
? text.slice(0, 2000) + '...'
|
||||
: text;
|
||||
onTextSelect(truncatedText);
|
||||
selection?.removeAllRanges();
|
||||
}
|
||||
}, [onTextSelect]);
|
||||
|
||||
// Compute all match positions in the full content
|
||||
const matchPositions = useMemo(() => {
|
||||
if (!highlightedText) return [];
|
||||
const positions: number[] = [];
|
||||
const searchText = highlightedText.toLowerCase();
|
||||
const contentLower = content.toLowerCase();
|
||||
let pos = 0;
|
||||
while (pos < contentLower.length) {
|
||||
const index = contentLower.indexOf(searchText, pos);
|
||||
if (index === -1) break;
|
||||
positions.push(index);
|
||||
pos = index + 1;
|
||||
}
|
||||
return positions;
|
||||
}, [content, highlightedText]);
|
||||
|
||||
// Track which global match index we're at as we render paragraphs
|
||||
const globalMatchCounter = useRef(0);
|
||||
|
||||
// Reset counter before each render
|
||||
globalMatchCounter.current = 0;
|
||||
|
||||
// Render text with all highlights, marking current one specially
|
||||
const renderWithHighlight = useCallback((text: string): React.ReactNode => {
|
||||
if (!highlightedText) return text;
|
||||
|
||||
const textLower = text.toLowerCase();
|
||||
const searchLower = highlightedText.toLowerCase();
|
||||
|
||||
if (!textLower.includes(searchLower)) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Find all occurrences in this paragraph
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let pos = 0;
|
||||
|
||||
while (pos < textLower.length) {
|
||||
const index = textLower.indexOf(searchLower, pos);
|
||||
if (index === -1) break;
|
||||
|
||||
// Add text before match
|
||||
if (index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, index));
|
||||
}
|
||||
|
||||
// Determine if this is the current match
|
||||
const isCurrent = globalMatchCounter.current === highlightMatchIndex;
|
||||
globalMatchCounter.current++;
|
||||
|
||||
// Add highlighted match
|
||||
parts.push(
|
||||
<mark
|
||||
key={`match-${index}`}
|
||||
data-search-match={isCurrent ? 'current' : 'other'}
|
||||
style={{
|
||||
background: isCurrent ? 'rgba(250, 204, 21, 0.4)' : 'rgba(168, 85, 247, 0.2)',
|
||||
color: isCurrent ? '#fef08a' : '#e9d5ff',
|
||||
padding: '2px 0',
|
||||
borderRadius: '2px',
|
||||
}}
|
||||
>
|
||||
{text.slice(index, index + highlightedText.length)}
|
||||
</mark>
|
||||
);
|
||||
|
||||
lastIndex = index + highlightedText.length;
|
||||
pos = index + 1;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? <>{parts}</> : text;
|
||||
}, [highlightedText, highlightMatchIndex]);
|
||||
|
||||
// Split content into paragraphs (double newlines or single newlines with blank lines)
|
||||
const paragraphs = content
|
||||
.split(/\n\n+/)
|
||||
.map(p => p.trim())
|
||||
.filter(p => p.length > 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseUp={handleMouseUp}
|
||||
style={{
|
||||
maxWidth: '680px',
|
||||
margin: '0 auto',
|
||||
padding: '24px 16px',
|
||||
}}
|
||||
>
|
||||
{/* Clean text display */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5em' }}>
|
||||
{paragraphs.map((para, idx) => (
|
||||
<p
|
||||
key={idx}
|
||||
style={{
|
||||
fontFamily: "Georgia, 'Times New Roman', serif",
|
||||
fontSize: '16px',
|
||||
lineHeight: '1.75',
|
||||
color: '#d4d4d4',
|
||||
margin: 0,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordWrap: 'break-word',
|
||||
textAlign: 'justify',
|
||||
textJustify: 'inter-word',
|
||||
hyphens: 'auto',
|
||||
}}
|
||||
>
|
||||
{renderWithHighlight(para)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import MarkdownWithNodeTokens from '@/components/helpers/MarkdownWithNodeTokens';
|
||||
|
||||
interface MarkdownFormatterProps {
|
||||
content: string;
|
||||
onTextSelect?: (text: string) => void;
|
||||
highlightedText?: string | null;
|
||||
highlightMatchIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown Formatter - Renders markdown content with good typography
|
||||
* Note: Search highlighting not yet implemented for markdown content
|
||||
*/
|
||||
export default function MarkdownFormatter({ content, onTextSelect }: MarkdownFormatterProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (!onTextSelect) return;
|
||||
|
||||
const selection = window.getSelection();
|
||||
const text = selection?.toString().trim();
|
||||
|
||||
if (text && text.length > 10) {
|
||||
const truncatedText = text.length > 2000
|
||||
? text.slice(0, 2000) + '...'
|
||||
: text;
|
||||
onTextSelect(truncatedText);
|
||||
selection?.removeAllRanges();
|
||||
}
|
||||
}, [onTextSelect]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseUp={handleMouseUp}
|
||||
style={{
|
||||
maxWidth: '680px',
|
||||
margin: '0 auto',
|
||||
padding: '24px 16px',
|
||||
fontFamily: "Georgia, 'Times New Roman', serif",
|
||||
fontSize: '16px',
|
||||
lineHeight: '1.75',
|
||||
color: '#d4d4d4',
|
||||
}}
|
||||
>
|
||||
<MarkdownWithNodeTokens content={content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useCallback, useRef } from 'react';
|
||||
|
||||
interface RawFormatterProps {
|
||||
content: string;
|
||||
onTextSelect?: (text: string) => void;
|
||||
highlightedText?: string | null;
|
||||
highlightMatchIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw/Typography formatter for the Source Reader
|
||||
* Applies comfortable reading typography without any content transformation
|
||||
* Works as the default/fallback for all content types
|
||||
*/
|
||||
export default function RawFormatter({ content, onTextSelect, highlightedText, highlightMatchIndex = 0 }: RawFormatterProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const globalMatchCounter = useRef(0);
|
||||
|
||||
// Reset counter before each render
|
||||
globalMatchCounter.current = 0;
|
||||
|
||||
// Handle text selection - fires on mouseup
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (!onTextSelect) return;
|
||||
|
||||
const selection = window.getSelection();
|
||||
const text = selection?.toString().trim();
|
||||
|
||||
// Only trigger for meaningful selections (>10 chars)
|
||||
if (text && text.length > 10) {
|
||||
// Truncate very long selections to 2000 chars
|
||||
const truncatedText = text.length > 2000
|
||||
? text.slice(0, 2000) + '...'
|
||||
: text;
|
||||
onTextSelect(truncatedText);
|
||||
// Clear browser selection so our custom highlight shows
|
||||
selection?.removeAllRanges();
|
||||
}
|
||||
}, [onTextSelect]);
|
||||
|
||||
// Render text with all highlights, marking current one specially
|
||||
const renderWithHighlight = useCallback((text: string): React.ReactNode => {
|
||||
if (!highlightedText) return text;
|
||||
|
||||
const textLower = text.toLowerCase();
|
||||
const searchLower = highlightedText.toLowerCase();
|
||||
|
||||
if (!textLower.includes(searchLower)) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let pos = 0;
|
||||
|
||||
while (pos < textLower.length) {
|
||||
const index = textLower.indexOf(searchLower, pos);
|
||||
if (index === -1) break;
|
||||
|
||||
if (index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, index));
|
||||
}
|
||||
|
||||
const isCurrent = globalMatchCounter.current === highlightMatchIndex;
|
||||
globalMatchCounter.current++;
|
||||
|
||||
parts.push(
|
||||
<mark
|
||||
key={`match-${index}`}
|
||||
data-search-match={isCurrent ? 'current' : 'other'}
|
||||
style={{
|
||||
background: isCurrent ? 'rgba(250, 204, 21, 0.4)' : 'rgba(168, 85, 247, 0.2)',
|
||||
color: isCurrent ? '#fef08a' : '#e9d5ff',
|
||||
padding: '2px 0',
|
||||
borderRadius: '2px',
|
||||
}}
|
||||
>
|
||||
{text.slice(index, index + highlightedText.length)}
|
||||
</mark>
|
||||
);
|
||||
|
||||
lastIndex = index + highlightedText.length;
|
||||
pos = index + 1;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? <>{parts}</> : text;
|
||||
}, [highlightedText, highlightMatchIndex]);
|
||||
// Split content into paragraphs for better rendering
|
||||
const paragraphs = useMemo(() => {
|
||||
if (!content) return [];
|
||||
|
||||
// Split on double newlines to detect paragraphs
|
||||
// But preserve single newlines within paragraphs
|
||||
return content
|
||||
.split(/\n\n+/)
|
||||
.map(p => p.trim())
|
||||
.filter(p => p.length > 0);
|
||||
}, [content]);
|
||||
|
||||
if (!content) {
|
||||
return (
|
||||
<div style={{
|
||||
color: '#555',
|
||||
fontSize: '15px',
|
||||
fontStyle: 'italic',
|
||||
textAlign: 'center',
|
||||
padding: '40px 20px',
|
||||
}}>
|
||||
No source content
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseUp={handleMouseUp}
|
||||
style={{
|
||||
maxWidth: '680px',
|
||||
margin: '0 auto',
|
||||
padding: '24px 16px',
|
||||
}}
|
||||
>
|
||||
{paragraphs.map((paragraph, index) => (
|
||||
<p
|
||||
key={index}
|
||||
style={{
|
||||
fontFamily: "Georgia, 'Times New Roman', serif",
|
||||
fontSize: '15px',
|
||||
lineHeight: '1.7',
|
||||
color: '#d4d4d4',
|
||||
margin: 0,
|
||||
marginBottom: index < paragraphs.length - 1 ? '1.5em' : 0,
|
||||
whiteSpace: 'pre-wrap', // Preserve single newlines within paragraphs
|
||||
wordWrap: 'break-word',
|
||||
}}
|
||||
>
|
||||
{renderWithHighlight(paragraph)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useCallback, useRef } from 'react';
|
||||
|
||||
interface TranscriptFormatterProps {
|
||||
content: string;
|
||||
onTextSelect?: (text: string) => void;
|
||||
highlightedText?: string | null;
|
||||
highlightMatchIndex?: number;
|
||||
}
|
||||
|
||||
interface TranscriptSegment {
|
||||
timestamp: string;
|
||||
speaker: string | null;
|
||||
text: string;
|
||||
}
|
||||
|
||||
// Timestamp patterns for parsing
|
||||
const TIMESTAMP_REGEX = /^(\[?\d{1,2}:\d{2}(?::\d{2})?\]?|\(\d{1,2}:\d{2}(?::\d{2})?\)|\d{1,2}:\d{2}(?::\d{2})?\s*[-–—]|\[\d+(?:\.\d+)?s\])\s*/;
|
||||
|
||||
// Speaker pattern - looks for "Name:" at start after timestamp
|
||||
const SPEAKER_REGEX = /^([A-Z][a-zA-Z\s]{1,30}):?\s+/;
|
||||
|
||||
/**
|
||||
* Parse a line into timestamp, speaker, and text components
|
||||
*/
|
||||
function parseLine(line: string): TranscriptSegment | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const timestampMatch = trimmed.match(TIMESTAMP_REGEX);
|
||||
if (!timestampMatch) return null;
|
||||
|
||||
const timestamp = timestampMatch[1].replace(/[-–—]\s*$/, '').replace(/[\[\]\(\)]/g, '').trim();
|
||||
let remaining = trimmed.slice(timestampMatch[0].length);
|
||||
|
||||
// Check for speaker name
|
||||
let speaker: string | null = null;
|
||||
const speakerMatch = remaining.match(SPEAKER_REGEX);
|
||||
if (speakerMatch) {
|
||||
speaker = speakerMatch[1].trim();
|
||||
remaining = remaining.slice(speakerMatch[0].length);
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp,
|
||||
speaker,
|
||||
text: remaining.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Group consecutive non-timestamp lines with the previous segment
|
||||
*/
|
||||
function parseTranscript(content: string): TranscriptSegment[] {
|
||||
const lines = content.split('\n');
|
||||
const segments: TranscriptSegment[] = [];
|
||||
let currentSegment: TranscriptSegment | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const parsed = parseLine(line);
|
||||
|
||||
if (parsed) {
|
||||
if (currentSegment) {
|
||||
segments.push(currentSegment);
|
||||
}
|
||||
currentSegment = parsed;
|
||||
} else if (currentSegment && line.trim()) {
|
||||
currentSegment.text += '\n' + line.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSegment) {
|
||||
segments.push(currentSegment);
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcript Formatter - Clean view for timestamped content
|
||||
*/
|
||||
export default function TranscriptFormatter({ content, onTextSelect, highlightedText, highlightMatchIndex = 0 }: TranscriptFormatterProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const globalMatchCounter = useRef(0);
|
||||
|
||||
// Reset counter before each render
|
||||
globalMatchCounter.current = 0;
|
||||
|
||||
const segments = useMemo(() => parseTranscript(content), [content]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (!onTextSelect) return;
|
||||
|
||||
const selection = window.getSelection();
|
||||
const text = selection?.toString().trim();
|
||||
|
||||
if (text && text.length > 10) {
|
||||
const truncatedText = text.length > 2000
|
||||
? text.slice(0, 2000) + '...'
|
||||
: text;
|
||||
onTextSelect(truncatedText);
|
||||
selection?.removeAllRanges();
|
||||
}
|
||||
}, [onTextSelect]);
|
||||
|
||||
const renderWithHighlight = useCallback((text: string): React.ReactNode => {
|
||||
if (!highlightedText) return text;
|
||||
|
||||
const textLower = text.toLowerCase();
|
||||
const searchLower = highlightedText.toLowerCase();
|
||||
|
||||
if (!textLower.includes(searchLower)) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let pos = 0;
|
||||
|
||||
while (pos < textLower.length) {
|
||||
const index = textLower.indexOf(searchLower, pos);
|
||||
if (index === -1) break;
|
||||
|
||||
if (index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, index));
|
||||
}
|
||||
|
||||
const isCurrent = globalMatchCounter.current === highlightMatchIndex;
|
||||
globalMatchCounter.current++;
|
||||
|
||||
parts.push(
|
||||
<mark
|
||||
key={`match-${index}`}
|
||||
data-search-match={isCurrent ? 'current' : 'other'}
|
||||
style={{
|
||||
background: isCurrent ? 'rgba(250, 204, 21, 0.4)' : 'rgba(168, 85, 247, 0.2)',
|
||||
color: isCurrent ? '#fef08a' : '#e9d5ff',
|
||||
padding: '2px 0',
|
||||
borderRadius: '2px',
|
||||
}}
|
||||
>
|
||||
{text.slice(index, index + highlightedText.length)}
|
||||
</mark>
|
||||
);
|
||||
|
||||
lastIndex = index + highlightedText.length;
|
||||
pos = index + 1;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? <>{parts}</> : text;
|
||||
}, [highlightedText, highlightMatchIndex]);
|
||||
|
||||
if (segments.length === 0) {
|
||||
return (
|
||||
<div style={{
|
||||
color: '#555',
|
||||
fontSize: '15px',
|
||||
fontStyle: 'italic',
|
||||
textAlign: 'center',
|
||||
padding: '40px 20px',
|
||||
}}>
|
||||
No transcript content detected
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseUp={handleMouseUp}
|
||||
style={{
|
||||
maxWidth: '680px',
|
||||
margin: '0 auto',
|
||||
padding: '24px 16px',
|
||||
}}
|
||||
>
|
||||
{segments.map((segment, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
{/* Timestamp and speaker on same line */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
marginBottom: '8px',
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
|
||||
color: '#555',
|
||||
}}>
|
||||
{segment.timestamp}
|
||||
</span>
|
||||
{segment.speaker && (
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: '#888',
|
||||
}}>
|
||||
{segment.speaker}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Text content */}
|
||||
<div style={{
|
||||
fontFamily: "Georgia, 'Times New Roman', serif",
|
||||
fontSize: '16px',
|
||||
lineHeight: '1.75',
|
||||
color: '#d4d4d4',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordWrap: 'break-word',
|
||||
}}>
|
||||
{renderWithHighlight(segment.text)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as SourceReader } from './SourceReader';
|
||||
export { detectContentType, getContentTypeLabel } from './ContentDetector';
|
||||
export type { ContentType } from './ContentDetector';
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight, FileText, MessageSquare, Workflow, FolderOpen, Map, LayoutList } from 'lucide-react';
|
||||
|
||||
type RailPosition = 'left' | 'right';
|
||||
type PaneType = 'nodes' | 'node' | 'chat' | 'workflows' | 'dimensions' | 'map' | 'views';
|
||||
|
||||
interface CollapsedRailProps {
|
||||
position: RailPosition;
|
||||
paneType: PaneType;
|
||||
onExpand: () => void;
|
||||
shortcut?: string;
|
||||
}
|
||||
|
||||
const PANE_ICONS: Record<PaneType, React.ReactNode> = {
|
||||
nodes: <FileText size={18} />,
|
||||
node: <FileText size={18} />,
|
||||
chat: <MessageSquare size={18} />,
|
||||
workflows: <Workflow size={18} />,
|
||||
dimensions: <FolderOpen size={18} />,
|
||||
map: <Map size={18} />,
|
||||
views: <LayoutList size={18} />,
|
||||
};
|
||||
|
||||
const PANE_LABELS: Record<PaneType, string> = {
|
||||
nodes: 'Nodes',
|
||||
node: 'Focus',
|
||||
chat: 'Chat',
|
||||
workflows: 'Workflows',
|
||||
dimensions: 'Dimensions',
|
||||
map: 'Map',
|
||||
views: 'Feed',
|
||||
};
|
||||
|
||||
export default function CollapsedRail({ position, paneType, onExpand, shortcut }: CollapsedRailProps) {
|
||||
const isLeft = position === 'left';
|
||||
const ChevronIcon = isLeft ? ChevronRight : ChevronLeft;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onExpand}
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '100%',
|
||||
flexShrink: 0,
|
||||
borderLeft: isLeft ? 'none' : '1px solid #1f1f1f',
|
||||
borderRight: isLeft ? '1px solid #1f1f1f' : 'none',
|
||||
background: '#0c0c0c',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
paddingTop: '12px',
|
||||
gap: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#141414';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#0c0c0c';
|
||||
}}
|
||||
title={`Expand ${PANE_LABELS[paneType]}${shortcut ? ` (${shortcut})` : ''}`}
|
||||
>
|
||||
{/* Icon representing the collapsed panel */}
|
||||
<div
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '6px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#888',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{PANE_ICONS[paneType]}
|
||||
</div>
|
||||
|
||||
{/* Chevron indicator */}
|
||||
<div
|
||||
style={{
|
||||
color: '#666',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
<ChevronIcon size={14} />
|
||||
</div>
|
||||
|
||||
{/* Vertical label */}
|
||||
<div
|
||||
style={{
|
||||
writingMode: 'vertical-rl',
|
||||
textOrientation: 'mixed',
|
||||
transform: isLeft ? 'rotate(180deg)' : 'none',
|
||||
fontSize: '10px',
|
||||
fontWeight: 500,
|
||||
color: '#777',
|
||||
letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
{PANE_LABELS[paneType]}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
LayoutList,
|
||||
MessageSquare,
|
||||
Map,
|
||||
Folder,
|
||||
Workflow,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
import type { PaneType } from '../panes/types';
|
||||
|
||||
interface LeftToolbarProps {
|
||||
onSearchClick: () => void;
|
||||
onAddStuffClick: () => void;
|
||||
onSettingsClick: () => void;
|
||||
onPaneTypeClick: (paneType: PaneType) => void;
|
||||
activePane: 'A' | 'B';
|
||||
slotAType: PaneType | null;
|
||||
slotBType: PaneType | null;
|
||||
}
|
||||
|
||||
// Map pane types to their icons
|
||||
const PANE_TYPE_ICONS: Record<string, typeof LayoutList> = {
|
||||
views: LayoutList,
|
||||
chat: MessageSquare,
|
||||
map: Map,
|
||||
dimensions: Folder,
|
||||
workflows: Workflow,
|
||||
};
|
||||
|
||||
const PANE_TYPE_LABELS: Record<string, string> = {
|
||||
views: 'Feed',
|
||||
chat: 'Chat',
|
||||
map: 'Map',
|
||||
dimensions: 'Dimensions',
|
||||
workflows: 'Workflows',
|
||||
};
|
||||
|
||||
// Pane types shown in the toolbar (excludes 'node' which is opened via Feed)
|
||||
const TOOLBAR_PANE_TYPES: PaneType[] = ['views', 'chat', 'map', 'dimensions', 'workflows'];
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
icon: typeof Search;
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
function ToolbarButton({ icon: Icon, label, shortcut, onClick, disabled, isActive }: ToolbarButtonProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
title={shortcut ? `${label} (${shortcut})` : label}
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: isHovered ? '#1a1a1a' : 'transparent',
|
||||
color: isActive ? '#22c55e' : (isHovered ? '#aaa' : '#666'),
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.15s ease',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface PaneTypeButtonProps {
|
||||
icon: typeof LayoutList;
|
||||
label: string;
|
||||
paneType: PaneType;
|
||||
isOpen: boolean;
|
||||
isActivePane: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function PaneTypeButton({ icon: Icon, label, paneType, isOpen, isActivePane, onClick }: PaneTypeButtonProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
// Determine color: green if open, brighter if it's the active pane
|
||||
const getColor = () => {
|
||||
if (isOpen) {
|
||||
return isActivePane ? '#4ade80' : '#22c55e'; // Brighter green for active
|
||||
}
|
||||
return isHovered ? '#aaa' : '#666';
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
title={label}
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: isOpen ? '#1a1a1a' : (isHovered ? '#151515' : 'transparent'),
|
||||
color: getColor(),
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.15s ease',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{/* Active pane indicator dot */}
|
||||
{isActivePane && isOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '4px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: '4px',
|
||||
height: '4px',
|
||||
borderRadius: '50%',
|
||||
background: '#4ade80',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LeftToolbar({
|
||||
onSearchClick,
|
||||
onAddStuffClick,
|
||||
onSettingsClick,
|
||||
onPaneTypeClick,
|
||||
activePane,
|
||||
slotAType,
|
||||
slotBType,
|
||||
}: LeftToolbarProps) {
|
||||
// Determine which pane types are currently open
|
||||
const openPaneTypes = new Set<PaneType>(
|
||||
[slotAType, slotBType].filter((t): t is PaneType => t !== null)
|
||||
);
|
||||
|
||||
// Determine which pane type is in the active pane (null if pane is closed)
|
||||
const activePaneType = activePane === 'A' ? slotAType : slotBType;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '50px',
|
||||
height: '100%',
|
||||
background: '#0a0a0a',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 0',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* Top section - Actions */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
|
||||
<ToolbarButton
|
||||
icon={Search}
|
||||
label="Search"
|
||||
shortcut="⌘K"
|
||||
onClick={onSearchClick}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={Plus}
|
||||
label="Add Stuff"
|
||||
onClick={onAddStuffClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Middle section - Pane Types */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '8px 0',
|
||||
}}
|
||||
>
|
||||
{TOOLBAR_PANE_TYPES.map((paneType) => {
|
||||
const Icon = PANE_TYPE_ICONS[paneType];
|
||||
const label = PANE_TYPE_LABELS[paneType];
|
||||
const isOpen = openPaneTypes.has(paneType);
|
||||
const isActivePane = activePaneType === paneType;
|
||||
|
||||
return (
|
||||
<PaneTypeButton
|
||||
key={paneType}
|
||||
icon={Icon}
|
||||
label={label}
|
||||
paneType={paneType}
|
||||
isOpen={isOpen}
|
||||
isActivePane={isActivePane}
|
||||
onClick={() => onPaneTypeClick(paneType)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bottom section - Settings */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<ToolbarButton
|
||||
icon={Settings}
|
||||
label="Settings"
|
||||
onClick={onSettingsClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
|
||||
interface SplitHandleProps {
|
||||
isSecondPaneOpen: boolean;
|
||||
onOpenSecondPane: () => void;
|
||||
onResize: (newWidthPercent: number) => void;
|
||||
onCloseSecondPane: () => void;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
toolbarWidth?: number;
|
||||
}
|
||||
|
||||
export default function SplitHandle({
|
||||
isSecondPaneOpen,
|
||||
onOpenSecondPane,
|
||||
onResize,
|
||||
onCloseSecondPane,
|
||||
containerRef,
|
||||
toolbarWidth = 50,
|
||||
}: SplitHandleProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const startXRef = useRef<number>(0);
|
||||
const startWidthRef = useRef<number>(50);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!isSecondPaneOpen) {
|
||||
// First drag opens the second pane at 50%
|
||||
onOpenSecondPane();
|
||||
}
|
||||
|
||||
setIsDragging(true);
|
||||
startXRef.current = e.clientX;
|
||||
|
||||
// Calculate current width from container
|
||||
if (containerRef.current) {
|
||||
const containerWidth = containerRef.current.offsetWidth - toolbarWidth;
|
||||
// Assume current position is at the split point
|
||||
startWidthRef.current = 50; // Default to 50% for new split
|
||||
}
|
||||
}, [isSecondPaneOpen, onOpenSecondPane, containerRef, toolbarWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const containerWidth = containerRect.width - toolbarWidth;
|
||||
const mouseX = e.clientX - containerRect.left - toolbarWidth;
|
||||
|
||||
// Calculate what percentage of the available space should be Slot B
|
||||
// mouseX is distance from left edge, so slotA width = mouseX
|
||||
// slotB width = containerWidth - mouseX
|
||||
const slotBWidthPercent = ((containerWidth - mouseX) / containerWidth) * 100;
|
||||
|
||||
// Clamp between 20% and 70%
|
||||
const clampedWidth = Math.max(20, Math.min(70, slotBWidthPercent));
|
||||
|
||||
// If dragged to less than 15%, close the pane
|
||||
if (slotBWidthPercent < 15) {
|
||||
onCloseSecondPane();
|
||||
setIsDragging(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onResize(clampedWidth);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
}, [isDragging, containerRef, toolbarWidth, onResize, onCloseSecondPane]);
|
||||
|
||||
// When second pane is closed, show a wider handle with grip icon
|
||||
if (!isSecondPaneOpen) {
|
||||
return (
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{
|
||||
width: '12px',
|
||||
cursor: 'col-resize',
|
||||
background: isHovered ? '#1a1a1a' : 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background 0.15s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title="Drag to split view (⌘\)"
|
||||
>
|
||||
<GripVertical
|
||||
size={12}
|
||||
color={isHovered ? '#888' : '#444'}
|
||||
style={{ transition: 'color 0.15s ease' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// When second pane is open, show resize handle
|
||||
return (
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{
|
||||
width: '8px',
|
||||
cursor: 'col-resize',
|
||||
background: isDragging ? '#22c55e' : (isHovered ? '#1a1a1a' : 'transparent'),
|
||||
transition: isDragging ? 'none' : 'background 0.15s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,11 +41,12 @@ interface FolderViewOverlayProps {
|
||||
onNodeOpen: (nodeId: number) => void;
|
||||
refreshToken: number;
|
||||
onDataChanged?: () => void;
|
||||
onDimensionSelect?: (dimensionName: string | null) => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, onDataChanged }: FolderViewOverlayProps) {
|
||||
export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, onDataChanged, onDimensionSelect: _onDimensionSelect }: FolderViewOverlayProps) {
|
||||
const [view, setView] = useState<'dimensions' | 'nodes'>('dimensions');
|
||||
const [dimensions, setDimensions] = useState<DimensionSummary[]>([]);
|
||||
const [dimensionsLoading, setDimensionsLoading] = useState(true);
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import RAHChat from '@/components/agents/RAHChat';
|
||||
import PaneHeader from './PaneHeader';
|
||||
import { ChatPaneProps } from './types';
|
||||
import type { ChatMessage } from '@/components/agents/hooks/useSSEChat';
|
||||
|
||||
export default function ChatPane({
|
||||
slot,
|
||||
isActive,
|
||||
onPaneAction,
|
||||
onCollapse,
|
||||
onSwapPanes,
|
||||
openTabsData,
|
||||
activeTabId,
|
||||
activeDimension,
|
||||
onClearDimension,
|
||||
onNodeClick,
|
||||
delegations,
|
||||
chatMessages: externalMessages,
|
||||
setChatMessages: externalSetMessages,
|
||||
highlightedPassage,
|
||||
onClearPassage,
|
||||
}: ChatPaneProps) {
|
||||
const [rahMode, setRahMode] = useState<'easy' | 'hard'>('easy');
|
||||
const [hasStarted, setHasStarted] = useState(false);
|
||||
|
||||
// Use lifted state if provided, otherwise fall back to local state
|
||||
const [internalMessages, setInternalMessages] = useState<ChatMessage[]>([]);
|
||||
|
||||
const rahMessages = (externalMessages as ChatMessage[]) ?? internalMessages;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const setRahMessages = (externalSetMessages as any) ?? setInternalMessages;
|
||||
|
||||
// Load mode from localStorage
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const stored = window.localStorage.getItem('rah-mode');
|
||||
if (stored === 'easy' || stored === 'hard') {
|
||||
setRahMode(stored);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Persist mode to localStorage
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem('rah-mode', rahMode);
|
||||
}, [rahMode]);
|
||||
|
||||
// Listen for mode toggle events
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ mode: 'easy' | 'hard' }>).detail;
|
||||
if (detail?.mode) {
|
||||
setRahMode(detail.mode);
|
||||
}
|
||||
};
|
||||
window.addEventListener('rah:mode-toggle', handler as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('rah:mode-toggle', handler as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-start if there are existing messages
|
||||
useEffect(() => {
|
||||
if (rahMessages.length > 0 && !hasStarted) {
|
||||
setHasStarted(true);
|
||||
}
|
||||
}, [rahMessages.length, hasStarted]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} />
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
{!hasStarted ? (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '20px',
|
||||
}}>
|
||||
{/* Start Chat button */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setHasStarted(true);
|
||||
setRahMode('easy');
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '14px',
|
||||
padding: '0',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
const text = e.currentTarget.querySelector('.start-text') as HTMLElement;
|
||||
const icon = e.currentTarget.querySelector('.start-icon') as HTMLElement;
|
||||
if (text) text.style.color = '#22c55e';
|
||||
if (icon) {
|
||||
icon.style.transform = 'translateY(-3px)';
|
||||
icon.style.boxShadow = '0 8px 20px rgba(34, 197, 94, 0.3), 0 0 0 4px rgba(34, 197, 94, 0.1)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const text = e.currentTarget.querySelector('.start-text') as HTMLElement;
|
||||
const icon = e.currentTarget.querySelector('.start-icon') as HTMLElement;
|
||||
if (text) text.style.color = '#737373';
|
||||
if (icon) {
|
||||
icon.style.transform = 'translateY(0)';
|
||||
icon.style.boxShadow = '0 0 0 0 rgba(34, 197, 94, 0)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="start-text"
|
||||
style={{
|
||||
color: '#737373',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.15em',
|
||||
textTransform: 'uppercase',
|
||||
transition: 'color 0.2s ease'
|
||||
}}
|
||||
>
|
||||
Start
|
||||
</span>
|
||||
<div
|
||||
className="start-icon"
|
||||
style={{
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
borderRadius: '50%',
|
||||
background: '#22c55e',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: '0 0 0 0 rgba(34, 197, 94, 0)'
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#0a0a0a" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 19V5"/>
|
||||
<path d="M5 12l7-7 7 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<RAHChat
|
||||
openTabsData={openTabsData}
|
||||
activeTabId={activeTabId}
|
||||
activeDimension={activeDimension}
|
||||
onClearDimension={onClearDimension}
|
||||
onNodeClick={onNodeClick}
|
||||
delegations={delegations}
|
||||
messages={rahMessages}
|
||||
setMessages={setRahMessages}
|
||||
mode={rahMode}
|
||||
highlightedPassage={highlightedPassage}
|
||||
onClearPassage={onClearPassage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import FolderViewOverlay from '@/components/nodes/FolderViewOverlay';
|
||||
import PaneHeader from './PaneHeader';
|
||||
import { DimensionsPaneProps, PaneType } from './types';
|
||||
|
||||
export default function DimensionsPane({
|
||||
slot,
|
||||
isActive,
|
||||
onPaneAction,
|
||||
onCollapse,
|
||||
onSwapPanes,
|
||||
onNodeOpen,
|
||||
refreshToken,
|
||||
onDataChanged,
|
||||
onDimensionSelect,
|
||||
}: DimensionsPaneProps) {
|
||||
const handleTypeChange = (type: PaneType) => {
|
||||
onPaneAction?.({ type: 'switch-pane-type', paneType: type });
|
||||
};
|
||||
|
||||
// When used as a pane, "close" means switch back to node view
|
||||
const handleClose = () => {
|
||||
onPaneAction?.({ type: 'switch-pane-type', paneType: 'node' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} />
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', position: 'relative' }}>
|
||||
{/* FolderViewOverlay expects to be an overlay, so we wrap it in a container */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'transparent',
|
||||
}}>
|
||||
<FolderViewOverlay
|
||||
onClose={handleClose}
|
||||
onNodeOpen={onNodeOpen}
|
||||
refreshToken={refreshToken}
|
||||
onDataChanged={onDataChanged}
|
||||
onDimensionSelect={onDimensionSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, useCallback, type CSSProperties } from 'react';
|
||||
import type { Edge, Node as DbNode } from '@/types/database';
|
||||
import PaneHeader from './PaneHeader';
|
||||
import type { MapPaneProps } from './types';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
interface GraphNode extends DbNode {
|
||||
edge_count?: number;
|
||||
x: number;
|
||||
y: number;
|
||||
radius: number;
|
||||
isExpanded?: boolean; // Node was dynamically loaded as a connection
|
||||
}
|
||||
|
||||
interface DimensionInfo {
|
||||
dimension: string;
|
||||
count: number;
|
||||
isPriority: boolean;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
const NODE_LIMIT = 200;
|
||||
const LABEL_THRESHOLD = 15;
|
||||
|
||||
export default function MapPane({
|
||||
slot,
|
||||
isActive,
|
||||
onPaneAction,
|
||||
onCollapse,
|
||||
onSwapPanes,
|
||||
onNodeClick,
|
||||
activeTabId,
|
||||
}: MapPaneProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [containerSize, setContainerSize] = useState({ width: 800, height: 600 });
|
||||
const [baseNodes, setBaseNodes] = useState<DbNode[]>([]); // Nodes from dimension filter
|
||||
const [expandedNodes, setExpandedNodes] = useState<DbNode[]>([]); // Connected nodes loaded dynamically
|
||||
const [edges, setEdges] = useState<Edge[]>([]);
|
||||
const [lockedDimensions, setLockedDimensions] = useState<DimensionInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<DbNode | null>(null);
|
||||
const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 });
|
||||
|
||||
// Dimension filter state
|
||||
const [selectedDimension, setSelectedDimension] = useState<string | null>(null);
|
||||
const [dimensionDropdownOpen, setDimensionDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Combine base nodes + expanded nodes
|
||||
const allNodes = useMemo(() => {
|
||||
const baseNodeIds = new Set(baseNodes.map(n => n.id));
|
||||
// Add expanded nodes that aren't already in base nodes
|
||||
const uniqueExpanded = expandedNodes.filter(n => !baseNodeIds.has(n.id));
|
||||
return [...baseNodes, ...uniqueExpanded];
|
||||
}, [baseNodes, expandedNodes]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as HTMLElement)) {
|
||||
setDimensionDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (dimensionDropdownOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [dimensionDropdownOpen]);
|
||||
|
||||
// Resize observer
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver(entries => {
|
||||
const entry = entries[0];
|
||||
if (entry?.contentRect) {
|
||||
setContainerSize({
|
||||
width: entry.contentRect.width,
|
||||
height: entry.contentRect.height,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (containerRef.current) {
|
||||
observer.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Fetch base data (dimension-filtered nodes)
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Build nodes URL with optional dimension filter
|
||||
const nodesUrl = selectedDimension
|
||||
? `/api/nodes?limit=${NODE_LIMIT}&sortBy=edges&dimensions=${encodeURIComponent(selectedDimension)}`
|
||||
: `/api/nodes?limit=${NODE_LIMIT}&sortBy=edges`;
|
||||
|
||||
const [nodesRes, edgesRes, dimsRes] = await Promise.all([
|
||||
fetch(nodesUrl),
|
||||
fetch('/api/edges'),
|
||||
fetch('/api/dimensions/popular'),
|
||||
]);
|
||||
|
||||
if (!nodesRes.ok || !edgesRes.ok) {
|
||||
throw new Error('Failed to load data');
|
||||
}
|
||||
|
||||
const nodesPayload = await nodesRes.json();
|
||||
const edgesPayload = await edgesRes.json();
|
||||
|
||||
setBaseNodes(nodesPayload.data || []);
|
||||
setEdges(edgesPayload.data || []);
|
||||
setExpandedNodes([]); // Clear expanded nodes when filter changes
|
||||
setSelectedNode(null); // Clear selection when filter changes
|
||||
|
||||
// Get locked (priority) dimensions
|
||||
if (dimsRes.ok) {
|
||||
const dimsPayload = await dimsRes.json();
|
||||
if (dimsPayload.success && dimsPayload.data) {
|
||||
const locked = (dimsPayload.data as DimensionInfo[])
|
||||
.filter((d) => d.isPriority);
|
||||
setLockedDimensions(locked);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [selectedDimension]);
|
||||
|
||||
// Fetch edges and connected nodes when a node is selected
|
||||
const fetchConnectedNodes = useCallback(async (nodeId: number) => {
|
||||
try {
|
||||
// First, fetch edges for this specific node (in case we don't have them)
|
||||
const edgesRes = await fetch(`/api/nodes/${nodeId}/edges`);
|
||||
let nodeEdges: Edge[] = [];
|
||||
|
||||
if (edgesRes.ok) {
|
||||
const edgesData = await edgesRes.json();
|
||||
nodeEdges = edgesData.data || [];
|
||||
|
||||
// Add new edges to our edges state
|
||||
if (nodeEdges.length > 0) {
|
||||
setEdges(prev => {
|
||||
const existingEdgeIds = new Set(prev.map(e => e.id));
|
||||
const newEdges = nodeEdges.filter(e => !existingEdgeIds.has(e.id));
|
||||
if (newEdges.length > 0) {
|
||||
return [...prev, ...newEdges];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get connected node IDs from both existing edges and newly fetched edges
|
||||
const connectedIds = new Set<number>();
|
||||
|
||||
// From existing edges
|
||||
edges.forEach(edge => {
|
||||
if (edge.from_node_id === nodeId) connectedIds.add(edge.to_node_id);
|
||||
if (edge.to_node_id === nodeId) connectedIds.add(edge.from_node_id);
|
||||
});
|
||||
|
||||
// From newly fetched edges
|
||||
nodeEdges.forEach(edge => {
|
||||
if (edge.from_node_id === nodeId) connectedIds.add(edge.to_node_id);
|
||||
if (edge.to_node_id === nodeId) connectedIds.add(edge.from_node_id);
|
||||
});
|
||||
|
||||
// Find which connected nodes we don't have yet
|
||||
const existingIds = new Set(allNodes.map(n => n.id));
|
||||
const missingIds = Array.from(connectedIds).filter(id => !existingIds.has(id));
|
||||
|
||||
if (missingIds.length === 0) return;
|
||||
|
||||
// Fetch missing nodes
|
||||
const fetchPromises = missingIds.slice(0, 50).map(async (id) => { // Limit to 50 to prevent overload
|
||||
try {
|
||||
const res = await fetch(`/api/nodes/${id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
return data.node as DbNode;
|
||||
}
|
||||
} catch {
|
||||
// Ignore individual fetch errors
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const fetchedNodes = (await Promise.all(fetchPromises)).filter((n): n is DbNode => n !== null);
|
||||
|
||||
if (fetchedNodes.length > 0) {
|
||||
setExpandedNodes(prev => {
|
||||
const existingExpandedIds = new Set(prev.map(n => n.id));
|
||||
const newNodes = fetchedNodes.filter(n => !existingExpandedIds.has(n.id));
|
||||
return [...prev, ...newNodes];
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch connected nodes:', err);
|
||||
}
|
||||
}, [edges, allNodes]);
|
||||
|
||||
// When selection changes, fetch connected nodes
|
||||
useEffect(() => {
|
||||
if (selectedNode) {
|
||||
fetchConnectedNodes(selectedNode.id);
|
||||
}
|
||||
}, [selectedNode, fetchConnectedNodes]);
|
||||
|
||||
// Sync with focused node from other panes (focused node awareness)
|
||||
useEffect(() => {
|
||||
if (!activeTabId) return;
|
||||
|
||||
// Check if this node is already in our nodes
|
||||
const existingNode = allNodes.find(n => n.id === activeTabId);
|
||||
|
||||
if (existingNode) {
|
||||
// Node is visible, just select it
|
||||
setSelectedNode(existingNode);
|
||||
} else {
|
||||
// Node not in current view - fetch it and add as expanded
|
||||
const fetchFocusedNode = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/nodes/${activeTabId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const node = data.node as DbNode;
|
||||
if (node) {
|
||||
// Add to expanded nodes
|
||||
setExpandedNodes(prev => {
|
||||
if (prev.some(n => n.id === node.id)) return prev;
|
||||
return [...prev, node];
|
||||
});
|
||||
// Select it
|
||||
setSelectedNode(node);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch focused node:', err);
|
||||
}
|
||||
};
|
||||
fetchFocusedNode();
|
||||
}
|
||||
}, [activeTabId, allNodes]);
|
||||
|
||||
// Position nodes in a cluster layout
|
||||
const graphNodes = useMemo<GraphNode[]>(() => {
|
||||
if (allNodes.length === 0) return [];
|
||||
|
||||
const { width, height } = containerSize;
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
|
||||
// Separate base nodes and expanded nodes
|
||||
const baseNodeIds = new Set(baseNodes.map(n => n.id));
|
||||
|
||||
// Sort base nodes by edge count
|
||||
const sortedBase = [...baseNodes].sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0));
|
||||
const maxEdges = Math.max(...sortedBase.map(n => n.edge_count ?? 0), 1);
|
||||
|
||||
// Position base nodes using spiral layout
|
||||
const positioned: GraphNode[] = sortedBase.map((node, index) => {
|
||||
const edgeCount = node.edge_count ?? 0;
|
||||
const edgeRatio = edgeCount / maxEdges;
|
||||
|
||||
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
|
||||
const angle = index * goldenAngle;
|
||||
|
||||
const isLabeled = index < LABEL_THRESHOLD;
|
||||
const labelSpacing = isLabeled ? 60 : 0;
|
||||
const baseDistance = 80 + labelSpacing + (1 - edgeRatio) * Math.min(width, height) * 0.35;
|
||||
const distance = baseDistance + (index * 4);
|
||||
|
||||
const x = centerX + Math.cos(angle) * distance;
|
||||
const y = centerY + Math.sin(angle) * distance;
|
||||
|
||||
const minRadius = 3;
|
||||
const maxRadius = 18;
|
||||
const radius = minRadius + edgeRatio * (maxRadius - minRadius);
|
||||
|
||||
return { ...node, x, y, radius, isExpanded: false };
|
||||
});
|
||||
|
||||
// Track positioned node IDs for quick lookup
|
||||
const positionedMap = new Map<number, GraphNode>();
|
||||
positioned.forEach(n => positionedMap.set(n.id, n));
|
||||
|
||||
// Position expanded nodes
|
||||
if (selectedNode) {
|
||||
// Find the selected node's position (could be in base or already expanded)
|
||||
let selectedGraphNode = positionedMap.get(selectedNode.id);
|
||||
|
||||
// If selected node is an expanded node not yet positioned, position it first
|
||||
if (!selectedGraphNode && !baseNodeIds.has(selectedNode.id)) {
|
||||
// Position the selected expanded node at a reasonable location
|
||||
// Use the center or find a reference point
|
||||
const selectedExpanded: GraphNode = {
|
||||
...selectedNode,
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
radius: 10,
|
||||
isExpanded: true,
|
||||
};
|
||||
positioned.push(selectedExpanded);
|
||||
positionedMap.set(selectedNode.id, selectedExpanded);
|
||||
selectedGraphNode = selectedExpanded;
|
||||
}
|
||||
|
||||
if (selectedGraphNode) {
|
||||
// Get all expanded nodes that need positioning (not in base and not already positioned)
|
||||
const expandedToPosition = expandedNodes.filter(n =>
|
||||
!baseNodeIds.has(n.id) && !positionedMap.has(n.id)
|
||||
);
|
||||
|
||||
expandedToPosition.forEach((node, index) => {
|
||||
// Position in a circle around the selected node
|
||||
const angle = (index / Math.max(expandedToPosition.length, 1)) * Math.PI * 2;
|
||||
const distance = 120 + (index % 3) * 30; // Vary distance slightly
|
||||
|
||||
const x = selectedGraphNode!.x + Math.cos(angle) * distance;
|
||||
const y = selectedGraphNode!.y + Math.sin(angle) * distance;
|
||||
|
||||
const newNode: GraphNode = {
|
||||
...node,
|
||||
x,
|
||||
y,
|
||||
radius: 8, // Fixed smaller radius for expanded nodes
|
||||
isExpanded: true,
|
||||
};
|
||||
positioned.push(newNode);
|
||||
positionedMap.set(node.id, newNode);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return positioned;
|
||||
}, [allNodes, baseNodes, expandedNodes, containerSize, selectedNode]);
|
||||
|
||||
// Get edges between visible nodes
|
||||
const graphEdges = useMemo(() => {
|
||||
if (graphNodes.length === 0 || edges.length === 0) return [];
|
||||
|
||||
const nodeMap = new Map<number, GraphNode>();
|
||||
graphNodes.forEach(node => nodeMap.set(node.id, node));
|
||||
|
||||
return edges
|
||||
.map(edge => {
|
||||
const source = nodeMap.get(edge.from_node_id);
|
||||
const target = nodeMap.get(edge.to_node_id);
|
||||
if (!source || !target) return null;
|
||||
return { id: edge.id, source, target };
|
||||
})
|
||||
.filter(Boolean) as Array<{ id: number; source: GraphNode; target: GraphNode }>;
|
||||
}, [edges, graphNodes]);
|
||||
|
||||
// Get connected node IDs for selected node
|
||||
const connectedNodeIds = useMemo(() => {
|
||||
if (!selectedNode) return new Set<number>();
|
||||
const connected = new Set<number>();
|
||||
edges.forEach(edge => {
|
||||
if (edge.from_node_id === selectedNode.id) connected.add(edge.to_node_id);
|
||||
if (edge.to_node_id === selectedNode.id) connected.add(edge.from_node_id);
|
||||
});
|
||||
return connected;
|
||||
}, [selectedNode, edges]);
|
||||
|
||||
// Set of locked dimension names for styling
|
||||
const lockedDimensionNames = useMemo(() =>
|
||||
new Set(lockedDimensions.map(d => d.dimension)),
|
||||
[lockedDimensions]
|
||||
);
|
||||
|
||||
// Pan handling
|
||||
const handlePanStart = (event: React.PointerEvent<SVGRectElement>) => {
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const originX = transform.x;
|
||||
const originY = transform.y;
|
||||
|
||||
const handleMove = (moveEvent: PointerEvent) => {
|
||||
setTransform(prev => ({
|
||||
...prev,
|
||||
x: originX + (moveEvent.clientX - startX),
|
||||
y: originY + (moveEvent.clientY - startY),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', handleUp);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
window.addEventListener('pointerup', handleUp);
|
||||
};
|
||||
|
||||
const handleZoom = (direction: 'in' | 'out' | 'reset') => {
|
||||
if (direction === 'reset') {
|
||||
setTransform({ x: 0, y: 0, scale: 1 });
|
||||
return;
|
||||
}
|
||||
setTransform(prev => ({
|
||||
...prev,
|
||||
scale: direction === 'in'
|
||||
? Math.min(prev.scale + 0.2, 3)
|
||||
: Math.max(prev.scale - 0.2, 0.5),
|
||||
}));
|
||||
};
|
||||
|
||||
// Handle node click - supports traversal
|
||||
const handleNodeClick = (node: GraphNode) => {
|
||||
const isCurrentlySelected = selectedNode?.id === node.id;
|
||||
|
||||
if (isCurrentlySelected) {
|
||||
// Clicking selected node deselects it
|
||||
setSelectedNode(null);
|
||||
} else {
|
||||
// Select this node - will trigger fetch of its connections
|
||||
setSelectedNode(node);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaneTypeChange = (type: typeof slot extends 'A' | 'B' ? import('./types').PaneType : never) => {
|
||||
onPaneAction?.({ type: 'switch-pane-type', paneType: type });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'transparent', overflow: 'hidden' }}>
|
||||
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes}>
|
||||
{/* Dimension filter dropdown */}
|
||||
<div ref={dropdownRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setDimensionDropdownOpen(!dimensionDropdownOpen)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '6px 10px',
|
||||
background: selectedDimension ? 'rgba(34, 197, 94, 0.1)' : 'transparent',
|
||||
border: '1px solid',
|
||||
borderColor: selectedDimension ? 'rgba(34, 197, 94, 0.3)' : '#2a2a2a',
|
||||
borderRadius: '6px',
|
||||
color: selectedDimension ? '#22c55e' : '#888',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<span>{selectedDimension || 'All dimensions'}</span>
|
||||
<ChevronDown size={12} style={{
|
||||
transform: dimensionDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.15s ease',
|
||||
}} />
|
||||
</button>
|
||||
|
||||
{dimensionDropdownOpen && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
marginTop: '4px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '8px',
|
||||
padding: '4px',
|
||||
minWidth: '180px',
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
{/* All dimensions option */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedDimension(null);
|
||||
setDimensionDropdownOpen(false);
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
background: !selectedDimension ? '#2a2a2a' : 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: !selectedDimension ? '#fff' : '#888',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.1s ease',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
All dimensions
|
||||
{!selectedDimension && <span style={{ marginLeft: 'auto', color: '#22c55e' }}>✓</span>}
|
||||
</button>
|
||||
|
||||
{lockedDimensions.map((dim) => (
|
||||
<button
|
||||
key={dim.dimension}
|
||||
onClick={() => {
|
||||
setSelectedDimension(dim.dimension);
|
||||
setDimensionDropdownOpen(false);
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
background: selectedDimension === dim.dimension ? '#2a2a2a' : 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: selectedDimension === dim.dimension ? '#fff' : '#888',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.1s ease',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedDimension !== dim.dimension) {
|
||||
e.currentTarget.style.background = '#222';
|
||||
e.currentTarget.style.color = '#ccc';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (selectedDimension !== dim.dimension) {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = '#888';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{dim.dimension}
|
||||
{selectedDimension === dim.dimension && <span style={{ marginLeft: 'auto', color: '#22c55e' }}>✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PaneHeader>
|
||||
|
||||
{/* Map content */}
|
||||
<div ref={containerRef} style={{ position: 'relative', flex: 1, background: '#080808' }}>
|
||||
{loading ? (
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#666' }}>
|
||||
Loading map...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#ef4444' }}>
|
||||
{error}
|
||||
</div>
|
||||
) : graphNodes.length === 0 ? (
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#666' }}>
|
||||
No nodes to display
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Zoom controls */}
|
||||
<div style={{ position: 'absolute', top: 16, right: 16, display: 'flex', gap: 8, zIndex: 10 }}>
|
||||
<button onClick={() => handleZoom('in')} style={controlBtn} title="Zoom in">+</button>
|
||||
<button onClick={() => handleZoom('out')} style={controlBtn} title="Zoom out">−</button>
|
||||
<button onClick={() => handleZoom('reset')} style={controlBtn} title="Reset">⟳</button>
|
||||
</div>
|
||||
|
||||
{/* Selected node info */}
|
||||
{selectedNode && (
|
||||
<div style={infoPanel}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: 8 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>
|
||||
{selectedNode.title || 'Untitled'}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedNode(null)}
|
||||
style={{ background: 'none', border: 'none', color: '#666', cursor: 'pointer', fontSize: 16 }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>
|
||||
{connectedNodeIds.size} connected nodes
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#22c55e', marginBottom: 8 }}>
|
||||
Click a connected node to traverse
|
||||
</div>
|
||||
{selectedNode.dimensions && selectedNode.dimensions.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 8 }}>
|
||||
{selectedNode.dimensions.slice(0, 5).map(dim => (
|
||||
<span
|
||||
key={dim}
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
fontSize: 11,
|
||||
background: lockedDimensionNames.has(dim) ? '#132018' : '#1a1a1a',
|
||||
color: lockedDimensionNames.has(dim) ? '#86efac' : '#888',
|
||||
}}
|
||||
>
|
||||
{dim}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Open node button */}
|
||||
<button
|
||||
onClick={() => onNodeClick?.(selectedNode.id)}
|
||||
style={{
|
||||
marginTop: 4,
|
||||
padding: '8px 12px',
|
||||
background: '#22c55e',
|
||||
color: '#052e16',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
Open Node
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SVG Graph */}
|
||||
<svg width="100%" height="100%" style={{ display: 'block' }}>
|
||||
<defs />
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill="transparent"
|
||||
style={{ cursor: 'grab' }}
|
||||
onPointerDown={handlePanStart}
|
||||
/>
|
||||
<g transform={`translate(${transform.x} ${transform.y}) scale(${transform.scale})`}>
|
||||
{/* Edges */}
|
||||
{graphEdges.map(edge => {
|
||||
const isConnected = selectedNode && (
|
||||
edge.source.id === selectedNode.id || edge.target.id === selectedNode.id
|
||||
);
|
||||
return (
|
||||
<line
|
||||
key={edge.id}
|
||||
x1={edge.source.x}
|
||||
y1={edge.source.y}
|
||||
x2={edge.target.x}
|
||||
y2={edge.target.y}
|
||||
stroke={isConnected ? '#22c55e' : '#374151'}
|
||||
strokeWidth={isConnected ? 1.5 : 0.75}
|
||||
strokeOpacity={selectedNode ? (isConnected ? 0.9 : 0.15) : 0.6}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Nodes */}
|
||||
{graphNodes.map((node, index) => {
|
||||
const isBaseNode = !node.isExpanded;
|
||||
const isTop = isBaseNode && index < LABEL_THRESHOLD;
|
||||
const isSelected = selectedNode?.id === node.id;
|
||||
const isConnectedToSelected = connectedNodeIds.has(node.id);
|
||||
const isDimmed = selectedNode && !isSelected && !isConnectedToSelected;
|
||||
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
onClick={() => handleNodeClick(node)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
opacity={isDimmed ? 0.25 : 1}
|
||||
>
|
||||
{/* Highlight ring for connected nodes */}
|
||||
{isConnectedToSelected && !isSelected && (
|
||||
<circle
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={node.radius + 4}
|
||||
fill="none"
|
||||
stroke="#22c55e"
|
||||
strokeWidth={2}
|
||||
strokeOpacity={0.6}
|
||||
/>
|
||||
)}
|
||||
{/* Node circle */}
|
||||
<circle
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={node.radius}
|
||||
fill={node.isExpanded ? '#f59e0b' : (isTop ? '#22c55e' : '#334155')}
|
||||
fillOpacity={node.isExpanded ? 0.7 : (isTop ? 0.6 : 0.4)}
|
||||
stroke={isSelected ? '#fff' : (node.isExpanded ? '#b45309' : (isTop ? '#166534' : '#1e293b'))}
|
||||
strokeWidth={isSelected ? 2 : (isTop ? 1.5 : 0.5)}
|
||||
/>
|
||||
|
||||
{/* Label for top base nodes OR expanded nodes */}
|
||||
{(isTop || node.isExpanded) && (
|
||||
<>
|
||||
<text
|
||||
x={node.x}
|
||||
y={node.y + node.radius + 14}
|
||||
textAnchor="middle"
|
||||
fill={node.isExpanded ? '#fbbf24' : '#e5e7eb'}
|
||||
fontSize={node.isExpanded ? 10 : 11}
|
||||
fontWeight={500}
|
||||
>
|
||||
{(node.title || 'Untitled').slice(0, 20)}
|
||||
{(node.title?.length ?? 0) > 20 ? '…' : ''}
|
||||
</text>
|
||||
|
||||
{!node.isExpanded && node.dimensions && node.dimensions.length > 0 && (() => {
|
||||
const dims = node.dimensions.slice(0, 3).map(d => d.length > 10 ? d.slice(0, 9) + '…' : d).join(' · ');
|
||||
const labelWidth = dims.length * 5 + 16;
|
||||
return (
|
||||
<g>
|
||||
<rect
|
||||
x={node.x - labelWidth / 2}
|
||||
y={node.y + node.radius + 18}
|
||||
width={labelWidth}
|
||||
height={16}
|
||||
rx={8}
|
||||
fill="#141414"
|
||||
stroke="#262626"
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
<text
|
||||
x={node.x}
|
||||
y={node.y + node.radius + 29}
|
||||
textAnchor="middle"
|
||||
fill="#a1a1aa"
|
||||
fontSize={9}
|
||||
>
|
||||
{dims}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Show dimension for expanded nodes */}
|
||||
{node.isExpanded && node.dimensions && node.dimensions.length > 0 && (
|
||||
<text
|
||||
x={node.x}
|
||||
y={node.y + node.radius + 24}
|
||||
textAnchor="middle"
|
||||
fill="#a1a1aa"
|
||||
fontSize={9}
|
||||
>
|
||||
{node.dimensions[0]}
|
||||
</text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const controlBtn: CSSProperties = {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 6,
|
||||
border: '1px solid #262626',
|
||||
background: '#141414',
|
||||
color: '#888',
|
||||
fontSize: 16,
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
const infoPanel: CSSProperties = {
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
width: 260,
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #1f1f1f',
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
zIndex: 10,
|
||||
};
|
||||
@@ -0,0 +1,267 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import FocusPanel from '@/components/focus/FocusPanel';
|
||||
import PaneHeader from './PaneHeader';
|
||||
import { NodePaneProps, PaneType } from './types';
|
||||
|
||||
// Simple truncate for tab titles
|
||||
function truncateTitle(title: string, maxLength = 20): string {
|
||||
if (title.length <= maxLength) return title;
|
||||
return title.slice(0, maxLength - 1) + '…';
|
||||
}
|
||||
|
||||
export default function NodePane({
|
||||
slot,
|
||||
isActive,
|
||||
onPaneAction,
|
||||
onCollapse,
|
||||
onSwapPanes,
|
||||
openTabs,
|
||||
activeTab,
|
||||
onTabSelect,
|
||||
onTabClose,
|
||||
onNodeClick,
|
||||
onReorderTabs,
|
||||
refreshTrigger,
|
||||
onOpenInOtherSlot,
|
||||
onTextSelect,
|
||||
highlightedPassage,
|
||||
}: NodePaneProps) {
|
||||
const [nodeTitles, setNodeTitles] = useState<Record<number, string>>({});
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tabId: number } | null>(null);
|
||||
const fetchedRef = useRef<Set<number>>(new Set());
|
||||
|
||||
const handleTypeChange = (type: PaneType) => {
|
||||
onPaneAction?.({ type: 'switch-pane-type', paneType: type });
|
||||
};
|
||||
|
||||
// Fetch node titles for tabs
|
||||
useEffect(() => {
|
||||
const fetchTitle = async (tabId: number) => {
|
||||
if (fetchedRef.current.has(tabId)) return;
|
||||
fetchedRef.current.add(tabId);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/nodes/${tabId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && data.node) {
|
||||
setNodeTitles(prev => ({ ...prev, [tabId]: data.node.title || 'Untitled' }));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch node title:', error);
|
||||
fetchedRef.current.delete(tabId); // Allow retry on error
|
||||
}
|
||||
};
|
||||
|
||||
openTabs.forEach(fetchTitle);
|
||||
}, [openTabs]);
|
||||
|
||||
// Clear fetched ref when tabs are closed
|
||||
useEffect(() => {
|
||||
const currentTabs = new Set(openTabs);
|
||||
fetchedRef.current.forEach(id => {
|
||||
if (!currentTabs.has(id)) {
|
||||
fetchedRef.current.delete(id);
|
||||
}
|
||||
});
|
||||
}, [openTabs]);
|
||||
|
||||
// Close context menu on outside click or escape
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
const handleClick = () => setContextMenu(null);
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setContextMenu(null);
|
||||
};
|
||||
document.addEventListener('click', handleClick);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [contextMenu]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes}>
|
||||
{/* Tabs rendered inline */}
|
||||
{openTabs.length === 0 ? (
|
||||
<span style={{ fontSize: '12px', color: '#666' }}>No tabs open</span>
|
||||
) : (
|
||||
openTabs.map((tabId) => {
|
||||
const title = nodeTitles[tabId] || 'Loading...';
|
||||
const isActiveTab = activeTab === tabId;
|
||||
return (
|
||||
<div
|
||||
key={tabId}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = 'copyMove';
|
||||
e.dataTransfer.setData('application/x-rah-tab', JSON.stringify({ id: tabId, title, sourceSlot: slot }));
|
||||
e.dataTransfer.setData('text/plain', `[NODE:${tabId}:"${title}"]`);
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, tabId });
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 8px',
|
||||
background: isActiveTab ? '#1f1f1f' : 'transparent',
|
||||
borderRadius: '4px',
|
||||
cursor: 'grab',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => onTabSelect(tabId)}
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: isActiveTab ? '#fff' : '#888',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{truncateTitle(title)}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTabClose(tabId);
|
||||
}}
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '0 2px',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</PaneHeader>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
<FocusPanel
|
||||
openTabs={openTabs}
|
||||
activeTab={activeTab}
|
||||
onTabSelect={onTabSelect}
|
||||
onNodeClick={onNodeClick}
|
||||
onTabClose={onTabClose}
|
||||
refreshTrigger={refreshTrigger}
|
||||
onReorderTabs={onReorderTabs}
|
||||
onOpenInOtherSlot={onOpenInOtherSlot}
|
||||
onTextSelect={onTextSelect}
|
||||
highlightedPassage={highlightedPassage}
|
||||
hideTabBar
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Context menu for tabs */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: contextMenu.y,
|
||||
left: contextMenu.x,
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '6px',
|
||||
padding: '4px',
|
||||
zIndex: 9999,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
||||
minWidth: '160px',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{onOpenInOtherSlot && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onOpenInOtherSlot(contextMenu.tabId);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#ccc',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2a2a2a';
|
||||
e.currentTarget.style.color = '#fff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = '#ccc';
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '14px' }}>↗</span>
|
||||
Open in other panel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
onTabClose(contextMenu.tabId);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#ccc',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2a2a2a';
|
||||
e.currentTarget.style.color = '#fff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = '#ccc';
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '14px' }}>×</span>
|
||||
Close tab
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { PaneHeaderProps } from './types';
|
||||
|
||||
export default function PaneHeader({
|
||||
slot,
|
||||
onCollapse,
|
||||
onSwapPanes,
|
||||
children
|
||||
}: PaneHeaderProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
if (!slot) return;
|
||||
e.dataTransfer.setData('application/x-rah-pane', slot);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
if (!e.dataTransfer.types.includes('application/x-rah-pane')) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
|
||||
const sourceSlot = e.dataTransfer.getData('application/x-rah-pane');
|
||||
if (sourceSlot && sourceSlot !== slot && onSwapPanes) {
|
||||
onSwapPanes();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable={!!slot && !!onSwapPanes}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
background: isDragOver ? 'rgba(34, 197, 94, 0.1)' : 'transparent',
|
||||
minHeight: '44px',
|
||||
cursor: slot && onSwapPanes ? 'grab' : 'default',
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
borderRadius: isDragOver ? '6px' : '0',
|
||||
transition: 'background 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{/* Children (tabs, etc.) - takes up available space */}
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '4px', minWidth: 0 }}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Close button (when onCollapse is provided) */}
|
||||
{onCollapse && (
|
||||
<button
|
||||
onClick={onCollapse}
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #2a2a2a',
|
||||
background: '#111',
|
||||
color: '#777',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title="Close pane"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#1a1a1a';
|
||||
e.currentTarget.style.borderColor = '#3a3a3a';
|
||||
e.currentTarget.style.color = '#aaa';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#111';
|
||||
e.currentTarget.style.borderColor = '#2a2a2a';
|
||||
e.currentTarget.style.color = '#777';
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import PaneHeader from './PaneHeader';
|
||||
import ViewsOverlay from '../views/ViewsOverlay';
|
||||
import type { BasePaneProps, PaneAction, PaneType } from './types';
|
||||
|
||||
export interface ViewsPaneProps extends BasePaneProps {
|
||||
onNodeClick: (nodeId: number) => void;
|
||||
onNodeOpenInOtherPane?: (nodeId: number) => void;
|
||||
refreshToken?: number;
|
||||
}
|
||||
|
||||
export default function ViewsPane({
|
||||
slot,
|
||||
isActive,
|
||||
onPaneAction,
|
||||
onCollapse,
|
||||
onSwapPanes,
|
||||
onNodeClick,
|
||||
onNodeOpenInOtherPane,
|
||||
refreshToken
|
||||
}: ViewsPaneProps) {
|
||||
const handleTypeChange = (type: PaneType) => {
|
||||
onPaneAction?.({ type: 'switch-pane-type', paneType: type });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
background: 'transparent',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} />
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<ViewsOverlay
|
||||
onNodeClick={onNodeClick}
|
||||
onNodeOpenInOtherPane={onNodeOpenInOtherPane}
|
||||
refreshToken={refreshToken}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import RAHChat from '@/components/agents/RAHChat';
|
||||
import PaneHeader from './PaneHeader';
|
||||
import { WorkflowsPaneProps, PaneType } from './types';
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer';
|
||||
import type { ChatMessage } from '@/components/agents/hooks/useSSEChat';
|
||||
|
||||
type ViewMode = 'list' | 'detail' | 'summary';
|
||||
|
||||
export default function WorkflowsPane({
|
||||
slot,
|
||||
isActive,
|
||||
onPaneAction,
|
||||
onCollapse,
|
||||
onSwapPanes,
|
||||
delegations,
|
||||
onNodeClick,
|
||||
openTabsData = [],
|
||||
activeTabId = null,
|
||||
activeDimension,
|
||||
}: WorkflowsPaneProps) {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [selectedDelegationId, setSelectedDelegationId] = useState<string | null>(null);
|
||||
const [delegationMessages, setDelegationMessages] = useState<Record<string, ChatMessage[]>>({});
|
||||
|
||||
const selectedDelegation = delegations.find(d => d.sessionId === selectedDelegationId);
|
||||
|
||||
const getDelegationMessages = useCallback((sessionId: string) => {
|
||||
return delegationMessages[sessionId] || [];
|
||||
}, [delegationMessages]);
|
||||
|
||||
const setDelegationMessagesFor = useCallback((sessionId: string) => {
|
||||
return (updater: (prev: ChatMessage[]) => ChatMessage[]) => {
|
||||
setDelegationMessages(prev => ({
|
||||
...prev,
|
||||
[sessionId]: updater(prev[sessionId] || [])
|
||||
}));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSelectDelegation = (sessionId: string) => {
|
||||
const delegation = delegations.find(d => d.sessionId === sessionId);
|
||||
setSelectedDelegationId(sessionId);
|
||||
|
||||
// Show summary view for completed/failed with no messages
|
||||
if (delegation &&
|
||||
(delegation.status === 'completed' || delegation.status === 'failed') &&
|
||||
getDelegationMessages(sessionId).length === 0) {
|
||||
setViewMode('summary');
|
||||
} else {
|
||||
setViewMode('detail');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDelegation = async (sessionId: string) => {
|
||||
try {
|
||||
await fetch(`/api/rah/delegations/${sessionId}`, { method: 'DELETE' });
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete delegation ${sessionId}:`, error);
|
||||
}
|
||||
// The parent will handle removing from delegations list via SSE
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setSelectedDelegationId(null);
|
||||
setViewMode('list');
|
||||
};
|
||||
|
||||
const handleTypeChange = (type: PaneType) => {
|
||||
onPaneAction?.({ type: 'switch-pane-type', paneType: type });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} />
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
{viewMode === 'list' && (
|
||||
<WorkflowsListView
|
||||
delegations={delegations}
|
||||
onSelectDelegation={handleSelectDelegation}
|
||||
onDeleteDelegation={handleDeleteDelegation}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'summary' && selectedDelegation && (
|
||||
<DelegationSummaryView
|
||||
delegation={selectedDelegation}
|
||||
onBack={handleBack}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'detail' && selectedDelegation && (
|
||||
<DelegationDetailView
|
||||
delegation={selectedDelegation}
|
||||
openTabsData={openTabsData}
|
||||
activeTabId={activeTabId}
|
||||
activeDimension={activeDimension}
|
||||
onNodeClick={onNodeClick}
|
||||
delegations={delegations}
|
||||
messages={getDelegationMessages(selectedDelegation.sessionId)}
|
||||
setMessages={setDelegationMessagesFor(selectedDelegation.sessionId)}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Workflows list view
|
||||
function WorkflowsListView({
|
||||
delegations,
|
||||
onSelectDelegation,
|
||||
onDeleteDelegation,
|
||||
onNodeClick
|
||||
}: {
|
||||
delegations: AgentDelegation[];
|
||||
onSelectDelegation: (sessionId: string) => void;
|
||||
onDeleteDelegation: (sessionId: string) => void;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}) {
|
||||
const activeDelegations = delegations.filter(d => d.status === 'queued' || d.status === 'in_progress');
|
||||
const completedDelegations = delegations.filter(d => d.status === 'completed' || d.status === 'failed');
|
||||
|
||||
const getStatusInfo = (delegation: AgentDelegation) => {
|
||||
const isWiseRAH = delegation.agentType === 'wise-rah';
|
||||
let color = '#6b6b6b';
|
||||
let label = 'Queued';
|
||||
|
||||
if (delegation.status === 'in_progress') {
|
||||
color = isWiseRAH ? '#8b5cf6' : '#22c55e';
|
||||
label = 'Running';
|
||||
} else if (delegation.status === 'completed') {
|
||||
color = '#22c55e';
|
||||
label = 'Done';
|
||||
} else if (delegation.status === 'failed') {
|
||||
color = '#ff6b6b';
|
||||
label = 'Failed';
|
||||
}
|
||||
|
||||
return { color, label };
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<span style={{
|
||||
color: '#e5e5e5',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em'
|
||||
}}>
|
||||
Workflows
|
||||
</span>
|
||||
{activeDelegations.length > 0 && (
|
||||
<span style={{
|
||||
color: '#22c55e',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500
|
||||
}}>
|
||||
{activeDelegations.length} running
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: '12px'
|
||||
}}>
|
||||
{delegations.length === 0 ? (
|
||||
<div style={{
|
||||
color: '#666',
|
||||
fontSize: '12px',
|
||||
textAlign: 'center',
|
||||
padding: '40px 20px'
|
||||
}}>
|
||||
No workflows yet. Use Quick Capture or ask RA-H to run a workflow.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{activeDelegations.map((delegation) => {
|
||||
const { color, label } = getStatusInfo(delegation);
|
||||
return (
|
||||
<WorkflowCard
|
||||
key={delegation.sessionId}
|
||||
delegation={delegation}
|
||||
statusColor={color}
|
||||
statusLabel={label}
|
||||
onSelect={() => onSelectDelegation(delegation.sessionId)}
|
||||
onDelete={() => onDeleteDelegation(delegation.sessionId)}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{activeDelegations.length > 0 && completedDelegations.length > 0 && (
|
||||
<div style={{
|
||||
height: '1px',
|
||||
background: '#1f1f1f',
|
||||
margin: '8px 0'
|
||||
}} />
|
||||
)}
|
||||
|
||||
{completedDelegations.map((delegation) => {
|
||||
const { color, label } = getStatusInfo(delegation);
|
||||
return (
|
||||
<WorkflowCard
|
||||
key={delegation.sessionId}
|
||||
delegation={delegation}
|
||||
statusColor={color}
|
||||
statusLabel={label}
|
||||
onSelect={() => onSelectDelegation(delegation.sessionId)}
|
||||
onDelete={() => onDeleteDelegation(delegation.sessionId)}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Individual workflow card
|
||||
function WorkflowCard({
|
||||
delegation,
|
||||
statusColor,
|
||||
statusLabel,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onNodeClick
|
||||
}: {
|
||||
delegation: AgentDelegation;
|
||||
statusColor: string;
|
||||
statusLabel: string;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}) {
|
||||
const isActive = delegation.status === 'in_progress' || delegation.status === 'queued';
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
background: '#151515',
|
||||
border: '1px solid #1f1f1f',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
position: 'relative'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#1a1a1a';
|
||||
e.currentTarget.style.borderColor = '#2a2a2a';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#151515';
|
||||
e.currentTarget.style.borderColor = '#1f1f1f';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: statusColor,
|
||||
animation: isActive ? 'pulse 2s infinite' : 'none'
|
||||
}} />
|
||||
<span style={{
|
||||
color: statusColor,
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em'
|
||||
}}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<span style={{
|
||||
color: '#555',
|
||||
fontSize: '11px',
|
||||
marginLeft: 'auto'
|
||||
}}>
|
||||
{new Date(delegation.createdAt).toLocaleTimeString()}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#444',
|
||||
cursor: 'pointer',
|
||||
padding: '2px 6px',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.color = '#ff6b6b'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.color = '#444'}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
color: '#e5e5e5',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.4',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical'
|
||||
}}>
|
||||
{delegation.task}
|
||||
</div>
|
||||
|
||||
{delegation.summary && delegation.status === 'completed' && (
|
||||
<div style={{
|
||||
color: '#666',
|
||||
fontSize: '11px',
|
||||
marginTop: '8px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{parseAndRenderContent(delegation.summary, onNodeClick)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Summary view for completed delegations
|
||||
function DelegationSummaryView({
|
||||
delegation,
|
||||
onBack,
|
||||
onNodeClick
|
||||
}: {
|
||||
delegation: AgentDelegation;
|
||||
onBack?: () => void;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
}) {
|
||||
const isSuccess = delegation.status === 'completed';
|
||||
const statusColor = isSuccess ? '#22c55e' : '#ff6b6b';
|
||||
const statusLabel = isSuccess ? 'Completed' : 'Failed';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
padding: '24px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
marginBottom: '24px',
|
||||
paddingBottom: '16px',
|
||||
borderBottom: '1px solid #1a1a1a'
|
||||
}}>
|
||||
{onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#666',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
)}
|
||||
<div style={{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
borderRadius: '50%',
|
||||
background: statusColor
|
||||
}} />
|
||||
<span style={{
|
||||
color: statusColor,
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em'
|
||||
}}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<span style={{
|
||||
color: '#666',
|
||||
fontSize: '11px',
|
||||
marginLeft: 'auto'
|
||||
}}>
|
||||
{new Date(delegation.updatedAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div style={{
|
||||
color: '#666',
|
||||
fontSize: '10px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
Task
|
||||
</div>
|
||||
<div style={{
|
||||
color: '#e5e5e5',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.5'
|
||||
}}>
|
||||
{delegation.task}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{delegation.summary && (
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
color: '#666',
|
||||
fontSize: '10px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
Result
|
||||
</div>
|
||||
<div style={{
|
||||
color: isSuccess ? '#a8a8a8' : '#fca5a5',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.6',
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}>
|
||||
{parseAndRenderContent(delegation.summary || '', onNodeClick)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!delegation.summary && (
|
||||
<div style={{
|
||||
color: '#666',
|
||||
fontSize: '12px',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
No details available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Detail view with chat
|
||||
function DelegationDetailView({
|
||||
delegation,
|
||||
openTabsData,
|
||||
activeTabId,
|
||||
activeDimension,
|
||||
onNodeClick,
|
||||
delegations,
|
||||
messages,
|
||||
setMessages,
|
||||
onBack
|
||||
}: {
|
||||
delegation: AgentDelegation;
|
||||
openTabsData: any[];
|
||||
activeTabId: number | null;
|
||||
activeDimension?: string | null;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
delegations: AgentDelegation[];
|
||||
messages: ChatMessage[];
|
||||
setMessages: (updater: (prev: ChatMessage[]) => ChatMessage[]) => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
background: 'transparent'
|
||||
}}>
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#666',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.color = '#a8a8a8'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.color = '#666'}
|
||||
>
|
||||
← Workflows
|
||||
</button>
|
||||
<span style={{ color: '#555', fontSize: '11px' }}>|</span>
|
||||
<span style={{
|
||||
color: delegation.status === 'in_progress' ? '#22c55e' : '#666',
|
||||
fontSize: '11px',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
{delegation.status === 'in_progress' ? 'Running' : delegation.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||
<RAHChat
|
||||
openTabsData={openTabsData}
|
||||
activeTabId={activeTabId}
|
||||
activeDimension={activeDimension}
|
||||
onNodeClick={onNodeClick}
|
||||
delegations={delegations}
|
||||
messages={messages}
|
||||
setMessages={setMessages}
|
||||
mode="easy"
|
||||
delegationMode={true}
|
||||
delegationSessionId={delegation.sessionId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { default as NodePane } from './NodePane';
|
||||
export { default as ChatPane } from './ChatPane';
|
||||
export { default as WorkflowsPane } from './WorkflowsPane';
|
||||
export { default as DimensionsPane } from './DimensionsPane';
|
||||
export { default as MapPane } from './MapPane';
|
||||
export { default as ViewsPane } from './ViewsPane';
|
||||
export { default as PaneHeader } from './PaneHeader';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Node } from '@/types/database';
|
||||
import type { AgentDelegation } from '@/services/agents/delegation';
|
||||
|
||||
// The six pane types
|
||||
export type PaneType = 'node' | 'chat' | 'workflows' | 'dimensions' | 'map' | 'views';
|
||||
|
||||
// State for each slot
|
||||
export interface SlotState {
|
||||
type: PaneType;
|
||||
// NodePane state
|
||||
nodeTabs?: number[];
|
||||
activeNodeTab?: number | null;
|
||||
// DimensionsPane state
|
||||
selectedDimension?: string | null;
|
||||
viewMode?: 'grid' | 'list' | 'kanban';
|
||||
}
|
||||
|
||||
// Actions panes can emit to the layout
|
||||
export type PaneAction =
|
||||
| { type: 'open-node'; nodeId: number; targetSlot?: 'A' | 'B' }
|
||||
| { type: 'open-dimension'; dimension: string; targetSlot?: 'A' | 'B' }
|
||||
| { type: 'switch-pane-type'; paneType: PaneType }
|
||||
| { type: 'close-pane' };
|
||||
|
||||
// Common props for all panes
|
||||
export interface BasePaneProps {
|
||||
slot: 'A' | 'B';
|
||||
isActive: boolean;
|
||||
onPaneAction?: (action: PaneAction) => void;
|
||||
onCollapse?: () => void;
|
||||
onSwapPanes?: () => void;
|
||||
}
|
||||
|
||||
// NodePane specific props
|
||||
export interface NodePaneProps extends BasePaneProps {
|
||||
openTabs: number[];
|
||||
activeTab: number | null;
|
||||
onTabSelect: (nodeId: number) => void;
|
||||
onTabClose: (nodeId: number) => void;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
onReorderTabs?: (fromIndex: number, toIndex: number) => void;
|
||||
refreshTrigger?: number;
|
||||
onOpenInOtherSlot?: (nodeId: number) => void;
|
||||
onTextSelect?: (nodeId: number, nodeTitle: string, text: string) => void;
|
||||
highlightedPassage?: HighlightedPassage | null;
|
||||
}
|
||||
|
||||
// Highlighted passage for source awareness
|
||||
export interface HighlightedPassage {
|
||||
nodeId: number;
|
||||
nodeTitle: string;
|
||||
selectedText: string;
|
||||
}
|
||||
|
||||
// ChatPane specific props
|
||||
export interface ChatPaneProps extends BasePaneProps {
|
||||
openTabsData: Node[];
|
||||
activeTabId: number | null;
|
||||
activeDimension?: string | null;
|
||||
onClearDimension?: () => void;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
delegations: AgentDelegation[];
|
||||
// Lifted state for persistence
|
||||
chatMessages?: unknown[];
|
||||
setChatMessages?: React.Dispatch<React.SetStateAction<unknown[]>>;
|
||||
// Source awareness
|
||||
highlightedPassage?: HighlightedPassage | null;
|
||||
onClearPassage?: () => void;
|
||||
}
|
||||
|
||||
// WorkflowsPane specific props
|
||||
export interface WorkflowsPaneProps extends BasePaneProps {
|
||||
delegations: AgentDelegation[];
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
openTabsData?: Node[];
|
||||
activeTabId?: number | null;
|
||||
activeDimension?: string | null;
|
||||
}
|
||||
|
||||
// DimensionsPane specific props
|
||||
export interface DimensionsPaneProps extends BasePaneProps {
|
||||
onNodeOpen: (nodeId: number) => void;
|
||||
refreshToken: number;
|
||||
onDataChanged?: () => void;
|
||||
onDimensionSelect?: (dimensionName: string | null) => void;
|
||||
}
|
||||
|
||||
// MapPane specific props
|
||||
export interface MapPaneProps extends BasePaneProps {
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
activeTabId?: number | null;
|
||||
}
|
||||
|
||||
// ViewsPane specific props
|
||||
export interface ViewsPaneProps extends BasePaneProps {
|
||||
onNodeClick: (nodeId: number) => void;
|
||||
onNodeOpenInOtherPane?: (nodeId: number) => void;
|
||||
refreshToken?: number;
|
||||
}
|
||||
|
||||
// Pane header props
|
||||
export interface PaneHeaderProps {
|
||||
slot?: 'A' | 'B';
|
||||
onCollapse?: () => void;
|
||||
onSwapPanes?: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
// Labels for pane types
|
||||
export const PANE_LABELS: Record<PaneType, string> = {
|
||||
node: 'Nodes',
|
||||
chat: 'Chat',
|
||||
workflows: 'Workflows',
|
||||
dimensions: 'Dimensions',
|
||||
map: 'Map',
|
||||
views: 'Feed',
|
||||
};
|
||||
|
||||
// Default slot states
|
||||
export const DEFAULT_SLOT_A: SlotState = {
|
||||
type: 'node',
|
||||
nodeTabs: [],
|
||||
activeNodeTab: null,
|
||||
};
|
||||
|
||||
export const DEFAULT_SLOT_B: SlotState = {
|
||||
type: 'chat',
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user