sync: multiple features from private repo

- quick-add-loading: fire-and-forget with loading placeholders, auto-open feed, SSE events, QuickAddInput redesign
- database-table-pane: TablePane + DatabaseTableView, countNodes(), event_date sort
- ui-polish-fixes: focus tab order, dim name editing, tab title sync, cost chip removal, custom sort drag-reorder, refresh button, dimension filter removal (~2,200 lines)
- node-creation-quality: description service prompt rewrite, title sanitization, extraction tool AI prompt rewrites
- feed-pane-ux: stripped kanban/grid/saved views, sort dropdown, AND dimension filtering, description preview

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
“BeeRad”
2026-02-15 17:57:32 +11:00
co-authored by Claude Opus 4.6
parent 9954792b1d
commit 2f6518207d
25 changed files with 1590 additions and 2647 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝
``` ```
A local SQLite database with a UI for storing knowledge, and an MCP server so your AI tools can read/write to it. **TL;DR:** Clone this repository and you'll have a local SQLite database on your computer. The database schema is structured so external AI agents can continuously read and write to it, building your knowledge graph externally.
**Full documentation:** [ra-h.app/docs/open-source](https://ra-h.app/docs/open-source) **Full documentation:** [ra-h.app/docs/open-source](https://ra-h.app/docs/open-source)
+50 -15
View File
@@ -33,7 +33,7 @@ export async function GET(request: NextRequest) {
// Handle sortBy parameter (sortBy=edges|updated|created) // Handle sortBy parameter (sortBy=edges|updated|created)
const sortByParam = searchParams.get('sortBy'); const sortByParam = searchParams.get('sortBy');
if (sortByParam === 'edges' || sortByParam === 'updated' || sortByParam === 'created') { if (sortByParam === 'edges' || sortByParam === 'updated' || sortByParam === 'created' || sortByParam === 'event_date') {
filters.sortBy = sortByParam; filters.sortBy = sortByParam;
} }
@@ -46,11 +46,13 @@ export async function GET(request: NextRequest) {
} }
const nodes = await nodeService.getNodes(filters); const nodes = await nodeService.getNodes(filters);
const total = await nodeService.countNodes(filters);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: nodes, data: nodes,
count: nodes.length count: nodes.length,
total
}); });
} catch (error) { } catch (error) {
console.error('Error fetching nodes:', error); console.error('Error fetching nodes:', error);
@@ -62,6 +64,20 @@ export async function GET(request: NextRequest) {
} }
} }
// Weak-pattern regex for post-creation quality monitoring
const WEAK_PATTERNS = /\b(discusses|explores|examines|talks about|is about|delves into|This is a)\b/i;
function sanitizeTitle(title: string): string {
let clean = title.trim();
// Strip "Title: " prefix (extraction artifact)
if (clean.startsWith('Title: ')) clean = clean.slice(7);
// Strip trailing " / X" (Twitter artifact)
if (clean.endsWith(' / X')) clean = clean.slice(0, -4);
// Collapse whitespace
clean = clean.replace(/\s+/g, ' ');
return clean.slice(0, 160);
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json(); const body = await request.json();
@@ -74,7 +90,11 @@ export async function POST(request: NextRequest) {
}, { status: 400 }); }, { status: 400 });
} }
// Sanitize title (strip extraction artifacts)
body.title = sanitizeTitle(body.title);
const rawNotes = typeof body.notes === 'string' ? body.notes : null; const rawNotes = typeof body.notes === 'string' ? body.notes : null;
const eventDate = typeof body.event_date === 'string' ? body.event_date : null;
// Process provided dimensions first (needed for description generation) // Process provided dimensions first (needed for description generation)
const providedDimensions = Array.isArray(body.dimensions) ? body.dimensions : []; const providedDimensions = Array.isArray(body.dimensions) ? body.dimensions : [];
@@ -83,19 +103,33 @@ export async function POST(request: NextRequest) {
.filter(Boolean) .filter(Boolean)
.slice(0, 8); .slice(0, 8);
// Generate description with all available context // Use provided description if present, otherwise auto-generate
let nodeDescription: string | undefined; let nodeDescription: string | undefined = typeof body.description === 'string' && body.description.trim()
try { ? body.description.trim().slice(0, 280)
nodeDescription = await generateDescription({ : undefined;
title: body.title,
notes: rawNotes || undefined, if (!nodeDescription) {
link: body.link || undefined, try {
metadata: body.metadata, nodeDescription = await generateDescription({
dimensions: trimmedProvidedDimensions title: body.title,
}); notes: rawNotes || undefined,
} catch (error) { link: body.link || undefined,
console.error('Error generating description:', error); metadata: body.metadata,
// Continue without description - dimension assignment will use content as fallback dimensions: trimmedProvidedDimensions
});
} catch (error) {
console.error('Error generating description:', error);
}
}
// Final safety net — never store null/empty description
if (!nodeDescription || nodeDescription.trim().length === 0) {
nodeDescription = body.title.slice(0, 280);
}
// Monitor description quality
if (nodeDescription && WEAK_PATTERNS.test(nodeDescription)) {
console.warn(`[DescriptionQuality] Weak description for node "${body.title}": "${nodeDescription}"`);
} }
// Auto-assign locked dimensions + keyword dimensions for all new nodes // Auto-assign locked dimensions + keyword dimensions for all new nodes
@@ -129,6 +163,7 @@ export async function POST(request: NextRequest) {
title: body.title, title: body.title,
description: nodeDescription, description: nodeDescription,
notes: rawNotes ?? undefined, notes: rawNotes ?? undefined,
event_date: eventDate ?? undefined,
link: body.link, link: body.link,
dimensions: finalDimensions, dimensions: finalDimensions,
chunk: chunkToStore ?? undefined, chunk: chunkToStore ?? undefined,
+208 -259
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useCallback } from 'react'; import { useState, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
interface QuickAddSubmitPayload { interface QuickAddSubmitPayload {
@@ -11,11 +11,40 @@ interface QuickAddSubmitPayload {
interface QuickAddInputProps { interface QuickAddInputProps {
onSubmit: (payload: QuickAddSubmitPayload) => Promise<void>; onSubmit: (payload: QuickAddSubmitPayload) => Promise<void>;
// External control (optional - if provided, component is controlled)
isOpen?: boolean; isOpen?: boolean;
onClose?: () => void; onClose?: () => void;
} }
type DetectedType = 'youtube' | 'website' | 'pdf-url' | 'note' | 'chat';
function detectType(raw: string): DetectedType {
const trimmed = raw.trim();
if (!trimmed) return 'note';
if (/youtu(\.be|be\.com)/i.test(trimmed)) return 'youtube';
if (/\.pdf($|\?)/i.test(trimmed) || /arxiv\.org\//i.test(trimmed)) return 'pdf-url';
if (/^https?:\/\//i.test(trimmed)) return 'website';
const newlines = (trimmed.match(/\n/g)?.length ?? 0);
if (newlines >= 3 && trimmed.length > 300) return 'chat';
if (/You said:|ChatGPT said:|Claude said:/i.test(trimmed)) return 'chat';
return 'note';
}
const TYPE_LABELS: Record<DetectedType, string> = {
youtube: 'YouTube',
website: 'Link',
'pdf-url': 'PDF',
note: 'Note',
chat: 'Transcript',
};
const TYPE_COLORS: Record<DetectedType, string> = {
youtube: '#ef4444',
website: '#3b82f6',
'pdf-url': '#f59e0b',
note: '#22c55e',
chat: '#a78bfa',
};
export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInputProps) { export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInputProps) {
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [isPosting, setIsPosting] = useState(false); const [isPosting, setIsPosting] = useState(false);
@@ -24,42 +53,32 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
const [uploadedFile, setUploadedFile] = useState<File | null>(null); const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null); const [uploadError, setUploadError] = useState<string | null>(null);
// Support both controlled (isOpen/onClose) and uncontrolled (internal state) modes
const isControlled = isOpen !== undefined; const isControlled = isOpen !== undefined;
const isExpanded = isControlled ? isOpen : isExpandedInternal; const isExpanded = isControlled ? isOpen : isExpandedInternal;
const setIsExpanded = isControlled const setIsExpanded = isControlled
? (value: boolean) => { if (!value && onClose) onClose(); } ? (value: boolean) => { if (!value && onClose) onClose(); }
: setIsExpandedInternal; : setIsExpandedInternal;
const detectedType = useMemo(() => detectType(input), [input]);
const showTypePill = input.trim().length > 0 && !uploadedFile;
const handleFileUpload = useCallback(async (file: File) => { const handleFileUpload = useCallback(async (file: File) => {
setIsPosting(true); setIsPosting(true);
setUploadError(null); setUploadError(null);
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
const response = await fetch('/api/extract/pdf/upload', { const response = await fetch('/api/extract/pdf/upload', {
method: 'POST', method: 'POST',
body: formData, body: formData,
}); });
const result = await response.json(); const result = await response.json();
if (!response.ok || !result.success) { if (!response.ok || !result.success) {
throw new Error(result.error || 'Upload failed'); throw new Error(result.error || 'Upload failed');
} }
// Success - clear state
setUploadedFile(null); setUploadedFile(null);
setInput(''); setInput('');
setIsExpanded(false); setIsExpanded(false);
// Show warning if present (large file)
if (result.warning) {
console.log('[QuickAddInput] Upload warning:', result.warning);
}
} catch (error) { } catch (error) {
console.error('[QuickAddInput] Upload error:', error); console.error('[QuickAddInput] Upload error:', error);
setUploadError(error instanceof Error ? error.message : 'Upload failed'); setUploadError(error instanceof Error ? error.message : 'Upload failed');
@@ -69,22 +88,14 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
}, []); }, []);
const handleSubmit = async () => { const handleSubmit = async () => {
// If there's a file, upload it
if (uploadedFile) { if (uploadedFile) {
await handleFileUpload(uploadedFile); await handleFileUpload(uploadedFile);
return; return;
} }
// Otherwise, submit text as before
if (!input.trim() || isPosting) return; if (!input.trim() || isPosting) return;
setIsPosting(true); setIsPosting(true);
try { try {
// Mode is auto-detected server-side via quickAdd.ts detectInputType() await onSubmit({ input: input.trim(), mode: 'link' });
await onSubmit({
input: input.trim(),
mode: 'link', // Default; actual type is inferred server-side
});
setInput(''); setInput('');
setIsExpanded(false); setIsExpanded(false);
} catch (error) { } catch (error) {
@@ -124,30 +135,18 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
e.stopPropagation(); e.stopPropagation();
setDragOver(false); setDragOver(false);
setUploadError(null); setUploadError(null);
const file = e.dataTransfer?.files[0]; const file = e.dataTransfer?.files[0];
if (!file) return; if (!file) return;
// Check if it's a PDF
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) { if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
setUploadError('Only PDF files are supported'); setUploadError('Only PDF files are supported');
return; return;
} }
if (file.size > 50 * 1024 * 1024) {
// Check size (50MB limit) setUploadError(`File too large (${Math.round(file.size / 1024 / 1024)}MB). Max 50MB.`);
const MAX_SIZE = 50 * 1024 * 1024;
if (file.size > MAX_SIZE) {
setUploadError(`File too large (${Math.round(file.size / 1024 / 1024)}MB). Maximum is 50MB.`);
return; return;
} }
setUploadedFile(file); setUploadedFile(file);
setInput(''); // Clear text input when file is dropped setInput('');
}, []);
const clearFile = useCallback(() => {
setUploadedFile(null);
setUploadError(null);
}, []); }, []);
const hasContent = input.trim() || uploadedFile; const hasContent = input.trim() || uploadedFile;
@@ -159,12 +158,8 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
setUploadError(null); setUploadError(null);
}; };
// Collapsed state - only show button if NOT controlled externally
if (!isExpanded) { if (!isExpanded) {
// In controlled mode, don't render anything when closed
if (isControlled) return null; if (isControlled) return null;
// Uncontrolled mode - show the "ADD STUFF" button
return ( return (
<button <button
onClick={() => setIsExpanded(true)} onClick={() => setIsExpanded(true)}
@@ -190,32 +185,23 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(34, 197, 94, 0.2)'; e.currentTarget.style.background = 'rgba(34, 197, 94, 0.2)';
e.currentTarget.style.borderColor = '#22c55e'; e.currentTarget.style.borderColor = '#22c55e';
e.currentTarget.style.boxShadow = '0 0 20px rgba(34, 197, 94, 0.25)';
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(34, 197, 94, 0.1)'; e.currentTarget.style.background = 'rgba(34, 197, 94, 0.1)';
e.currentTarget.style.borderColor = 'rgba(34, 197, 94, 0.3)'; e.currentTarget.style.borderColor = 'rgba(34, 197, 94, 0.3)';
e.currentTarget.style.boxShadow = '0 0 12px rgba(34, 197, 94, 0.15)';
}} }}
> >
<span style={{ <span style={{
width: '18px', width: '18px', height: '18px', borderRadius: '50%',
height: '18px', background: '#22c55e', color: '#0a0a0a',
borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center',
background: '#22c55e', fontSize: '12px', fontWeight: 700
color: '#0a0a0a',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: 700
}}>+</span> }}>+</span>
Add Stuff Add Stuff
</button> </button>
); );
} }
// Expanded state - the card content
const modalContent = ( const modalContent = (
<div <div
className="qa-card" className="qa-card"
@@ -224,117 +210,76 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
onDrop={handleDrop} onDrop={handleDrop}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Header */} {/* File preview */}
<div className="qa-header">
<span className="qa-title">Add Stuff</span>
<button onClick={handleClose} className="qa-close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
{/* File preview (when a file is dropped) */}
{uploadedFile && ( {uploadedFile && (
<div className="qa-file-preview"> <div className="qa-file-row">
{/* PDF icon */} <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="1.5">
<div className="qa-file-icon"> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" strokeLinecap="round" strokeLinejoin="round"/>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="1.5"> <polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" strokeLinecap="round" strokeLinejoin="round"/> </svg>
<polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round"/> <span className="qa-file-name">{uploadedFile.name}</span>
<line x1="16" y1="13" x2="8" y2="13" strokeLinecap="round"/> <span className="qa-file-size">
<line x1="16" y1="17" x2="8" y2="17" strokeLinecap="round"/> {uploadedFile.size < 1024 * 1024
</svg> ? `${Math.round(uploadedFile.size / 1024)} KB`
</div> : `${(uploadedFile.size / 1024 / 1024).toFixed(1)} MB`}
</span>
{/* File info */} <button onClick={() => { setUploadedFile(null); setUploadError(null); }} className="qa-file-remove">
<div style={{ flex: 1, minWidth: 0 }}> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<div style={{
color: '#e5e5e5',
fontSize: '13px',
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{uploadedFile.name}
</div>
<div style={{ color: '#666', fontSize: '11px', marginTop: '2px' }}>
{uploadedFile.size < 1024 * 1024
? `${Math.round(uploadedFile.size / 1024)} KB`
: `${(uploadedFile.size / 1024 / 1024).toFixed(1)} MB`}
</div>
</div>
{/* Remove button */}
<button onClick={clearFile} className="qa-close" style={{ color: '#666' }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" /> <path d="M18 6L6 18M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
</button> </button>
</div> </div>
)} )}
{/* Error message */} {/* Error */}
{uploadError && ( {uploadError && (
<div style={{ <div className="qa-error">{uploadError}</div>
padding: '10px 12px',
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.2)',
borderRadius: '10px',
color: '#ef4444',
fontSize: '12px',
}}>
{uploadError}
</div>
)} )}
{/* Input area - show if no file uploaded */} {/* Input area */}
{!uploadedFile && ( {!uploadedFile && (
<div className={`qa-input-wrapper ${dragOver ? 'dragging' : ''}`}> <div className={`qa-input-area ${dragOver ? 'dragging' : ''}`}>
{/* Drag overlay */}
{dragOver && ( {dragOver && (
<div className="qa-drag-overlay"> <div className="qa-drag-overlay">
<div style={{ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="1.5">
display: 'flex', <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" strokeLinecap="round" strokeLinejoin="round"/>
flexDirection: 'column', <polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round"/>
alignItems: 'center', </svg>
gap: '8px', <span>Drop PDF</span>
color: '#22c55e'
}}>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" strokeLinecap="round" strokeLinejoin="round"/>
<polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span style={{ fontSize: '13px', fontWeight: 500 }}>Drop PDF here</span>
</div>
</div> </div>
)} )}
<textarea <textarea
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Paste a URL, note, or transcript — or drop a PDF" placeholder="Paste a URL, write a note, or drop a PDF..."
disabled={isPosting} disabled={isPosting}
autoFocus autoFocus
className="qa-textarea" className="qa-textarea"
style={{ opacity: dragOver ? 0.3 : 1 }} style={{ opacity: dragOver ? 0.2 : 1 }}
/> />
</div> </div>
)} )}
{/* Footer */} {/* Footer */}
<div className="qa-footer"> <div className="qa-footer">
<span className="qa-hint"> <div className="qa-footer-left">
{uploadedFile {showTypePill && (
? 'Ready to upload' <span className="qa-type-pill" style={{
: <><kbd>{'\u2318\u21B5'}</kbd> submit <span className="qa-hint-sep">&middot;</span> <kbd>esc</kbd> close</> color: TYPE_COLORS[detectedType],
} borderColor: TYPE_COLORS[detectedType] + '30',
</span> background: TYPE_COLORS[detectedType] + '0a',
}}>
{TYPE_LABELS[detectedType]}
</span>
)}
<span className="qa-hint">
{uploadedFile
? 'Ready to upload'
: <><kbd>{'\u2318\u21B5'}</kbd><span className="qa-hint-sep">send</span><kbd>esc</kbd><span className="qa-hint-sep">close</span></>
}
</span>
</div>
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!hasContent || isPosting} disabled={!hasContent || isPosting}
@@ -344,8 +289,8 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
<span className="qa-spinner" /> <span className="qa-spinner" />
) : ( ) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 19V5"/> <path d="M5 12h14"/>
<path d="M5 12l7-7 7 7"/> <path d="M12 5l7 7-7 7"/>
</svg> </svg>
)} )}
</button> </button>
@@ -355,86 +300,27 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
.qa-card { .qa-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 0;
background: #141414; background: #111111;
padding: 24px;
border-radius: 16px; border-radius: 16px;
border: 1px solid #262626; border: 1px solid rgba(255, 255, 255, 0.06);
transition: border-color 0.15s ease; overflow: hidden;
box-shadow: box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.04), 0 0 0 1px rgba(255, 255, 255, 0.03),
0 24px 48px -12px rgba(0, 0, 0, 0.6); 0 24px 80px -12px rgba(0, 0, 0, 0.8),
animation: qaCardIn 200ms cubic-bezier(0.16, 1, 0.3, 1); 0 0 60px -10px rgba(34, 197, 94, 0.06);
width: ${isControlled ? '540px' : 'auto'}; animation: qaIn 250ms cubic-bezier(0.16, 1, 0.3, 1);
width: ${isControlled ? '600px' : 'auto'};
max-width: ${isControlled ? '90vw' : 'none'}; max-width: ${isControlled ? '90vw' : 'none'};
} }
.qa-header { .qa-input-area {
display: flex;
align-items: center;
justify-content: space-between;
}
.qa-title {
color: #fafafa;
font-size: 15px;
font-weight: 600;
}
.qa-close {
padding: 6px;
background: transparent;
border: none;
color: #525252;
cursor: pointer;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s ease;
}
.qa-close:hover {
color: #a3a3a3;
}
.qa-file-preview {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: #0f1a0f;
border: 1px solid #1a3a1a;
border-radius: 12px;
}
.qa-file-icon {
width: 40px;
height: 40px;
border-radius: 8px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.qa-input-wrapper {
position: relative; position: relative;
border-radius: 12px; transition: all 0.2s ease;
border: 1px solid #262626;
background: #0a0a0a;
transition: all 0.15s ease;
} }
.qa-input-wrapper:focus-within { .qa-input-area.dragging {
border-color: #333; background: rgba(34, 197, 94, 0.03);
}
.qa-input-wrapper.dragging {
border: 2px dashed #22c55e;
background: rgba(34, 197, 94, 0.05);
} }
.qa-drag-overlay { .qa-drag-overlay {
@@ -443,8 +329,10 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(34, 197, 94, 0.05); gap: 8px;
border-radius: 12px; color: #22c55e;
font-size: 13px;
font-weight: 500;
z-index: 10; z-index: 10;
pointer-events: none; pointer-events: none;
} }
@@ -453,10 +341,10 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
width: 100%; width: 100%;
min-height: 120px; min-height: 120px;
max-height: 300px; max-height: 300px;
padding: 16px 18px; padding: 20px 22px;
background: transparent; background: transparent;
border: none; border: none;
color: #fafafa; color: #e5e5e5;
font-size: 15px; font-size: 15px;
font-family: inherit; font-family: inherit;
outline: none; outline: none;
@@ -466,50 +354,119 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
} }
.qa-textarea::placeholder { .qa-textarea::placeholder {
color: #525252; color: #444;
}
.qa-file-row {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.qa-file-name {
flex: 1;
min-width: 0;
color: #e5e5e5;
font-size: 13px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.qa-file-size {
color: #555;
font-size: 11px;
flex-shrink: 0;
}
.qa-file-remove {
padding: 4px;
background: transparent;
border: none;
color: #555;
cursor: pointer;
border-radius: 4px;
display: flex;
transition: color 0.15s ease;
}
.qa-file-remove:hover {
color: #ef4444;
}
.qa-error {
padding: 10px 20px;
color: #ef4444;
font-size: 12px;
background: rgba(239, 68, 68, 0.06);
border-bottom: 1px solid rgba(239, 68, 68, 0.1);
} }
.qa-footer { .qa-footer {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 12px 14px 12px 18px;
border-top: 1px solid rgba(255, 255, 255, 0.04);
}
.qa-footer-left {
display: flex;
align-items: center;
gap: 10px;
}
.qa-type-pill {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid;
text-transform: uppercase;
animation: pillIn 150ms ease-out;
} }
.qa-hint { .qa-hint {
font-size: 11px; font-size: 11px;
color: #525252; color: #3a3a3a;
display: flex;
align-items: center;
gap: 4px;
} }
.qa-hint kbd { .qa-hint kbd {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; padding: 1px 5px;
padding: 2px 6px; background: rgba(255, 255, 255, 0.04);
background: #262626; border-radius: 3px;
border-radius: 4px;
font-size: 10px; font-size: 10px;
font-family: inherit; font-family: inherit;
color: #737373; color: #444;
border: 1px solid #333;
} }
.qa-hint-sep { .qa-hint-sep {
margin: 0 2px; margin: 0 1px;
color: #333;
} }
.qa-submit { .qa-submit {
width: 36px; width: 34px;
height: 36px; height: 34px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0; padding: 0;
background: #262626; background: rgba(255, 255, 255, 0.04);
border: none; border: none;
border-radius: 10px; border-radius: 10px;
color: #525252; color: #333;
cursor: default; cursor: default;
transition: all 0.2s ease; transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
} }
.qa-submit.active { .qa-submit.active {
@@ -520,23 +477,23 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
.qa-submit.active:hover { .qa-submit.active:hover {
background: #16a34a; background: #16a34a;
transform: translateY(-1px); transform: scale(1.05);
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.3); box-shadow: 0 0 20px rgba(34, 197, 94, 0.3);
} }
.qa-spinner { .qa-spinner {
width: 14px; width: 14px;
height: 14px; height: 14px;
border: 2px solid #0a0a0a; border: 2px solid #052e16;
border-top-color: transparent; border-top-color: transparent;
border-radius: 50%; border-radius: 50%;
animation: qaSpin 0.8s linear infinite; animation: qaSpin 0.8s linear infinite;
} }
@keyframes qaCardIn { @keyframes qaIn {
from { from {
opacity: 0; opacity: 0;
transform: scale(0.96) translateY(-8px); transform: scale(0.97) translateY(-6px);
} }
to { to {
opacity: 1; opacity: 1;
@@ -544,6 +501,11 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
} }
} }
@keyframes pillIn {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
@keyframes qaSpin { @keyframes qaSpin {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
@@ -551,55 +513,42 @@ export default function QuickAddInput({ onSubmit, isOpen, onClose }: QuickAddInp
</div> </div>
); );
// In controlled mode, wrap with blurred backdrop + portal
if (isControlled) { if (isControlled) {
const backdrop = ( const backdrop = (
<div <div className="qa-backdrop" onClick={handleClose}>
className="qa-backdrop"
onClick={handleClose}
>
<div className="qa-container"> <div className="qa-container">
{modalContent} {modalContent}
</div> </div>
<style jsx>{` <style jsx>{`
.qa-backdrop { .qa-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.85); background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
display: flex; display: flex;
align-items: center;
justify-content: center; justify-content: center;
padding-top: 15vh;
z-index: 9999; z-index: 9999;
animation: qaBackdropIn 200ms ease-out; animation: qaFadeIn 200ms ease-out;
} }
.qa-container { .qa-container {
width: 100%; width: 100%;
max-width: 540px; max-width: 600px;
height: fit-content;
} }
@keyframes qaFadeIn {
@keyframes qaBackdropIn {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
} }
`}</style> `}</style>
</div> </div>
); );
return typeof window !== 'undefined' ? createPortal(backdrop, document.body) : null; return typeof window !== 'undefined' ? createPortal(backdrop, document.body) : null;
} }
// Uncontrolled mode - render with absolute positioning
return ( return (
<div style={{ <div style={{ position: 'absolute', top: '60px', left: '20px', right: '20px', zIndex: 100 }}>
position: 'absolute',
top: '60px',
left: '20px',
right: '20px',
zIndex: 100,
}}>
{modalContent} {modalContent}
</div> </div>
); );
+18 -18
View File
@@ -119,7 +119,7 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
const [regeneratingDescription, setRegeneratingDescription] = useState<number | null>(null); const [regeneratingDescription, setRegeneratingDescription] = useState<number | null>(null);
// Content tab state: 'notes', 'desc', or 'source' // Content tab state: 'notes', 'desc', or 'source'
const [activeContentTab, setActiveContentTab] = useState<'notes' | 'desc' | 'edges' | 'source'>('notes'); const [activeContentTab, setActiveContentTab] = useState<'notes' | 'desc' | 'edges' | 'source'>('desc');
// Desc (description) edit mode state // Desc (description) edit mode state
const [descEditMode, setDescEditMode] = useState(false); const [descEditMode, setDescEditMode] = useState(false);
@@ -2106,23 +2106,6 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
marginBottom: '12px', marginBottom: '12px',
borderBottom: '1px solid #1a1a1a' borderBottom: '1px solid #1a1a1a'
}}> }}>
<button
onClick={() => { setActiveContentTab('notes'); setDescEditMode(false); setSourceEditMode(false); }}
style={{
padding: '8px 16px',
fontSize: '11px',
fontWeight: activeContentTab === 'notes' ? 600 : 400,
color: activeContentTab === 'notes' ? '#e5e5e5' : '#666',
background: 'transparent',
border: 'none',
borderBottom: activeContentTab === 'notes' ? '2px solid #22c55e' : '2px solid transparent',
cursor: 'pointer',
transition: 'all 0.15s',
marginBottom: '-1px'
}}
>
Notes
</button>
<button <button
onClick={() => { setActiveContentTab('desc'); setNotesEditMode(false); setSourceEditMode(false); }} onClick={() => { setActiveContentTab('desc'); setNotesEditMode(false); setSourceEditMode(false); }}
style={{ style={{
@@ -2140,6 +2123,23 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
> >
Desc Desc
</button> </button>
<button
onClick={() => { setActiveContentTab('notes'); setDescEditMode(false); setSourceEditMode(false); }}
style={{
padding: '8px 16px',
fontSize: '11px',
fontWeight: activeContentTab === 'notes' ? 600 : 400,
color: activeContentTab === 'notes' ? '#e5e5e5' : '#666',
background: 'transparent',
border: 'none',
borderBottom: activeContentTab === 'notes' ? '2px solid #22c55e' : '2px solid transparent',
cursor: 'pointer',
transition: 'all 0.15s',
marginBottom: '-1px'
}}
>
Notes
</button>
<button <button
onClick={() => { setActiveContentTab('edges'); setDescEditMode(false); setNotesEditMode(false); setSourceEditMode(false); }} onClick={() => { setActiveContentTab('edges'); setDescEditMode(false); setNotesEditMode(false); setSourceEditMode(false); }}
style={{ style={{
+13 -1
View File
@@ -4,9 +4,11 @@ import { useState, useCallback } from 'react';
import { import {
Search, Search,
Plus, Plus,
RefreshCw,
LayoutList, LayoutList,
Map, Map,
Folder, Folder,
Table2,
Settings, Settings,
} from 'lucide-react'; } from 'lucide-react';
import type { PaneType } from '../panes/types'; import type { PaneType } from '../panes/types';
@@ -14,6 +16,7 @@ import type { PaneType } from '../panes/types';
interface LeftToolbarProps { interface LeftToolbarProps {
onSearchClick: () => void; onSearchClick: () => void;
onAddStuffClick: () => void; onAddStuffClick: () => void;
onRefreshClick: () => void;
onSettingsClick: () => void; onSettingsClick: () => void;
onPaneTypeClick: (paneType: PaneType) => void; onPaneTypeClick: (paneType: PaneType) => void;
activePane: 'A' | 'B'; activePane: 'A' | 'B';
@@ -26,16 +29,18 @@ const PANE_TYPE_ICONS: Record<string, typeof LayoutList> = {
views: LayoutList, views: LayoutList,
map: Map, map: Map,
dimensions: Folder, dimensions: Folder,
table: Table2,
}; };
const PANE_TYPE_LABELS: Record<string, string> = { const PANE_TYPE_LABELS: Record<string, string> = {
views: 'Feed', views: 'Feed',
map: 'Map', map: 'Map',
dimensions: 'Dimensions', dimensions: 'Dimensions',
table: 'Table',
}; };
// Pane types shown in the toolbar (excludes 'node', 'chat', and 'guides' which is in settings) // Pane types shown in the toolbar (excludes 'node', 'chat', and 'guides' which is in settings)
const TOOLBAR_PANE_TYPES: PaneType[] = ['views', 'map', 'dimensions']; const TOOLBAR_PANE_TYPES: PaneType[] = ['views', 'map', 'dimensions', 'table'];
interface ToolbarButtonProps { interface ToolbarButtonProps {
icon: typeof Search; icon: typeof Search;
@@ -140,6 +145,7 @@ function PaneTypeButton({ icon: Icon, label, paneType, isOpen, isActivePane, onC
export default function LeftToolbar({ export default function LeftToolbar({
onSearchClick, onSearchClick,
onAddStuffClick, onAddStuffClick,
onRefreshClick,
onSettingsClick, onSettingsClick,
onPaneTypeClick, onPaneTypeClick,
activePane, activePane,
@@ -180,6 +186,12 @@ export default function LeftToolbar({
label="Add Stuff" label="Add Stuff"
onClick={onAddStuffClick} onClick={onAddStuffClick}
/> />
<ToolbarButton
icon={RefreshCw}
label="Refresh"
shortcut="⌘⇧R"
onClick={onRefreshClick}
/>
</div> </div>
{/* Middle section - Pane Types */} {/* Middle section - Pane Types */}
+124 -26
View File
@@ -21,12 +21,21 @@ type AgentDelegation = {
updatedAt: string; updatedAt: string;
}; };
export interface PendingNode {
id: string;
input: string;
inputType: string;
submittedAt: number;
status: 'processing' | 'error';
error?: string;
}
// Layout components // Layout components
import LeftToolbar from './LeftToolbar'; import LeftToolbar from './LeftToolbar';
import SplitHandle from './SplitHandle'; import SplitHandle from './SplitHandle';
// Pane components (ChatPane removed in rah-light, GuidesPane moved to settings) // Pane components (ChatPane removed in rah-light, GuidesPane moved to settings)
import { NodePane, DimensionsPane, MapPane, ViewsPane } from '../panes'; import { NodePane, DimensionsPane, MapPane, ViewsPane, TablePane } from '../panes';
import QuickAddInput from '../agents/QuickAddInput'; import QuickAddInput from '../agents/QuickAddInput';
import type { PaneType, SlotState, PaneAction } from '../panes/types'; import type { PaneType, SlotState, PaneAction } from '../panes/types';
@@ -99,6 +108,9 @@ export default function ThreePanelLayout() {
selectedText: string; selectedText: string;
} | null>(null); } | null>(null);
// Pending quick-add nodes (loading placeholders)
const [pendingNodes, setPendingNodes] = useState<PendingNode[]>([]);
// Ref to get current openTabs value in SSE handler // Ref to get current openTabs value in SSE handler
const openTabsRef = useRef<number[]>([]); const openTabsRef = useRef<number[]>([]);
@@ -164,16 +176,23 @@ export default function ThreePanelLayout() {
} }
}; };
// Update tab data whenever openTabs changes (use string key to prevent infinite loops) // Update tab data whenever openTabs changes or focus panel refreshes (use string key to prevent infinite loops)
const openTabsKey = openTabs.join(','); const openTabsKey = openTabs.join(',');
useEffect(() => { useEffect(() => {
openTabsRef.current = openTabs; openTabsRef.current = openTabs;
fetchOpenTabsData(openTabs); fetchOpenTabsData(openTabs);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [openTabsKey]); }, [openTabsKey, focusPanelRefresh]);
// Delegations loading removed (delegation system removed in rah-light) // Delegations loading removed (delegation system removed in rah-light)
// Refresh all panes
const handleRefreshAll = useCallback(() => {
setNodesPanelRefresh(prev => prev + 1);
setFolderViewRefresh(prev => prev + 1);
setFocusPanelRefresh(prev => prev + 1);
}, []);
// Keyboard shortcut handler // Keyboard shortcut handler
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
@@ -192,6 +211,11 @@ export default function ThreePanelLayout() {
setSlotB({ type: 'node', nodeTabs: [], activeNodeTab: null }); setSlotB({ type: 'node', nodeTabs: [], activeNodeTab: null });
} }
} }
// Cmd+Shift+R - refresh all panes
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'r') {
e.preventDefault();
handleRefreshAll();
}
// Cmd+N - open Add Stuff modal // Cmd+N - open Add Stuff modal
if ((e.metaKey || e.ctrlKey) && e.key === 'n') { if ((e.metaKey || e.ctrlKey) && e.key === 'n') {
// Don't prevent default - browser may want this for new window // Don't prevent default - browser may want this for new window
@@ -205,7 +229,7 @@ export default function ThreePanelLayout() {
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [slotB, setSlotB]); }, [slotB, setSlotB, handleRefreshAll]);
// SSE connection for real-time updates // SSE connection for real-time updates
useEffect(() => { useEffect(() => {
@@ -274,6 +298,22 @@ export default function ThreePanelLayout() {
} }
break; break;
case 'QUICK_ADD_COMPLETED':
if (data.data?.quickAddId) {
setPendingNodes(prev => prev.filter(p => p.id !== data.data.quickAddId));
}
break;
case 'QUICK_ADD_FAILED':
if (data.data?.quickAddId) {
setPendingNodes(prev => prev.map(p =>
p.id === data.data.quickAddId
? { ...p, status: 'error' as const, error: data.data.error || 'Unknown error' }
: p
));
}
break;
case 'CONNECTION_ESTABLISHED': case 'CONNECTION_ESTABLISHED':
console.log('✅ SSE connection established'); console.log('✅ SSE connection established');
break; break;
@@ -301,6 +341,21 @@ export default function ThreePanelLayout() {
}; };
}, []); }, []);
// Auto-dismiss pending nodes after timeout
useEffect(() => {
if (pendingNodes.length === 0) return;
const interval = setInterval(() => {
const now = Date.now();
setPendingNodes(prev => prev.filter(p => {
const age = now - p.submittedAt;
if (p.status === 'processing' && age > 90_000) return false; // 90s timeout
if (p.status === 'error' && age > 120_000) return false; // 120s for errors
return true;
}));
}, 5000);
return () => clearInterval(interval);
}, [pendingNodes.length]);
// Node tab management // Node tab management
const handleNodeSelect = useCallback((nodeId: number, multiSelect: boolean) => { const handleNodeSelect = useCallback((nodeId: number, multiSelect: boolean) => {
// If slotA is not a node pane (or doesn't exist), switch it to node // If slotA is not a node pane (or doesn't exist), switch it to node
@@ -405,27 +460,6 @@ export default function ThreePanelLayout() {
setActivePane('A'); setActivePane('A');
}, [slotA, setSlotA]); }, [slotA, setSlotA]);
// Handle Quick Add submit (used by global Add Stuff modal)
const handleQuickAddSubmit = useCallback(async ({ input, mode, description }: { input: string; mode: 'link' | 'note' | 'chat'; description?: string }) => {
try {
const response = await fetch('/api/quick-add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input, mode, description })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to submit Quick Add');
}
// Close the modal on success
setShowAddStuff(false);
} catch (error) {
console.error('[ThreePanelLayout] Quick Add error:', error);
}
}, []);
const handleNodeDeleted = useCallback((nodeId: number) => { const handleNodeDeleted = useCallback((nodeId: number) => {
handleCloseTab(nodeId); handleCloseTab(nodeId);
}, [handleCloseTab]); }, [handleCloseTab]);
@@ -494,6 +528,51 @@ export default function ThreePanelLayout() {
} }
}, [activePane, slotA, slotB, setSlotA, setSlotB]); }, [activePane, slotA, slotB, setSlotA, setSlotB]);
// Ensure the Feed pane is visible (for quick-add loading placeholders)
const ensureFeedOpen = useCallback(() => {
if (slotA?.type === 'views') return;
if (slotB?.type === 'views') return;
handlePaneTypeClick('views');
}, [slotA, slotB, handlePaneTypeClick]);
// Handle Quick Add submit (used by global Add Stuff modal)
const handleQuickAddSubmit = useCallback(async ({ input, mode, description }: { input: string; mode: 'link' | 'note' | 'chat'; description?: string }) => {
try {
const response = await fetch('/api/quick-add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input, mode, description })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to submit Quick Add');
}
const result = await response.json();
const delegation = result.delegation;
// Add pending placeholder
if (delegation?.id && delegation?.inputType) {
setPendingNodes(prev => [{
id: delegation.id,
input: input.slice(0, 120),
inputType: delegation.inputType,
submittedAt: Date.now(),
status: 'processing' as const,
}, ...prev]);
}
// Ensure feed pane is visible
ensureFeedOpen();
// Close the modal on success
setShowAddStuff(false);
} catch (error) {
console.error('[ThreePanelLayout] Quick Add error:', error);
}
}, [ensureFeedOpen]);
// Handle closing a pane // Handle closing a pane
const handleCloseSlotA = useCallback(() => { const handleCloseSlotA = useCallback(() => {
if (slotB) { if (slotB) {
@@ -860,6 +939,24 @@ export default function ThreePanelLayout() {
}} }}
onNodeOpenInOtherPane={slot === 'A' ? handleNodeOpenInSlotB : handleNodeOpenInSlotA} onNodeOpenInOtherPane={slot === 'A' ? handleNodeOpenInSlotB : handleNodeOpenInSlotA}
refreshToken={nodesPanelRefresh} refreshToken={nodesPanelRefresh}
pendingNodes={pendingNodes}
onDismissPending={(id) => setPendingNodes(prev => prev.filter(p => p.id !== id))}
/>
);
case 'table':
return (
<TablePane
slot={slot}
isActive={isActive}
onPaneAction={slot === 'A' ? handleSlotAAction : handleSlotBAction}
onCollapse={onCollapse}
onSwapPanes={slotB ? handleSwapPanes : undefined}
onNodeClick={(nodeId) => {
handleNodeSelect(nodeId, false);
setActivePane(slot);
}}
refreshToken={nodesPanelRefresh}
/> />
); );
@@ -892,6 +989,7 @@ export default function ThreePanelLayout() {
activePane={activePane} activePane={activePane}
slotAType={slotA?.type ?? null} slotAType={slotA?.type ?? null}
slotBType={slotB?.type ?? null} slotBType={slotB?.type ?? null}
onRefreshClick={handleRefreshAll}
/> />
{/* Main content area */} {/* Main content area */}
@@ -931,7 +1029,7 @@ export default function ThreePanelLayout() {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
// Center single pane (except map) // Center single pane (except map)
...((!slotB && slotA.type !== 'map') ? { ...((!slotB && slotA.type !== 'map' && slotA.type !== 'table') ? {
maxWidth: '900px', maxWidth: '900px',
margin: '0 auto', margin: '0 auto',
width: '100%', width: '100%',
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
"use client";
import PaneHeader from './PaneHeader';
import DatabaseTableView from '../views/DatabaseTableView';
import type { BasePaneProps } from './types';
export interface TablePaneProps extends BasePaneProps {
onNodeClick: (nodeId: number) => void;
refreshToken?: number;
}
export default function TablePane({
slot,
isActive,
onPaneAction,
onCollapse,
onSwapPanes,
tabBar,
onNodeClick,
refreshToken
}: TablePaneProps) {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
background: 'transparent',
overflow: 'hidden'
}}>
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} tabBar={tabBar} />
<div style={{ flex: 1, overflow: 'hidden' }}>
<DatabaseTableView
onNodeClick={onNodeClick}
refreshToken={refreshToken}
/>
</div>
</div>
);
}
+8 -1
View File
@@ -3,11 +3,14 @@
import PaneHeader from './PaneHeader'; import PaneHeader from './PaneHeader';
import ViewsOverlay from '../views/ViewsOverlay'; import ViewsOverlay from '../views/ViewsOverlay';
import type { BasePaneProps, PaneAction, PaneType } from './types'; import type { BasePaneProps, PaneAction, PaneType } from './types';
import type { PendingNode } from '../layout/ThreePanelLayout';
export interface ViewsPaneProps extends BasePaneProps { export interface ViewsPaneProps extends BasePaneProps {
onNodeClick: (nodeId: number) => void; onNodeClick: (nodeId: number) => void;
onNodeOpenInOtherPane?: (nodeId: number) => void; onNodeOpenInOtherPane?: (nodeId: number) => void;
refreshToken?: number; refreshToken?: number;
pendingNodes?: PendingNode[];
onDismissPending?: (id: string) => void;
} }
export default function ViewsPane({ export default function ViewsPane({
@@ -18,7 +21,9 @@ export default function ViewsPane({
onSwapPanes, onSwapPanes,
onNodeClick, onNodeClick,
onNodeOpenInOtherPane, onNodeOpenInOtherPane,
refreshToken refreshToken,
pendingNodes,
onDismissPending,
}: ViewsPaneProps) { }: ViewsPaneProps) {
const handleTypeChange = (type: PaneType) => { const handleTypeChange = (type: PaneType) => {
onPaneAction?.({ type: 'switch-pane-type', paneType: type }); onPaneAction?.({ type: 'switch-pane-type', paneType: type });
@@ -38,6 +43,8 @@ export default function ViewsPane({
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
onNodeOpenInOtherPane={onNodeOpenInOtherPane} onNodeOpenInOtherPane={onNodeOpenInOtherPane}
refreshToken={refreshToken} refreshToken={refreshToken}
pendingNodes={pendingNodes}
onDismissPending={onDismissPending}
/> />
</div> </div>
</div> </div>
+1
View File
@@ -2,5 +2,6 @@ export { default as NodePane } from './NodePane';
export { default as DimensionsPane } from './DimensionsPane'; export { default as DimensionsPane } from './DimensionsPane';
export { default as MapPane } from './MapPane'; export { default as MapPane } from './MapPane';
export { default as ViewsPane } from './ViewsPane'; export { default as ViewsPane } from './ViewsPane';
export { default as TablePane } from './TablePane';
export { default as PaneHeader } from './PaneHeader'; export { default as PaneHeader } from './PaneHeader';
export * from './types'; export * from './types';
+8 -1
View File
@@ -15,7 +15,7 @@ export type AgentDelegation = {
}; };
// The four pane types (chat removed in rah-light, guides moved to settings) // The four pane types (chat removed in rah-light, guides moved to settings)
export type PaneType = 'node' | 'dimensions' | 'map' | 'views'; export type PaneType = 'node' | 'dimensions' | 'map' | 'views' | 'table';
// State for each slot // State for each slot
export interface SlotState { export interface SlotState {
@@ -89,6 +89,12 @@ export interface ViewsPaneProps extends BasePaneProps {
refreshToken?: number; refreshToken?: number;
} }
// TablePane specific props
export interface TablePaneProps extends BasePaneProps {
onNodeClick: (nodeId: number) => void;
refreshToken?: number;
}
// Pane header props // Pane header props
export interface PaneHeaderProps { export interface PaneHeaderProps {
slot?: 'A' | 'B'; slot?: 'A' | 'B';
@@ -104,6 +110,7 @@ export const PANE_LABELS: Record<PaneType, string> = {
dimensions: 'Dimensions', dimensions: 'Dimensions',
map: 'Map', map: 'Map',
views: 'Feed', views: 'Feed',
table: 'Table',
}; };
// Default slot states // Default slot states
-5
View File
@@ -97,11 +97,6 @@ export default function LogsRow({ log, isEven }: LogsRowProps) {
🧵 {metrics.thread.substring(0, 16)} 🧵 {metrics.thread.substring(0, 16)}
</span> </span>
)} )}
{metrics.cost_usd !== undefined && (
<span style={{ color: '#34d399' }}>
💰 ${metrics.cost_usd.toFixed(6)}
</span>
)}
{metrics.input_tokens !== undefined && metrics.output_tokens !== undefined && ( {metrics.input_tokens !== undefined && metrics.output_tokens !== undefined && (
<span> <span>
📊 {metrics.input_tokens} {metrics.output_tokens} 📊 {metrics.input_tokens} {metrics.output_tokens}
+629
View File
@@ -0,0 +1,629 @@
"use client";
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { Filter, ChevronDown, ChevronLeft, ChevronRight, X, ArrowUpDown, Search, ExternalLink } from 'lucide-react';
import type { Node } from '@/types/database';
type SortOrder = 'updated' | 'edges' | 'created' | 'event_date';
const SORT_LABELS: Record<SortOrder, string> = {
updated: 'Recently Updated',
edges: 'Most Edges',
created: 'Creation Date',
event_date: 'Event Date',
};
const PAGE_SIZE = 50;
interface DimensionSummary {
dimension: string;
count: number;
isPriority: boolean;
}
interface DatabaseTableViewProps {
onNodeClick: (nodeId: number) => void;
refreshToken?: number;
}
function formatRelativeTime(dateStr: string): string {
const now = Date.now();
const then = new Date(dateStr).getTime();
const diff = now - then;
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.floor(months / 12)}y ago`;
}
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return '\u2014';
try {
return dateStr.slice(0, 10);
} catch {
return '\u2014';
}
}
export default function DatabaseTableView({ onNodeClick, refreshToken = 0 }: DatabaseTableViewProps) {
const [nodes, setNodes] = useState<Node[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [sortOrder, setSortOrder] = useState<SortOrder>('updated');
const [searchQuery, setSearchQuery] = useState('');
const [activeSearch, setActiveSearch] = useState('');
const [selectedFilters, setSelectedFilters] = useState<string[]>([]);
const [dimensions, setDimensions] = useState<DimensionSummary[]>([]);
const [showFilterPicker, setShowFilterPicker] = useState(false);
const [filterSearchQuery, setFilterSearchQuery] = useState('');
const [showSortDropdown, setShowSortDropdown] = useState(false);
const [hoveredRow, setHoveredRow] = useState<number | null>(null);
const filterPickerRef = useRef<HTMLDivElement>(null);
const sortDropdownRef = useRef<HTMLDivElement>(null);
const sortedDimensions = useMemo(() => {
return [...dimensions].sort((a, b) => {
if (a.isPriority && !b.isPriority) return -1;
if (!a.isPriority && b.isPriority) return 1;
return a.dimension.localeCompare(b.dimension);
});
}, [dimensions]);
const filterPickerDimensions = sortedDimensions.filter(d =>
d.dimension.toLowerCase().includes(filterSearchQuery.toLowerCase())
);
// Fetch dimensions
useEffect(() => {
(async () => {
try {
const res = await fetch('/api/dimensions');
const data = await res.json();
if (data.success) setDimensions(data.data || []);
} catch (e) {
console.error('Error fetching dimensions:', e);
}
})();
}, [refreshToken]);
// Fetch nodes
const fetchNodes = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String((page - 1) * PAGE_SIZE),
sortBy: sortOrder,
});
if (activeSearch) params.set('search', activeSearch);
if (selectedFilters.length > 0) {
params.set('dimensions', selectedFilters.join(','));
params.set('dimensionsMatch', 'all');
}
const res = await fetch(`/api/nodes?${params}`);
const data = await res.json();
if (data.success) {
setNodes(data.data || []);
setTotal(data.total ?? data.count ?? 0);
}
} catch (e) {
console.error('Error fetching nodes:', e);
} finally {
setLoading(false);
}
}, [page, sortOrder, activeSearch, selectedFilters]);
useEffect(() => {
fetchNodes();
}, [fetchNodes, refreshToken]);
// Reset to page 1 when filters/sort/search change
const filtersKey = selectedFilters.join(',');
useEffect(() => {
setPage(1);
}, [sortOrder, activeSearch, filtersKey]);
// Close dropdowns on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (showFilterPicker && filterPickerRef.current && !filterPickerRef.current.contains(e.target as HTMLElement)) {
setShowFilterPicker(false);
setFilterSearchQuery('');
}
if (showSortDropdown && sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as HTMLElement)) {
setShowSortDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showFilterPicker, showSortDropdown]);
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
setActiveSearch(searchQuery);
};
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const startItem = (page - 1) * PAGE_SIZE + 1;
const endItem = Math.min(page * PAGE_SIZE, total);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'transparent' }}>
{/* Compact toolbar */}
<div style={{
padding: '8px 12px',
borderBottom: '1px solid #1a1a1a',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
{/* Search */}
<form onSubmit={handleSearchSubmit} style={{ display: 'flex', alignItems: 'center', gap: '0' }}>
<div style={{
display: 'flex',
alignItems: 'center',
background: '#0d0d0d',
border: '1px solid #222',
borderRadius: '6px',
padding: '0 8px',
gap: '6px',
}}>
<Search size={12} style={{ color: '#555', flexShrink: 0 }} />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search nodes..."
style={{
background: 'transparent',
border: 'none',
color: '#f0f0f0',
fontSize: '12px',
padding: '5px 0',
outline: 'none',
width: '140px',
}}
/>
{activeSearch && (
<button
type="button"
onClick={() => { setSearchQuery(''); setActiveSearch(''); }}
style={{ background: 'transparent', border: 'none', color: '#555', cursor: 'pointer', padding: 0, display: 'flex' }}
>
<X size={11} />
</button>
)}
</div>
</form>
{/* Filter chips + button */}
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flex: 1, flexWrap: 'wrap' }}>
{selectedFilters.map(f => (
<div key={f} style={{
display: 'flex', alignItems: 'center', gap: '4px',
padding: '2px 7px',
background: 'rgba(34, 197, 94, 0.06)',
border: '1px solid rgba(34, 197, 94, 0.12)',
borderRadius: '4px', fontSize: '11px', color: '#5a9'
}}>
{f}
<button
onClick={() => setSelectedFilters(selectedFilters.filter(x => x !== f))}
style={{ background: 'transparent', border: 'none', color: '#5a9', cursor: 'pointer', padding: 0, display: 'flex' }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#5a9'; }}
>
<X size={10} />
</button>
</div>
))}
<div style={{ position: 'relative' }} ref={filterPickerRef}>
<button
onClick={() => setShowFilterPicker(!showFilterPicker)}
style={{
display: 'flex', alignItems: 'center', gap: '4px',
padding: '4px 7px', background: 'transparent',
border: '1px solid #222', borderRadius: '5px',
color: '#888', fontSize: '11px', cursor: 'pointer',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
<Filter size={11} />
Filter
</button>
{showFilterPicker && (
<div style={{
position: 'absolute', top: '100%', left: 0, marginTop: '4px',
background: '#141414', border: '1px solid #222', borderRadius: '10px',
padding: '6px', minWidth: '220px', maxHeight: '320px', overflowY: 'auto',
zIndex: 1000, boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
}}>
<input
type="text"
value={filterSearchQuery}
onChange={(e) => setFilterSearchQuery(e.target.value)}
placeholder="Search dimensions..."
autoFocus
style={{
width: '100%', padding: '7px 10px', background: '#0d0d0d',
border: '1px solid transparent', borderRadius: '6px',
color: '#f0f0f0', fontSize: '12px', marginBottom: '4px', outline: 'none',
}}
/>
{filterPickerDimensions.length === 0 ? (
<div style={{ padding: '12px', color: '#666', fontSize: '12px', textAlign: 'center' }}>
No matching dimensions
</div>
) : (
filterPickerDimensions.map(d => (
<button
key={d.dimension}
onClick={() => {
if (!selectedFilters.includes(d.dimension)) {
setSelectedFilters([...selectedFilters, d.dimension]);
}
setShowFilterPicker(false);
setFilterSearchQuery('');
}}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
width: '100%', padding: '7px 10px', background: 'transparent',
border: 'none', borderRadius: '5px', color: '#ccc',
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
<span>{d.dimension}</span>
<span style={{ color: '#555', fontSize: '10px', background: '#1a1a1a', padding: '1px 6px', borderRadius: '10px' }}>
{d.count}
</span>
</button>
))
)}
</div>
)}
</div>
{selectedFilters.length > 0 && (
<button
onClick={() => setSelectedFilters([])}
style={{ padding: '4px 6px', background: 'transparent', border: 'none', color: '#666', fontSize: '11px', cursor: 'pointer' }}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
>
Clear
</button>
)}
</div>
{/* Sort dropdown */}
<div style={{ position: 'relative' }} ref={sortDropdownRef}>
<button
onClick={() => setShowSortDropdown(!showSortDropdown)}
style={{
display: 'flex', alignItems: 'center', gap: '4px',
padding: '4px 7px', background: 'transparent',
border: '1px solid #222', borderRadius: '5px',
color: '#888', fontSize: '11px', cursor: 'pointer', whiteSpace: 'nowrap',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
<ArrowUpDown size={11} />
{SORT_LABELS[sortOrder]}
<ChevronDown size={10} />
</button>
{showSortDropdown && (
<div style={{
position: 'absolute', top: '100%', right: 0, marginTop: '4px',
background: '#141414', border: '1px solid #222', borderRadius: '10px',
padding: '4px', minWidth: '160px', zIndex: 1000,
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
}}>
{(Object.keys(SORT_LABELS) as SortOrder[]).map(key => (
<button
key={key}
onClick={() => { setSortOrder(key); setShowSortDropdown(false); }}
style={{
display: 'flex', alignItems: 'center', gap: '8px',
width: '100%', padding: '7px 10px',
background: sortOrder === key ? 'rgba(255,255,255,0.04)' : 'transparent',
border: 'none', borderRadius: '5px',
color: sortOrder === key ? '#f0f0f0' : '#999',
fontSize: '12px', cursor: 'pointer', textAlign: 'left',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = sortOrder === key ? 'rgba(255,255,255,0.04)' : 'transparent'; }}
>
{sortOrder === key && <span style={{ color: '#22c55e', fontSize: '12px' }}></span>}
{SORT_LABELS[key]}
</button>
))}
</div>
)}
</div>
{/* Pagination */}
<div style={{
display: 'flex', alignItems: 'center', gap: '6px',
fontSize: '11px', color: '#666', whiteSpace: 'nowrap',
}}>
<span>{total > 0 ? `${startItem}-${endItem} of ${total}` : '0 nodes'}</span>
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
style={{
background: 'transparent', border: '1px solid #222', borderRadius: '4px',
color: page <= 1 ? '#333' : '#888', cursor: page <= 1 ? 'default' : 'pointer',
padding: '2px 4px', display: 'flex',
}}
>
<ChevronLeft size={14} />
</button>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
style={{
background: 'transparent', border: '1px solid #222', borderRadius: '4px',
color: page >= totalPages ? '#333' : '#888',
cursor: page >= totalPages ? 'default' : 'pointer',
padding: '2px 4px', display: 'flex',
}}
>
<ChevronRight size={14} />
</button>
</div>
</div>
{/* Table */}
<div style={{ flex: 1, overflow: 'auto' }}>
{loading ? (
<div style={{ padding: '40px', color: '#666', textAlign: 'center', fontSize: '13px' }}>Loading...</div>
) : nodes.length === 0 ? (
<div style={{ padding: '40px', color: '#666', textAlign: 'center', fontSize: '13px' }}>
{activeSearch || selectedFilters.length > 0 ? 'No nodes match your filters.' : 'No nodes yet.'}
</div>
) : (
<table style={{ minWidth: '1600px', width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<thead>
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<th style={thStyle({ width: '240px' })}>TITLE</th>
<th style={thStyle({ width: '55px', textAlign: 'right' })}>ID</th>
<th style={thStyle({ width: '200px' })}>DESCRIPTION</th>
<th style={thStyle({ width: '160px' })}>NOTES</th>
<th style={thStyle({ width: '180px' })}>LINK</th>
<th style={thStyle({ width: '160px' })}>DIMENSIONS</th>
<th style={thStyle({ width: '50px', textAlign: 'right' })}>EDGES</th>
<th style={thStyle({ width: '90px' })}>EVENT</th>
<th style={thStyle({ width: '85px' })}>UPDATED</th>
<th style={thStyle({ width: '85px' })}>CREATED</th>
<th style={thStyle({ width: '160px' })}>METADATA</th>
<th style={thStyle({ width: '160px' })}>CHUNK</th>
<th style={thStyle({ width: '80px' })}>CHUNK STATUS</th>
<th style={thStyle({ width: '85px' })}>EMB UPDATED</th>
</tr>
</thead>
<tbody>
{nodes.map((node, i) => {
const metaStr = node.metadata
? (typeof node.metadata === 'string' ? node.metadata : JSON.stringify(node.metadata))
: '';
return (
<tr
key={node.id}
onClick={() => onNodeClick(node.id)}
onMouseEnter={() => setHoveredRow(node.id)}
onMouseLeave={() => setHoveredRow(null)}
style={{
height: '44px',
cursor: 'pointer',
background: hoveredRow === node.id
? '#141414'
: i % 2 === 0 ? '#080808' : '#0d0d0d',
transition: 'background 0.1s ease',
}}
>
{/* Title */}
<td style={tdStyle()}>
<div style={truncCell}>
<span style={{ fontSize: '13px', color: '#e0e0e0', fontWeight: 400 }}>
{node.title || 'Untitled'}
</span>
</div>
</td>
{/* ID */}
<td style={tdStyle({ textAlign: 'right' })}>
<span style={{
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
fontSize: '11px', color: '#555',
}}>
{node.id}
</span>
</td>
{/* Description */}
<td style={tdStyle()}>
<div style={truncCell}>
<span style={{ fontSize: '11px', color: '#888' }}>
{node.description || '\u2014'}
</span>
</div>
</td>
{/* Notes */}
<td style={tdStyle()}>
<div style={truncCell}>
<span style={{ fontSize: '11px', color: '#888' }}>
{node.notes ? node.notes.slice(0, 120) : '\u2014'}
</span>
</div>
</td>
{/* Link */}
<td style={tdStyle()}>
<div style={truncCell}>
{node.link ? (
<span style={{ fontSize: '11px', color: '#6a9fd8', display: 'flex', alignItems: 'center', gap: '4px' }}>
<ExternalLink size={10} style={{ flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{node.link.replace(/^https?:\/\/(www\.)?/, '')}
</span>
</span>
) : (
<span style={{ fontSize: '11px', color: '#333' }}>{'\u2014'}</span>
)}
</div>
</td>
{/* Dimensions */}
<td style={tdStyle()}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '3px', overflow: 'hidden', maxHeight: '36px' }}>
{node.dimensions && node.dimensions.length > 0 ? (
<>
{node.dimensions.slice(0, 3).map(d => (
<span key={d} style={{
fontSize: '9px', padding: '1px 5px',
background: '#111914', border: '1px solid #1f2f24',
color: '#bbf7d0', borderRadius: '3px',
whiteSpace: 'nowrap',
}}>
{d}
</span>
))}
{node.dimensions.length > 3 && (
<span style={{ fontSize: '9px', color: '#555' }}>
+{node.dimensions.length - 3}
</span>
)}
</>
) : (
<span style={{ fontSize: '10px', color: '#333' }}>{'\u2014'}</span>
)}
</div>
</td>
{/* Edges */}
<td style={tdStyle({ textAlign: 'right' })}>
<span style={{ fontSize: '12px', color: node.edge_count ? '#888' : '#333' }}>
{node.edge_count ?? 0}
</span>
</td>
{/* Event Date */}
<td style={tdStyle()}>
<span style={{ fontSize: '11px', color: node.event_date ? '#888' : '#333' }}>
{formatDate(node.event_date)}
</span>
</td>
{/* Updated */}
<td style={tdStyle()}>
<span style={{ fontSize: '11px', color: '#666' }}>
{formatRelativeTime(node.updated_at)}
</span>
</td>
{/* Created */}
<td style={tdStyle()}>
<span style={{ fontSize: '11px', color: '#666' }}>
{formatRelativeTime(node.created_at)}
</span>
</td>
{/* Metadata */}
<td style={tdStyle()}>
<div style={truncCell}>
<span style={{ fontSize: '10px', color: '#555', fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace' }}>
{metaStr || '\u2014'}
</span>
</div>
</td>
{/* Chunk */}
<td style={tdStyle()}>
<div style={truncCell}>
<span style={{ fontSize: '10px', color: '#555' }}>
{node.chunk ? node.chunk.slice(0, 100) : '\u2014'}
</span>
</div>
</td>
{/* Chunk Status */}
<td style={tdStyle()}>
<span style={{
fontSize: '10px',
color: node.chunk_status === 'chunked' ? '#4a9' : node.chunk_status === 'error' ? '#e55' : '#555',
}}>
{node.chunk_status || '\u2014'}
</span>
</td>
{/* Embedding Updated */}
<td style={tdStyle()}>
<span style={{ fontSize: '11px', color: '#666' }}>
{node.embedding_updated_at ? formatRelativeTime(node.embedding_updated_at) : '\u2014'}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
);
}
function thStyle(extra: React.CSSProperties = {}): React.CSSProperties {
return {
position: 'sticky' as const,
top: 0,
background: '#0a0a0a',
padding: '8px 12px',
fontSize: '10px',
fontWeight: 500,
color: '#555',
textAlign: 'left',
letterSpacing: '0.05em',
whiteSpace: 'nowrap',
borderBottom: '1px solid rgba(255,255,255,0.06)',
zIndex: 1,
...extra,
};
}
function tdStyle(extra: React.CSSProperties = {}): React.CSSProperties {
return {
padding: '0 12px',
verticalAlign: 'middle',
borderBottom: '1px solid #111',
overflow: 'hidden',
...extra,
};
}
const truncCell: React.CSSProperties = {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
+231 -11
View File
@@ -1,18 +1,20 @@
"use client"; "use client";
import { useEffect, useMemo, useState, useRef, useCallback } from 'react'; import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import { Filter, ChevronDown, X, ArrowUpDown } from 'lucide-react'; import { Filter, ChevronDown, X, ArrowUpDown, GripVertical } from 'lucide-react';
import type { Node } from '@/types/database'; import type { Node } from '@/types/database';
import { getNodeIcon } from '@/utils/nodeIcons'; import { getNodeIcon } from '@/utils/nodeIcons';
import { useDimensionIcons } from '@/context/DimensionIconsContext'; import { useDimensionIcons } from '@/context/DimensionIconsContext';
import { usePersistentState } from '@/hooks/usePersistentState'; import { usePersistentState } from '@/hooks/usePersistentState';
import type { PendingNode } from '../layout/ThreePanelLayout';
type SortOrder = 'updated' | 'edges' | 'created'; type SortOrder = 'updated' | 'edges' | 'created' | 'custom';
const SORT_LABELS: Record<SortOrder, string> = { const SORT_LABELS: Record<SortOrder, string> = {
updated: 'Updated', updated: 'Updated',
edges: 'Edges', edges: 'Edges',
created: 'Created', created: 'Created',
custom: 'Custom',
}; };
interface ColumnFilter { interface ColumnFilter {
@@ -27,13 +29,112 @@ interface DimensionSummary {
description?: string | null; description?: string | null;
} }
const INPUT_TYPE_LABELS: Record<string, string> = {
youtube: 'Extracting YouTube video...',
website: 'Extracting webpage...',
pdf: 'Extracting PDF...',
note: 'Creating note...',
chat: 'Importing transcript...',
};
function PendingNodeCard({ pending, onDismiss }: { pending: PendingNode; onDismiss?: () => void }) {
const isError = pending.status === 'error';
return (
<div style={{
padding: '10px 12px',
background: 'transparent',
borderBottom: '1px solid #141414',
borderLeft: `3px solid ${isError ? '#ef4444' : '#22c55e'}`,
opacity: 0.8,
}}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
<div style={{
width: '32px',
height: '32px',
borderRadius: '8px',
background: '#141414',
border: `1px solid ${isError ? 'rgba(239,68,68,0.3)' : '#1f1f1f'}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}>
{isError ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<span className="pending-spinner" />
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: '13px',
fontWeight: 500,
color: isError ? '#ef4444' : '#888',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
{isError ? 'Failed' : (INPUT_TYPE_LABELS[pending.inputType] || 'Processing...')}
</div>
<div style={{
fontSize: '11px',
color: '#555',
marginTop: '2px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
{isError && pending.error ? pending.error : pending.input}
</div>
</div>
{isError && onDismiss && (
<button
onClick={(e) => { e.stopPropagation(); onDismiss(); }}
style={{
padding: '4px',
background: 'transparent',
border: 'none',
color: '#555',
cursor: 'pointer',
borderRadius: '4px',
display: 'flex',
flexShrink: 0,
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#555'; }}
>
<X size={14} />
</button>
)}
</div>
<style jsx>{`
.pending-spinner {
width: 14px;
height: 14px;
border: 2px solid #22c55e;
border-top-color: transparent;
border-radius: 50%;
animation: pendingSpin 0.8s linear infinite;
}
@keyframes pendingSpin {
to { transform: rotate(360deg); }
}
`}</style>
</div>
);
}
interface ViewsOverlayProps { interface ViewsOverlayProps {
onNodeClick: (nodeId: number) => void; onNodeClick: (nodeId: number) => void;
onNodeOpenInOtherPane?: (nodeId: number) => void; onNodeOpenInOtherPane?: (nodeId: number) => void;
refreshToken?: number; refreshToken?: number;
pendingNodes?: PendingNode[];
onDismissPending?: (id: string) => void;
} }
export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refreshToken = 0 }: ViewsOverlayProps) { export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refreshToken = 0, pendingNodes, onDismissPending }: ViewsOverlayProps) {
const { dimensionIcons } = useDimensionIcons(); const { dimensionIcons } = useDimensionIcons();
// Dimensions for filter picker // Dimensions for filter picker
@@ -43,6 +144,13 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
// Sort order (persisted) // Sort order (persisted)
const [sortOrder, setSortOrder] = usePersistentState<SortOrder>('ui.feedSortOrder', 'updated'); const [sortOrder, setSortOrder] = usePersistentState<SortOrder>('ui.feedSortOrder', 'updated');
// Custom order (persisted) — stores node IDs in user-defined order
const [customOrder, setCustomOrder] = usePersistentState<number[]>('ui.feedCustomOrder', []);
// Drag-to-reorder state
const [reorderDragIndex, setReorderDragIndex] = useState<number | null>(null);
const [reorderDropIndex, setReorderDropIndex] = useState<number | null>(null);
// Filter system state // Filter system state
const [columns, setColumns] = useState<ColumnFilter[]>([]); const [columns, setColumns] = useState<ColumnFilter[]>([]);
const [filteredNodes, setFilteredNodes] = useState<Node[]>([]); const [filteredNodes, setFilteredNodes] = useState<Node[]>([]);
@@ -86,18 +194,37 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
const fetchAllNodes = useCallback(async () => { const fetchAllNodes = useCallback(async () => {
setFilteredNodesLoading(true); setFilteredNodesLoading(true);
try { try {
const response = await fetch(`/api/nodes?limit=500&sortBy=${sortOrder}`); // Custom sort fetches with 'updated' then reorders client-side
const apiSort = sortOrder === 'custom' ? 'updated' : sortOrder;
const response = await fetch(`/api/nodes?limit=500&sortBy=${apiSort}`);
const data = await response.json(); const data = await response.json();
if (!response.ok || !data.success) { if (!response.ok || !data.success) {
throw new Error(data.error || 'Failed to fetch nodes'); throw new Error(data.error || 'Failed to fetch nodes');
} }
setFilteredNodes(data.data || []); const nodes: Node[] = data.data || [];
if (sortOrder === 'custom' && customOrder.length > 0) {
// Reorder nodes based on saved custom order
const orderMap = new Map(customOrder.map((id, idx) => [id, idx]));
const ordered: Node[] = [];
const unordered: Node[] = [];
for (const node of nodes) {
if (orderMap.has(node.id)) {
ordered.push(node);
} else {
unordered.push(node); // New nodes not in custom order — append at bottom
}
}
ordered.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
setFilteredNodes([...ordered, ...unordered]);
} else {
setFilteredNodes(nodes);
}
} catch (error) { } catch (error) {
console.error('Error fetching nodes:', error); console.error('Error fetching nodes:', error);
} finally { } finally {
setFilteredNodesLoading(false); setFilteredNodesLoading(false);
} }
}, [sortOrder]); }, [sortOrder, customOrder]);
const fetchFilteredNodes = useCallback(async (filters: string[]) => { const fetchFilteredNodes = useCallback(async (filters: string[]) => {
if (filters.length === 0) { if (filters.length === 0) {
@@ -106,18 +233,35 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
} }
setFilteredNodesLoading(true); setFilteredNodesLoading(true);
try { try {
const response = await fetch(`/api/nodes?limit=500&sortBy=${sortOrder}&dimensions=${encodeURIComponent(filters.join(','))}&dimensionsMatch=all`); const apiSort = sortOrder === 'custom' ? 'updated' : sortOrder;
const response = await fetch(`/api/nodes?limit=500&sortBy=${apiSort}&dimensions=${encodeURIComponent(filters.join(','))}&dimensionsMatch=all`);
const data = await response.json(); const data = await response.json();
if (!response.ok || !data.success) { if (!response.ok || !data.success) {
throw new Error(data.error || 'Failed to fetch nodes'); throw new Error(data.error || 'Failed to fetch nodes');
} }
setFilteredNodes(data.data || []); const nodes: Node[] = data.data || [];
if (sortOrder === 'custom' && customOrder.length > 0) {
const orderMap = new Map(customOrder.map((id, idx) => [id, idx]));
const ordered: Node[] = [];
const unordered: Node[] = [];
for (const node of nodes) {
if (orderMap.has(node.id)) {
ordered.push(node);
} else {
unordered.push(node);
}
}
ordered.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
setFilteredNodes([...ordered, ...unordered]);
} else {
setFilteredNodes(nodes);
}
} catch (error) { } catch (error) {
console.error('Error fetching filtered nodes:', error); console.error('Error fetching filtered nodes:', error);
} finally { } finally {
setFilteredNodesLoading(false); setFilteredNodesLoading(false);
} }
}, [fetchAllNodes, sortOrder]); }, [fetchAllNodes, sortOrder, customOrder]);
// Stringify filters for stable dependency // Stringify filters for stable dependency
const filtersKey = selectedFilters.join(','); const filtersKey = selectedFilters.join(',');
@@ -192,9 +336,29 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showFilterPicker, showSortDropdown]); }, [showFilterPicker, showSortDropdown]);
// Reorder handlers
const handleReorderDrop = useCallback((dropIdx: number) => {
if (reorderDragIndex === null || reorderDragIndex === dropIdx) {
setReorderDragIndex(null);
setReorderDropIndex(null);
return;
}
// Reorder filteredNodes and persist
const newNodes = [...filteredNodes];
const [moved] = newNodes.splice(reorderDragIndex, 1);
newNodes.splice(dropIdx > reorderDragIndex ? dropIdx - 1 : dropIdx, 0, moved);
setFilteredNodes(newNodes);
setCustomOrder(newNodes.map(n => n.id));
setReorderDragIndex(null);
setReorderDropIndex(null);
}, [reorderDragIndex, filteredNodes, setCustomOrder]);
// Render node card // Render node card
const renderNodeCard = (node: Node) => { const renderNodeCard = (node: Node, index: number) => {
const nodeIcon = getNodeIcon(node, dimensionIcons, 18); const nodeIcon = getNodeIcon(node, dimensionIcons, 18);
const isCustomSort = sortOrder === 'custom';
const isDragSource = reorderDragIndex === index;
const isDropTarget = reorderDropIndex === index;
// Description preview — first meaningful line, truncated // Description preview — first meaningful line, truncated
const descPreview = node.description && node.description.length > 10 const descPreview = node.description && node.description.length > 10
@@ -212,6 +376,21 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
e.dataTransfer.setData('application/node-info', JSON.stringify({ id: node.id, title, dimensions: node.dimensions || [] })); e.dataTransfer.setData('application/node-info', JSON.stringify({ id: node.id, title, dimensions: node.dimensions || [] }));
e.dataTransfer.setData('text/plain', `[NODE:${node.id}:"${title}"]`); e.dataTransfer.setData('text/plain', `[NODE:${node.id}:"${title}"]`);
}} }}
onDragOver={(e) => {
if (!isCustomSort || reorderDragIndex === null) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setReorderDropIndex(index);
}}
onDragLeave={() => {
if (!isCustomSort) return;
setReorderDropIndex(null);
}}
onDrop={(e) => {
if (!isCustomSort || reorderDragIndex === null) return;
e.preventDefault();
handleReorderDrop(index);
}}
style={{ style={{
padding: '10px 12px', padding: '10px 12px',
background: 'transparent', background: 'transparent',
@@ -219,6 +398,8 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
transition: 'all 0.15s ease', transition: 'all 0.15s ease',
borderBottom: '1px solid #141414', borderBottom: '1px solid #141414',
borderLeft: '3px solid transparent', borderLeft: '3px solid transparent',
opacity: isDragSource ? 0.4 : 1,
borderTop: isDropTarget ? '2px solid #22c55e' : '2px solid transparent',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.02)'; e.currentTarget.style.background = 'rgba(255,255,255,0.02)';
@@ -234,6 +415,38 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
}} }}
> >
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}> <div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
{/* Grip handle — only in custom sort mode */}
{isCustomSort && (
<div
draggable
onDragStart={(e) => {
e.stopPropagation();
setReorderDragIndex(index);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('application/x-rah-reorder', String(index));
}}
onDragEnd={() => {
setReorderDragIndex(null);
setReorderDropIndex(null);
}}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '16px',
cursor: 'grab',
color: '#444',
flexShrink: 0,
alignSelf: 'center',
transition: 'color 0.15s ease',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#888'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#444'; }}
onClick={(e) => e.stopPropagation()}
>
<GripVertical size={14} />
</div>
)}
<div style={{ <div style={{
width: '32px', width: '32px',
height: '32px', height: '32px',
@@ -622,7 +835,14 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
flexDirection: 'column', flexDirection: 'column',
gap: '8px' gap: '8px'
}}> }}>
{filteredNodes.map(node => renderNodeCard(node))} {pendingNodes && pendingNodes.length > 0 && pendingNodes.map(p => (
<PendingNodeCard
key={p.id}
pending={p}
onDismiss={onDismissPending ? () => onDismissPending(p.id) : undefined}
/>
))}
{filteredNodes.map((node, index) => renderNodeCard(node, index))}
</div> </div>
)} )}
</div> </div>
+14 -4
View File
@@ -28,11 +28,21 @@ When creating a node derived from existing content:
## Dimension Assignment ## Dimension Assignment
- New nodes should be assigned to relevant existing dimensions - New nodes should be assigned to relevant existing dimensions
- Check locked/priority dimensions — these auto-assign - Check priority dimensions — assign these when relevant
- If no existing dimension fits, create a new one (but prefer existing) - If no existing dimension fits, create a new one (but prefer existing)
## Description Field ## Description Field
- AI auto-generates a ~1 sentence description after creation The description is the most important field for AI context. It powers search (5x boost),
- Description is used for embeddings and search ranking (5x boost) appears in system prompts, and determines how agents understand the node.
- Format: what is this node about, in plain language
**Standard:** State WHAT this is + WHY it matters. Extremely concise, high-level. Max 280 characters.
**Rules:**
- NO weak verbs: "discusses", "explores", "examines", "talks about"
- State the actual claim, insight, or purpose directly
- Include significance or implication in 1 phrase
- If auto-generated (omitted), the system will generate one — but agent-written descriptions are always better
**Good:** "By Karpathy — Software is becoming fluid: agents can rip functionality from repos instead of taking dependencies."
**Bad:** "This article discusses the importance of software becoming more fluid."
+41 -34
View File
@@ -51,16 +51,6 @@ function buildTaskPrompt(type: QuickAddInputType, input: string): string {
} }
} }
function buildExpectedOutcome(type: QuickAddInputType): string {
if (type === 'note') {
return 'Call createNode with the user input as content. Use a descriptive title extracted from the first few words. Set dimensions to an empty array. Return a brief confirmation like "Created note [NODE:id:title]"';
}
if (type === 'chat') {
return 'Store the entire transcript in chunk_content, summarize the conversation into content, and return a confirmation like "Created chat transcript node [NODE:id:title]"';
}
return 'Use the appropriate extraction tool (youtubeExtract, websiteExtract, or paperExtract) to create the node. Return the standard extraction summary.';
}
type ExtractionQuickAddType = Extract<QuickAddInputType, 'youtube' | 'website' | 'pdf'>; type ExtractionQuickAddType = Extract<QuickAddInputType, 'youtube' | 'website' | 'pdf'>;
const EXTRACTION_TOOL_MAP = { const EXTRACTION_TOOL_MAP = {
@@ -207,7 +197,7 @@ async function handleNoteQuickAdd(rawInput: string, task: string, userDescriptio
const title = deriveNoteTitle(content); const title = deriveNoteTitle(content);
const nodePayload: Record<string, unknown> = { const nodePayload: Record<string, unknown> = {
title, title,
content, notes: content,
metadata: { metadata: {
source: 'quick-add-note', source: 'quick-add-note',
refined_at: new Date().toISOString(), refined_at: new Date().toISOString(),
@@ -312,7 +302,7 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Pro
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
title, title,
content, notes: content,
chunk: transcript, chunk: transcript,
metadata, metadata,
}), }),
@@ -341,41 +331,58 @@ async function handleChatTranscriptQuickAdd(rawInput: string, task: string): Pro
} }
export interface QuickAddResult { export interface QuickAddResult {
success: boolean; id: string;
task: string;
inputType: QuickAddInputType;
status: 'queued' | 'completed' | 'failed';
summary?: string; summary?: string;
error?: string; error?: string;
nodeId?: number;
} }
export async function enqueueQuickAdd({ rawInput, mode, description }: QuickAddInput): Promise<QuickAddResult> { export async function enqueueQuickAdd({ rawInput, mode, description }: QuickAddInput): Promise<QuickAddResult> {
const inputType = detectInputType(rawInput, mode); const inputType = detectInputType(rawInput, mode);
const task = buildTaskPrompt(inputType, rawInput); const task = buildTaskPrompt(inputType, rawInput);
const id = `qa_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
try { const result: QuickAddResult = {
let summary: string; id,
if (inputType === 'note') { task,
summary = await handleNoteQuickAdd(rawInput, task, description); inputType,
} else if (inputType === 'chat') { status: 'queued',
summary = await handleChatTranscriptQuickAdd(rawInput, task); };
} else {
summary = await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task);
}
// Extract node ID from summary if present // Run async - fire and forget
const nodeIdMatch = summary.match(/\[NODE:(\d+)/); setImmediate(async () => {
const nodeId = nodeIdMatch ? parseInt(nodeIdMatch[1], 10) : undefined; try {
let summary: string;
if (inputType === 'note') {
summary = await handleNoteQuickAdd(rawInput, task, description);
} else if (inputType === 'chat') {
summary = await handleChatTranscriptQuickAdd(rawInput, task);
} else {
summary = await handleExtractionQuickAdd(inputType as ExtractionQuickAddType, rawInput, task);
}
// Broadcast node created event console.log(`[QuickAdd] Completed: ${task}`);
if (nodeId) { // Broadcast completion so ThreePanelLayout can remove the pending placeholder
eventBroadcaster.broadcast({
type: 'QUICK_ADD_COMPLETED',
data: { quickAddId: id, source: 'quick-add' }
});
// Also broadcast NODE_CREATED to refresh the feed
eventBroadcaster.broadcast({ eventBroadcaster.broadcast({
type: 'NODE_CREATED', type: 'NODE_CREATED',
data: { node: { id: nodeId, title: 'Quick Add' } } data: { node: { title: task }, source: 'quick-add' }
});
} catch (error) {
const message = error instanceof Error ? error.message : 'unknown error';
console.error(`[QuickAdd] Failed: ${task} - ${message}`);
eventBroadcaster.broadcast({
type: 'QUICK_ADD_FAILED',
data: { quickAddId: id, error: message, source: 'quick-add' }
}); });
} }
});
return { success: true, summary, nodeId }; return result;
} catch (error) {
const message = error instanceof Error ? error.message : 'unknown error';
return { success: false, error: `Quick Add failed: ${message}` };
}
} }
+16 -12
View File
@@ -40,7 +40,7 @@ export function generateFallbackDescription(input: DescriptionInput): string {
return `${parts.join(' — ')}: ${title.slice(0, 200)}`; return `${parts.join(' — ')}: ${title.slice(0, 200)}`;
} }
return `Knowledge item: ${title.slice(0, 250)}`; return title.slice(0, 280);
} }
/** /**
@@ -133,20 +133,24 @@ function buildDescriptionPrompt(input: DescriptionInput): string {
const contentPreview = input.notes?.slice(0, 800) || ''; const contentPreview = input.notes?.slice(0, 800) || '';
if (contentPreview) lines.push(`Notes: ${contentPreview}${input.notes && input.notes.length > 800 ? '...' : ''}`); if (contentPreview) lines.push(`Notes: ${contentPreview}${input.notes && input.notes.length > 800 ? '...' : ''}`);
return `Your job is to answer: "what is this?" in one short line. Max 280 characters. return `Write a description for this knowledge node. Max 280 characters.
GOAL: Include "who created it" when possible. Say WHAT this literally is and WHY it matters. Be concrete and specific like you're telling a friend what this thing is in one breath.
RULES (in priority order): RULES:
1) ONLY use a creator if you have a creator hint (Author/Channel) or the content explicitly says "by <X>" / "hosted by <X>". 1) Name the format: "Podcast episode where…", "Blog post arguing…", "Your note on…", "Research paper showing…", "Idea that…"
- Do NOT treat prominent people in the title/transcript (e.g. a guest) as the creator. 2) Name people by role channel/host is the creator, title figures are guests/subjects. Use the Creator hint if available.
- If a creator hint is provided, prefer it. 3) State the actual claim, finding, or insight from the content not a vague summary of the topic.
2) If you can identify a creator/author/channel/person/org from a creator hint, start with: "By <creator> — ..." 4) End with why it's interesting or important one concrete phrase.
3) If it's likely user-authored, start with: "Your <thing> — ..." (don't invent a creator name). 5) ABSOLUTELY FORBIDDEN these words will be rejected: "discusses", "explores", "examines", "talks about", "is about", "delves into", "emphasizing the need for". State things directly instead.
4) If creator is unknown, do NOT guess; omit the byline.
Then, in the remainder, state what it is (video/paper/article/note/idea/etc) + what it's about (high-signal). GOOD: "Karpathy blog post — AI agents make software fluid, ripping functionality from repos instead of taking dependencies. Signals the end of monolithic libraries."
If unsure, say so briefly. GOOD: "Dwarkesh Patel interview with Anthropic CEO Dario Amodei — argues we're nearing the end of exponential AI scaling. Key signal for what comes next."
GOOD: "Your note — morning optimism consistently reverses to evening pessimism. Not energy — the belief itself flips. Pattern worth tracking."
BAD: "By Dario Amodei — discusses reaching the limits of exponential growth in AI, emphasizing the need for a critical perspective on future advancements."
BAD: "This article explores ideas about how software is changing."
Return ONLY the description text. Nothing else.
${lines.join('\n')}`; ${lines.join('\n')}`;
} }
+42
View File
@@ -7,6 +7,46 @@ export class NodeService {
return this.getNodesSQLite(filters); return this.getNodesSQLite(filters);
} }
async countNodes(filters: NodeFilters = {}): Promise<number> {
const { dimensions, search, dimensionsMatch = 'any',
createdAfter, createdBefore, eventAfter, eventBefore } = filters;
const sqlite = getSQLiteClient();
let query = `SELECT COUNT(*) as total FROM nodes n WHERE 1=1`;
const params: any[] = [];
if (dimensions && dimensions.length > 0) {
if (dimensionsMatch === 'all' && dimensions.length > 1) {
query += ` AND (
SELECT COUNT(DISTINCT nd.dimension) FROM node_dimensions nd
WHERE nd.node_id = n.id
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
) = ?`;
params.push(...dimensions, dimensions.length);
} else {
query += ` AND EXISTS (
SELECT 1 FROM node_dimensions nd
WHERE nd.node_id = n.id
AND nd.dimension IN (${dimensions.map(() => '?').join(',')})
)`;
params.push(...dimensions);
}
}
if (search) {
query += ` AND (n.title LIKE ? COLLATE NOCASE OR n.description LIKE ? COLLATE NOCASE OR n.notes LIKE ? COLLATE NOCASE)`;
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
}
if (createdAfter) { query += ` AND n.created_at >= ?`; params.push(createdAfter); }
if (createdBefore) { query += ` AND n.created_at < ?`; params.push(createdBefore); }
if (eventAfter) { query += ` AND n.event_date >= ?`; params.push(eventAfter); }
if (eventBefore) { query += ` AND n.event_date < ?`; params.push(eventBefore); }
const result = sqlite.query<{ total: number }>(query, params);
return result.rows[0]?.total ?? 0;
}
// PostgreSQL path removed in SQLite-only consolidation // PostgreSQL path removed in SQLite-only consolidation
private async getNodesSQLite(filters: NodeFilters = {}): Promise<Node[]> { private async getNodesSQLite(filters: NodeFilters = {}): Promise<Node[]> {
@@ -94,6 +134,8 @@ export class NodeService {
query += ' ORDER BY edge_count DESC, n.updated_at DESC'; query += ' ORDER BY edge_count DESC, n.updated_at DESC';
} else if (sortBy === 'created') { } else if (sortBy === 'created') {
query += ' ORDER BY n.created_at DESC'; query += ' ORDER BY n.created_at DESC';
} else if (sortBy === 'event_date') {
query += ' ORDER BY n.event_date IS NULL, n.event_date DESC, n.updated_at DESC';
} else { } else {
query += ' ORDER BY n.updated_at DESC'; query += ' ORDER BY n.updated_at DESC';
} }
+2
View File
@@ -16,6 +16,8 @@ export interface DatabaseEvent {
| 'AGENT_DELEGATION_CREATED' | 'AGENT_DELEGATION_CREATED'
| 'AGENT_DELEGATION_UPDATED' | 'AGENT_DELEGATION_UPDATED'
| 'GUIDE_UPDATED' | 'GUIDE_UPDATED'
| 'QUICK_ADD_COMPLETED'
| 'QUICK_ADD_FAILED'
| 'CONNECTION_ESTABLISHED'; | 'CONNECTION_ESTABLISHED';
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any; data: any;
+3 -1
View File
@@ -6,8 +6,10 @@ export const createNodeTool = tool({
description: 'Create node with title/content/link and optional dimensions (locked dimensions auto-assigned)', description: 'Create node with title/content/link and optional dimensions (locked dimensions auto-assigned)',
inputSchema: z.object({ inputSchema: z.object({
title: z.string().describe('The title of the node'), title: z.string().describe('The title of the node'),
notes: z.string().optional().describe('The main notes, description, or notes for this node'), description: z.string().max(280).optional().describe('WHAT this is + WHY it matters. Extremely concise. No "discusses/explores". Auto-generated if omitted.'),
notes: z.string().optional().describe('The main notes or content for this node'),
link: z.string().optional().describe('A URL link to the source'), link: z.string().optional().describe('A URL link to the source'),
event_date: z.string().optional().describe('ISO date string for time-anchored nodes (e.g. meetings, events)'),
dimensions: z dimensions: z
.array(z.string()) .array(z.string())
.max(5) .max(5)
+4 -2
View File
@@ -7,8 +7,10 @@ export const updateNodeTool = tool({
id: z.number().describe('The ID of the node to update'), id: z.number().describe('The ID of the node to update'),
updates: z.object({ updates: z.object({
title: z.string().optional().describe('New title'), title: z.string().optional().describe('New title'),
notes: z.string().optional().describe('New content/description/notes'), description: z.string().max(280).optional().describe('New description (overwrites existing). WHAT this is + WHY it matters. No "discusses/explores".'),
notes: z.string().optional().describe('New notes (appended to existing)'),
link: z.string().optional().describe('New link'), link: z.string().optional().describe('New link'),
event_date: z.string().optional().describe('ISO date string for time-anchored nodes'),
dimensions: z.array(z.string()).optional().describe('New dimension tags - completely replaces existing dimensions'), dimensions: z.array(z.string()).optional().describe('New dimension tags - completely replaces existing dimensions'),
chunk: z.string().optional().describe('New chunk content'), chunk: z.string().optional().describe('New chunk content'),
metadata: z.record(z.any()).optional().describe('New metadata - completely replaces existing metadata') metadata: z.record(z.any()).optional().describe('New metadata - completely replaces existing metadata')
@@ -79,7 +81,7 @@ export const updateNodeTool = tool({
// Call the nodes API endpoint // Call the nodes API endpoint
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes/${id}`, { const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/nodes/${id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Notes-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates) body: JSON.stringify(updates)
}); });
+26 -16
View File
@@ -8,30 +8,37 @@ import { formatNodeForChat } from '../infrastructure/nodeFormatter';
// AI-powered content analysis // AI-powered content analysis
async function analyzeContentWithAI(title: string, description: string, contentType: string) { async function analyzeContentWithAI(title: string, description: string, contentType: string) {
try { try {
const prompt = `Analyze this ${contentType} content and provide classification: const prompt = `Analyze this ${contentType} content and provide classification.
Title: "${title}" Title: "${title}"
Description: "${description}" Description: "${description}"
CRITICAL nodeDescription rules (max 280 chars):
1. Say WHAT this literally is: "Paper by…", "Research from…", "Preprint introducing…"
2. Name the authors if known from the metadata.
3. State the actual finding, method, or contribution not "a study on X" but what they actually found or built.
4. End with why it matters one concrete phrase about impact or implication.
5. ABSOLUTELY FORBIDDEN: "discusses", "explores", "examines", "talks about", "delves into", "emphasizing the need for". State things directly.
Examples:
- Title: "Attention Is All You Need" / Authors: Vaswani et al.
GOOD: "Vaswani et al. introduce the Transformer architecture — replaces recurrence with self-attention for sequence modeling. Foundation of every modern LLM."
BAD: "This paper discusses a new architecture called the Transformer and explores its applications."
- Title: "Scaling Laws for Neural Language Models" / Authors: Kaplan et al.
GOOD: "Kaplan et al. show that LLM performance scales as a power law with compute, data, and parameters — and compute matters most. The paper that launched the scaling era."
BAD: "A study examining how neural language models scale with different factors."
Respond with ONLY valid JSON (no markdown, no code blocks): Respond with ONLY valid JSON (no markdown, no code blocks):
{ {
"enhancedDescription": "A comprehensive summary of what this content is about (can be several paragraphs, up to ~1500 characters)", "enhancedDescription": "A comprehensive summary (3-6 paragraphs, 800-1500 chars). Cover key points, arguments, takeaways.",
"tags": ["relevant", "semantic", "tags", "like", "ai", "economics", "research"], "nodeDescription": "<your 280-char description following the rules above>",
"reasoning": "Brief explanation of why you chose these categories" "tags": ["relevant", "semantic", "tags"],
} "reasoning": "Brief explanation of classification choices"
}`;
Guidelines:
- enhancedDescription should be thorough - cover key points, arguments, and takeaways
- Aim for 3-6 paragraphs or 800-1500 characters - don't artificially truncate
- Include 3-8 relevant semantic tags (not just generic ones)
- For academic papers, include tags like: research, academic, paper, plus domain-specific tags
- For AI/ML papers, include: ai, machine-learning, artificial-intelligence, deep-learning
- For economics papers, include: economics, finance, markets, policy
- Be specific and insightful
- Return ONLY the JSON object, no other text`;
const response = await generateText({ const response = await generateText({
model: openai('gpt-5-mini'), model: openai('gpt-4o-mini'),
prompt, prompt,
maxOutputTokens: 800 maxOutputTokens: 800
}); });
@@ -45,6 +52,7 @@ Guidelines:
return { return {
enhancedDescription: result.enhancedDescription || description, enhancedDescription: result.enhancedDescription || description,
nodeDescription: typeof result.nodeDescription === 'string' ? result.nodeDescription.slice(0, 280) : undefined,
tags: Array.isArray(result.tags) ? result.tags : [], tags: Array.isArray(result.tags) ? result.tags : [],
reasoning: result.reasoning || 'AI analysis completed' reasoning: result.reasoning || 'AI analysis completed'
}; };
@@ -53,6 +61,7 @@ Guidelines:
console.warn('Paper analysis fallback (using default description):', message); console.warn('Paper analysis fallback (using default description):', message);
return { return {
enhancedDescription: description, enhancedDescription: description,
nodeDescription: undefined,
tags: [], tags: [],
reasoning: 'Fallback description used' reasoning: 'Fallback description used'
}; };
@@ -143,6 +152,7 @@ export const paperExtractTool = tool({
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
title: nodeTitle, title: nodeTitle,
description: aiAnalysis?.nodeDescription,
notes: enhancedDescription, notes: enhancedDescription,
link: url, link: url,
dimensions: trimmedDimensions, dimensions: trimmedDimensions,
+26 -15
View File
@@ -8,29 +8,37 @@ import { formatNodeForChat } from '../infrastructure/nodeFormatter';
// AI-powered content analysis // AI-powered content analysis
async function analyzeContentWithAI(title: string, description: string, contentType: string) { async function analyzeContentWithAI(title: string, description: string, contentType: string) {
try { try {
const prompt = `Analyze this ${contentType} content and provide classification: const prompt = `Analyze this ${contentType} content and provide classification.
Title: "${title}" Title: "${title}"
Description: "${description}" Description: "${description}"
CRITICAL nodeDescription rules (max 280 chars):
1. Say WHAT this literally is: "Blog post by…", "Article from…", "Essay arguing…", "Tutorial on…", "Thread by…"
2. Name the author/site if known from the metadata.
3. State the actual claim or thesis don't paraphrase into vague abstractions.
4. End with why it's interesting or important one concrete phrase.
5. ABSOLUTELY FORBIDDEN: "discusses", "explores", "examines", "talks about", "delves into", "emphasizing the need for". State things directly.
Examples:
- Title: "Software is eating the world — again" / Author: Andrej Karpathy
GOOD: "Karpathy's blog post arguing AI agents make software fluid — they can rip functionality from repos instead of taking dependencies. Signals the end of monolithic libraries."
BAD: "By Karpathy — discusses the importance of software becoming more fluid and malleable with agents."
- Title: "The case for slowing down AI" / Site: The Atlantic
GOOD: "Atlantic article making the case that AI labs should voluntarily slow capability research until safety catches up. Notable because it cites internal lab disagreements."
BAD: "This article explores ideas about slowing down AI development and its implications."
Respond with ONLY valid JSON (no markdown, no code blocks): Respond with ONLY valid JSON (no markdown, no code blocks):
{ {
"enhancedDescription": "A comprehensive summary of what this content is about (can be several paragraphs, up to ~1500 characters)", "enhancedDescription": "A comprehensive summary (3-6 paragraphs, 800-1500 chars). Cover key points, arguments, takeaways.",
"tags": ["relevant", "semantic", "tags", "like", "ai", "economics", "research"], "nodeDescription": "<your 280-char description following the rules above>",
"reasoning": "Brief explanation of why you chose these categories" "tags": ["relevant", "semantic", "tags"],
} "reasoning": "Brief explanation of classification choices"
}`;
Guidelines:
- enhancedDescription should be thorough - cover key points, arguments, and takeaways
- Aim for 3-6 paragraphs or 800-1500 characters - don't artificially truncate
- Include 3-8 relevant semantic tags (not just generic ones)
- For AI/ML content, include tags like: ai, machine-learning, artificial-intelligence, deep-learning
- For economics content, include: economics, finance, markets, policy
- Be specific and insightful
- Return ONLY the JSON object, no other text`;
const response = await generateText({ const response = await generateText({
model: openai('gpt-5-mini'), model: openai('gpt-4o-mini'),
prompt, prompt,
maxOutputTokens: 800 maxOutputTokens: 800
}); });
@@ -44,6 +52,7 @@ Guidelines:
return { return {
enhancedDescription: result.enhancedDescription || description, enhancedDescription: result.enhancedDescription || description,
nodeDescription: typeof result.nodeDescription === 'string' ? result.nodeDescription.slice(0, 280) : undefined,
tags: Array.isArray(result.tags) ? result.tags : [], tags: Array.isArray(result.tags) ? result.tags : [],
reasoning: result.reasoning || 'AI analysis completed' reasoning: result.reasoning || 'AI analysis completed'
}; };
@@ -52,6 +61,7 @@ Guidelines:
console.warn('Website analysis fallback (using default description):', message); console.warn('Website analysis fallback (using default description):', message);
return { return {
enhancedDescription: description, enhancedDescription: description,
nodeDescription: undefined,
tags: [], tags: [],
reasoning: 'Fallback description used' reasoning: 'Fallback description used'
}; };
@@ -134,6 +144,7 @@ export const websiteExtractTool = tool({
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
title: nodeTitle, title: nodeTitle,
description: aiAnalysis?.nodeDescription,
notes: enhancedDescription, notes: enhancedDescription,
link: url, link: url,
dimensions: trimmedDimensions, dimensions: trimmedDimensions,
+28 -33
View File
@@ -5,41 +5,40 @@ import { generateText } from 'ai';
import { extractYouTube } from '@/services/typescript/extractors/youtube'; import { extractYouTube } from '@/services/typescript/extractors/youtube';
import { formatNodeForChat } from '../infrastructure/nodeFormatter'; import { formatNodeForChat } from '../infrastructure/nodeFormatter';
// Available segments for categorization - match database constraint
const AVAILABLE_SEGMENTS = [
'inbox', 'parking', 'in progress', 'archive'
];
// AI-powered content analysis // AI-powered content analysis
async function analyzeContentWithAI(title: string, description: string, contentType: string) { async function analyzeContentWithAI(title: string, description: string, contentType: string) {
try { try {
const prompt = `Analyze this ${contentType} content and provide classification: const prompt = `Analyze this ${contentType} content and provide classification.
Title: "${title}" Title: "${title}"
Description: "${description}" Description: "${description}"
Available types: ${AVAILABLE_SEGMENTS.join(', ')} CRITICAL nodeDescription rules (max 280 chars):
1. Say WHAT this literally is: "Podcast episode where…", "Talk by…", "Interview with…", "Video essay on…"
2. Name people by their role: the channel/host is the creator, anyone in the title is likely the guest or subject.
3. State the actual claim or thesis from the title don't paraphrase into vague abstractions.
4. End with why it's interesting or important one concrete phrase.
5. ABSOLUTELY FORBIDDEN: "discusses", "explores", "examines", "talks about", "delves into", "emphasizing the need for". State things directly.
Examples:
- Title: "Dario Amodei — We are near the end of the exponential" / Channel: Dwarkesh Patel
GOOD: "Dwarkesh Patel interview with Anthropic CEO Dario Amodei — argues we're nearing the end of exponential AI scaling. Key signal for what the next phase of AI development looks like."
BAD: "By Dario Amodei — discusses reaching the limits of exponential growth in AI, emphasizing the need for a critical perspective."
- Title: "The spell of language models" / Channel: Andrej Karpathy
GOOD: "Karpathy talk on how LLMs work under the hood — tokenization, attention, and why they feel like magic but aren't. Essential mental model for anyone building with LLMs."
BAD: "By Andrej Karpathy — explores the nature of language models and their capabilities."
Respond with ONLY valid JSON (no markdown, no code blocks): Respond with ONLY valid JSON (no markdown, no code blocks):
{ {
"enhancedDescription": "A comprehensive summary of what this content is about (can be several paragraphs, up to ~1500 characters)", "enhancedDescription": "A comprehensive summary (3-6 paragraphs, 800-1500 chars). Cover key points, arguments, takeaways.",
"tags": ["relevant", "semantic", "tags", "like", "ai", "economics", "research"], "nodeDescription": "<your 280-char description following the rules above>",
"segments": ["research"], "tags": ["relevant", "semantic", "tags"],
"reasoning": "Brief explanation of why you chose these categories" "reasoning": "Brief explanation of classification choices"
} }`;
Guidelines:
- Choose 1 segment that best fits the content type
- enhancedDescription should be thorough - cover key points, arguments, and takeaways
- Aim for 3-6 paragraphs or 800-1500 characters - don't artificially truncate
- Include 3-8 relevant semantic tags (not just generic ones)
- For AI/ML content, include tags like: ai, machine-learning, artificial-intelligence, deep-learning
- For economics content, include: economics, finance, markets, policy
- Be specific and insightful
- Return ONLY the JSON object, no other text`;
const response = await generateText({ const response = await generateText({
model: openai('gpt-5-mini'), model: openai('gpt-4o-mini'),
prompt, prompt,
maxOutputTokens: 800 maxOutputTokens: 800
}); });
@@ -51,15 +50,10 @@ Guidelines:
const result = JSON.parse(content); const result = JSON.parse(content);
// Validate segments are from available list
const validSegments = result.segments?.filter((seg: string) =>
AVAILABLE_SEGMENTS.includes(seg)
) || [];
return { return {
enhancedDescription: result.enhancedDescription || description, enhancedDescription: result.enhancedDescription || description,
nodeDescription: typeof result.nodeDescription === 'string' ? result.nodeDescription.slice(0, 280) : undefined,
tags: Array.isArray(result.tags) ? result.tags : [], tags: Array.isArray(result.tags) ? result.tags : [],
segments: validSegments,
reasoning: result.reasoning || 'AI analysis completed' reasoning: result.reasoning || 'AI analysis completed'
}; };
} catch (error) { } catch (error) {
@@ -67,8 +61,8 @@ Guidelines:
console.warn('YouTube analysis fallback (using default description):', message); console.warn('YouTube analysis fallback (using default description):', message);
return { return {
enhancedDescription: description, enhancedDescription: description,
nodeDescription: undefined,
tags: [], tags: [],
segments: [],
reasoning: 'Fallback description used' reasoning: 'Fallback description used'
}; };
} }
@@ -79,7 +73,7 @@ async function summariseTranscript(title: string, transcript: string): Promise<s
return null; return null;
} }
// Limit transcript length to keep token costs manageable (approx ≤4k tokens for gpt-4o-mini) // Limit transcript length to keep token costs manageable
const MAX_CHARS = 16000; const MAX_CHARS = 16000;
let excerpt = transcript.trim(); let excerpt = transcript.trim();
if (excerpt.length > MAX_CHARS) { if (excerpt.length > MAX_CHARS) {
@@ -179,7 +173,7 @@ export const youtubeExtractTool = tool({
// Step 3: Create node with extracted content and AI analysis // Step 3: Create node with extracted content and AI analysis
const nodeTitle = title || result.metadata?.video_title || `YouTube Video ${url.split('/').pop()?.split('?')[0]}`; const nodeTitle = title || result.metadata?.video_title || `YouTube Video ${url.split('/').pop()?.split('?')[0]}`;
const transcriptSummary = await summariseTranscript(nodeTitle, result.chunk || result.notes || ''); const transcriptSummary = await summariseTranscript(nodeTitle, result.chunk || result.notes || '');
const content = transcriptSummary || aiAnalysis?.enhancedDescription || `YouTube video by ${result.metadata?.channel_name || 'Unknown Channel'}`; const nodeNotes = transcriptSummary || aiAnalysis?.enhancedDescription || `YouTube video by ${result.metadata?.channel_name || 'Unknown Channel'}`;
const suppliedDimensions = Array.isArray(dimensions) ? dimensions : []; const suppliedDimensions = Array.isArray(dimensions) ? dimensions : [];
let trimmedDimensions = suppliedDimensions let trimmedDimensions = suppliedDimensions
@@ -193,7 +187,8 @@ export const youtubeExtractTool = tool({
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
title: nodeTitle, title: nodeTitle,
content, description: aiAnalysis?.nodeDescription,
notes: nodeNotes,
link: url, link: url,
dimensions: trimmedDimensions, dimensions: trimmedDimensions,
chunk: result.chunk || result.notes, chunk: result.chunk || result.notes,
+1 -1
View File
@@ -69,7 +69,7 @@ export interface NodeFilters {
search?: string; // Text search in title/content search?: string; // Text search in title/content
limit?: number; limit?: number;
offset?: number; offset?: number;
sortBy?: 'updated' | 'edges' | 'created'; // Sort by updated_at, edge count, or created_at sortBy?: 'updated' | 'edges' | 'created' | 'event_date'; // Sort by updated_at, edge count, created_at, or event_date
dimensionsMatch?: 'any' | 'all'; // 'any' = OR (default), 'all' = AND dimensionsMatch?: 'any' | 'all'; // 'any' = OR (default), 'all' = AND
createdAfter?: string; // ISO date (YYYY-MM-DD) — nodes created on or after createdAfter?: string; // ISO date (YYYY-MM-DD) — nodes created on or after
createdBefore?: string; // ISO date (YYYY-MM-DD) — nodes created before createdBefore?: string; // ISO date (YYYY-MM-DD) — nodes created before