feat: node view redesign — single-scroll, no tabs

Ports the full node view redesign from ra-h. Replaces the 4-tab layout
(Desc/Notes/Edges/Source) with a single-scroll document view.

- New FocusPanel: frontmatter rows, collapse toggle, NodeSearchModal,
  TipTap SourceEditor, section headers with extending rules
- NodeSearchModal: new full-screen portal modal for creating connections
- SourceEditor: TipTap WYSIWYG editor (new file)
- SourceReader: removed header chrome, added onContentClick prop
- Source formatters: inherit font, 15px/1.7, no max-width centering
- DimensionTags: subtle dashed + button replaces green pill
- autoEmbedQueue: recover nodes stuck in 'chunking' state on startup
- edges: fix extra parameter in getNodeConnectionsSQLite

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
“BeeRad”
2026-03-20 17:03:06 +11:00
co-authored by Claude Sonnet 4.6
parent 0d2c499b70
commit 6677b1b64b
13 changed files with 2568 additions and 2923 deletions
File diff suppressed because it is too large Load Diff
@@ -323,7 +323,7 @@ export default function DimensionTags({
</div>
)}
{/* Add dimension button - Opens modal */}
{/* Add dimension button */}
{!disabled && (
<button
onClick={(e) => {
@@ -333,43 +333,29 @@ export default function DimensionTags({
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
fontSize: '11px',
fontWeight: 600,
color: '#22c55e',
textTransform: 'uppercase',
letterSpacing: '0.1em',
borderBottom: '1px solid #1a1a1a',
background: '#0a0a0a',
border: 'none',
justifyContent: 'center',
width: '20px',
height: '20px',
fontSize: '14px',
lineHeight: 1,
color: 'var(--rah-text-muted)',
background: 'transparent',
border: '1px dashed #333',
borderRadius: '4px',
cursor: 'pointer',
padding: '8px 12px',
borderRadius: '8px',
transition: 'all 0.2s'
transition: 'color 120ms ease, border-color 120ms ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#151515';
e.currentTarget.style.color = 'var(--rah-text-soft)';
e.currentTarget.style.borderColor = '#555';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#0a0a0a';
e.currentTarget.style.color = 'var(--rah-text-muted)';
e.currentTarget.style.borderColor = '#333';
}}
title="Add dimension"
>
<span style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '16px',
height: '16px',
borderRadius: '50%',
background: '#22c55e',
color: '#0a0a0a',
fontSize: '12px',
lineHeight: 1,
fontWeight: 300,
flexShrink: 0
}}>+</span>
Add
+
</button>
)}
</div>
@@ -0,0 +1,402 @@
"use client";
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
interface NodeSearchModalProps {
isOpen: boolean;
onClose: () => void;
onEdgeCreate: (nodeId: number, explanation?: string) => Promise<void>;
excludeNodeId?: number | null;
}
interface NodeSuggestion {
id: number;
title: string;
dimensions?: string[];
}
export default function NodeSearchModal({
isOpen,
onClose,
onEdgeCreate,
excludeNodeId,
}: NodeSearchModalProps) {
const [mounted, setMounted] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [suggestions, setSuggestions] = useState<NodeSuggestion[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [selectedNode, setSelectedNode] = useState<NodeSuggestion | null>(null);
const [explanation, setExplanation] = useState('');
const [submitting, setSubmitting] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
setMounted(true);
}, []);
const resetState = () => {
setSearchQuery('');
setSuggestions([]);
setSelectedIndex(0);
setSelectedNode(null);
setExplanation('');
setSubmitting(false);
};
const closeModal = () => {
resetState();
onClose();
};
useEffect(() => {
if (!isOpen) {
resetState();
return;
}
const timer = setTimeout(() => inputRef.current?.focus(), 50);
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
if (selectedNode) {
setSelectedNode(null);
setExplanation('');
} else {
closeModal();
}
}
};
document.addEventListener('keydown', handleEscape);
return () => {
clearTimeout(timer);
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen, selectedNode]);
useEffect(() => {
if (!isOpen) return;
if (!searchQuery.trim()) {
setSuggestions([]);
setSelectedIndex(0);
return;
}
const timeoutId = setTimeout(async () => {
try {
const response = await fetch(`/api/nodes/search?q=${encodeURIComponent(searchQuery)}&limit=10`);
const result = await response.json();
if (response.ok && result.success) {
const nextSuggestions = (result.data as NodeSuggestion[])
.filter((node) => node.id !== excludeNodeId)
.map((node) => ({
id: node.id,
title: node.title,
dimensions: node.dimensions || [],
}));
setSuggestions(nextSuggestions);
setSelectedIndex(0);
} else {
setSuggestions([]);
}
} catch (error) {
console.error('Error fetching node suggestions:', error);
setSuggestions([]);
}
}, 150);
return () => clearTimeout(timeoutId);
}, [searchQuery, isOpen, excludeNodeId]);
useEffect(() => {
if (selectedNode) {
setTimeout(() => textareaRef.current?.focus(), 50);
}
}, [selectedNode]);
const handleCreate = async (node: NodeSuggestion, nextExplanation?: string) => {
setSubmitting(true);
try {
await onEdgeCreate(node.id, nextExplanation?.trim() || undefined);
closeModal();
} catch (err) {
console.error('Failed to create edge:', err);
} finally {
setSubmitting(false);
}
};
const handleSearchKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown') {
event.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1));
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
return;
}
if (event.key === 'Enter' && suggestions[selectedIndex]) {
event.preventDefault();
setSelectedNode(suggestions[selectedIndex]);
setExplanation('');
}
};
if (!mounted || !isOpen) return null;
const modal = (
<div
onClick={(e) => { if (e.target === e.currentTarget) closeModal(); }}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.7)',
backdropFilter: 'blur(6px)',
display: 'flex',
justifyContent: 'center',
paddingTop: '15vh',
zIndex: 9999,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: '100%',
maxWidth: '560px',
maxHeight: '70vh',
display: 'flex',
flexDirection: 'column',
gap: '8px',
alignSelf: 'flex-start',
}}
>
{/* Search input */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
background: '#141414',
border: '1px solid #2a2a2a',
borderRadius: '12px',
padding: '14px 18px',
boxShadow: '0 24px 48px -12px rgba(0,0,0,0.6)',
}}>
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor" style={{ color: '#555', flexShrink: 0 }}>
<path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" />
</svg>
<input
ref={inputRef}
type="text"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setSelectedNode(null);
setExplanation('');
}}
onKeyDown={(e) => { void handleSearchKeyDown(e); }}
placeholder="Search nodes to connect..."
style={{
flex: 1,
background: 'none',
border: 'none',
outline: 'none',
color: '#f0f0f0',
fontSize: '15px',
fontFamily: 'inherit',
}}
/>
<kbd style={{
display: 'inline-flex',
alignItems: 'center',
padding: '3px 7px',
background: '#222',
border: '1px solid #333',
borderRadius: '5px',
fontSize: '11px',
color: '#666',
}}>esc</kbd>
</div>
{/* Results */}
{!selectedNode && suggestions.length > 0 && (
<div style={{
background: '#141414',
border: '1px solid #2a2a2a',
borderRadius: '12px',
overflow: 'hidden',
boxShadow: '0 24px 48px -12px rgba(0,0,0,0.6)',
}}>
{suggestions.map((suggestion, index) => (
<button
key={suggestion.id}
type="button"
onClick={() => { setSelectedNode(suggestion); setExplanation(''); }}
onMouseEnter={() => setSelectedIndex(index)}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 16px',
background: index === selectedIndex ? '#1e1e1e' : 'transparent',
border: 'none',
borderBottom: index < suggestions.length - 1 ? '1px solid #1f1f1f' : 'none',
cursor: 'pointer',
textAlign: 'left',
fontFamily: 'inherit',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: '#e0e0e0', fontSize: '13px', marginBottom: '2px' }}>
{suggestion.title}
</div>
{suggestion.dimensions && suggestion.dimensions.length > 0 && (
<div style={{
color: '#666',
fontSize: '11px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{suggestion.dimensions.join(' · ')}
</div>
)}
</div>
<span style={{ color: '#444', fontSize: '11px', fontFamily: 'monospace', flexShrink: 0 }}>
#{suggestion.id}
</span>
{index === selectedIndex && (
<span style={{ color: '#444', fontSize: '12px', flexShrink: 0 }}></span>
)}
</button>
))}
</div>
)}
{/* Selected node — explanation step */}
{selectedNode && (
<div style={{
background: '#141414',
border: '1px solid #2a2a2a',
borderRadius: '12px',
padding: '16px',
boxShadow: '0 24px 48px -12px rgba(0,0,0,0.6)',
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '12px',
marginBottom: '14px',
}}>
<div>
<div style={{ color: '#555', fontSize: '10px', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '5px' }}>
connecting to
</div>
<div style={{ color: '#e0e0e0', fontSize: '14px' }}>{selectedNode.title}</div>
</div>
<button
type="button"
onClick={() => setSelectedNode(null)}
style={{
border: '1px solid #333',
background: 'transparent',
color: '#888',
padding: '5px 10px',
borderRadius: '7px',
cursor: 'pointer',
fontSize: '11px',
fontFamily: 'inherit',
flexShrink: 0,
}}
>
Change
</button>
</div>
<div style={{ position: 'relative', marginBottom: '10px' }}>
<textarea
ref={textareaRef}
value={explanation}
onChange={(e) => setExplanation(e.target.value.slice(0, 500))}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
void handleCreate(selectedNode, explanation);
}
}}
placeholder="Describe this connection... (optional, leave blank to auto-infer)"
rows={3}
style={{
width: '100%',
padding: '10px',
background: '#0e0e0e',
border: '1px solid #2a2a2a',
borderRadius: '8px',
color: '#d0d0d0',
fontSize: '13px',
fontFamily: 'inherit',
resize: 'none',
outline: 'none',
boxSizing: 'border-box',
}}
/>
<span style={{
position: 'absolute',
bottom: '8px',
right: '10px',
fontSize: '10px',
color: '#444',
fontFamily: 'monospace',
}}>
{explanation.length}/500
</span>
</div>
<button
type="button"
onClick={() => { void handleCreate(selectedNode, explanation); }}
disabled={submitting}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '10px 16px',
background: '#1a1a1a',
border: '1px solid #2a2a2a',
borderRadius: '8px',
cursor: submitting ? 'wait' : 'pointer',
fontFamily: 'inherit',
color: '#22c55e',
fontSize: '13px',
opacity: submitting ? 0.6 : 1,
}}
>
{submitting ? 'Creating…' : explanation.trim() ? 'Create connection' : 'Create connection (auto-infer)'}
</button>
</div>
)}
{!searchQuery && suggestions.length === 0 && !selectedNode && (
<div style={{
padding: '28px 20px',
textAlign: 'center',
color: '#555',
fontSize: '13px',
}}>
Start typing to search nodes
</div>
)}
</div>
</div>
);
return createPortal(modal, document.body);
}
@@ -0,0 +1,140 @@
"use client";
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { Markdown } from 'tiptap-markdown';
import { useEffect } from 'react';
interface SourceEditorProps {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}
function getMarkdown(editor: ReturnType<typeof useEditor>): string {
if (!editor) return '';
// tiptap-markdown attaches getMarkdown to storage at runtime
const storage = editor.storage as Record<string, any>;
return storage?.markdown?.getMarkdown?.() ?? editor.getText();
}
export default function SourceEditor({ value, onChange, disabled = false }: SourceEditorProps) {
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit,
Markdown.configure({
html: false,
transformPastedText: true,
transformCopiedText: true,
}),
],
content: value,
editable: !disabled,
onUpdate({ editor }) {
onChange(getMarkdown(editor));
},
});
useEffect(() => {
editor?.setEditable(!disabled);
}, [editor, disabled]);
// Re-sync if content changes externally (e.g. cancelled and re-opened)
useEffect(() => {
if (!editor || editor.isFocused) return;
const current = getMarkdown(editor);
if (current !== value) {
// setContent is overridden by tiptap-markdown to parse markdown
editor.commands.setContent(value, { emitUpdate: false });
}
}, [editor, value]);
return (
<>
<EditorContent editor={editor} style={{ outline: 'none' }} />
<style>{`
.tiptap {
outline: none;
min-height: 200px;
color: var(--rah-text-base);
font-family: inherit;
font-size: 15px;
line-height: 1.7;
caret-color: var(--rah-text-base);
}
.tiptap p {
margin: 0 0 0.85em 0;
}
.tiptap p:last-child {
margin-bottom: 0;
}
.tiptap h1, .tiptap h2, .tiptap h3, .tiptap h4 {
font-weight: 600;
line-height: 1.3;
margin: 1.1em 0 0.3em 0;
color: var(--rah-text-active);
}
.tiptap h1 { font-size: 22px; }
.tiptap h2 { font-size: 18px; }
.tiptap h3 { font-size: 16px; }
.tiptap h4 { font-size: 15px; }
.tiptap strong { font-weight: 600; color: var(--rah-text-active); }
.tiptap em { font-style: italic; }
.tiptap ul, .tiptap ol {
padding-left: 1.3em;
margin: 0 0 0.85em 0;
}
.tiptap li { margin-bottom: 0.2em; }
.tiptap li p { margin: 0; }
.tiptap blockquote {
border-left: 2px solid var(--rah-border-strong);
margin: 0 0 0.85em 0;
padding-left: 1em;
color: var(--rah-text-soft);
font-style: italic;
}
.tiptap code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
font-size: 11px;
background: var(--rah-bg-surface);
border: 1px solid var(--rah-border);
border-radius: 3px;
padding: 1px 4px;
color: var(--rah-text-soft);
}
.tiptap pre {
background: var(--rah-bg-surface);
border: 1px solid var(--rah-border);
border-radius: 6px;
padding: 12px;
margin: 0 0 0.85em 0;
overflow-x: auto;
}
.tiptap pre code {
background: none;
border: none;
padding: 0;
font-size: 12px;
}
.tiptap hr {
border: none;
border-top: 1px solid var(--rah-border);
margin: 1.4em 0;
}
`}</style>
</>
);
}
+15 -77
View File
@@ -1,7 +1,7 @@
"use client";
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
import { detectContentType, getContentTypeLabel } from './ContentDetector';
import { detectContentType } from './ContentDetector';
import RawFormatter from './formatters/RawFormatter';
import TranscriptFormatter from './formatters/TranscriptFormatter';
import BookFormatter from './formatters/BookFormatter';
@@ -12,18 +12,14 @@ interface SourceReaderProps {
content: string;
onTextSelect?: (text: string) => void;
highlightedText?: string | null;
onContentClick?: () => void;
}
/**
* Source Reader - Read-only formatted view of source content
*
* This is a pure presentation component that NEVER modifies the source data.
* It detects content type and applies appropriate formatting for comfortable reading.
*/
export default function SourceReader({
content,
onTextSelect,
highlightedText,
onContentClick,
}: SourceReaderProps) {
const [showSearch, setShowSearch] = useState(false);
const [searchHighlight, setSearchHighlight] = useState<string | null>(null);
@@ -31,14 +27,10 @@ export default function SourceReader({
const [scrollTrigger, setScrollTrigger] = useState(0);
const contentRef = useRef<HTMLDivElement>(null);
// Detect content type (memoized for performance)
const contentType = useMemo(() => detectContentType(content), [content]);
const contentTypeLabel = getContentTypeLabel(contentType);
// Combined highlight: search takes precedence over external highlight
const activeHighlight = searchHighlight || highlightedText;
// Keyboard shortcut for Cmd+F
// Cmd+F to open search
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
@@ -46,23 +38,17 @@ export default function SourceReader({
setShowSearch(true);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
// Scroll to current match when search highlight changes
// Scroll to current search match
useEffect(() => {
if (!searchHighlight || !contentRef.current) return;
// Small delay to let React render the mark element
const timer = setTimeout(() => {
const currentMatch = contentRef.current?.querySelector('mark[data-search-match="current"]');
if (currentMatch) {
currentMatch.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
currentMatch?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 50);
return () => clearTimeout(timer);
}, [searchHighlight, scrollTrigger]);
@@ -75,11 +61,9 @@ export default function SourceReader({
const handleHighlightChange = useCallback((text: string | null, matchIndex: number) => {
setSearchHighlight(text);
setSearchMatchIndex(matchIndex);
// Trigger scroll even if same text (for navigating between matches)
setScrollTrigger(prev => prev + 1);
}, []);
// Render appropriate formatter based on content type
const renderContent = () => {
const highlightIndex = showSearch ? searchMatchIndex : undefined;
switch (contentType) {
@@ -96,57 +80,8 @@ export default function SourceReader({
};
return (
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
background: 'var(--rah-bg-surface)',
borderRadius: '4px',
overflow: 'hidden',
}}>
{/* Header with content type and search */}
{content && content.length >= 50 && (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 16px',
borderBottom: '1px solid var(--rah-border)',
}}>
<span style={{
fontSize: '10px',
color: 'var(--rah-text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}>
Detected: {contentTypeLabel}
</span>
<button
onClick={() => setShowSearch(!showSearch)}
title="Search content (⌘F)"
style={{
background: showSearch ? 'var(--rah-border-strong)' : 'transparent',
border: 'none',
borderRadius: '4px',
padding: '4px 8px',
cursor: 'pointer',
color: showSearch ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '10px',
transition: 'all 150ms ease',
}}
>
<svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" />
</svg>
<span>Search</span>
</button>
</div>
)}
{/* Search bar */}
<div style={{ display: 'flex', flexDirection: 'column' }}>
{/* Search bar — only visible when active (Cmd+F) */}
{showSearch && content && (
<SourceSearchBar
content={content}
@@ -155,13 +90,16 @@ export default function SourceReader({
/>
)}
{/* Formatted content */}
{/* Content — no chrome, no background, just the text */}
<div
ref={contentRef}
style={{
flex: 1,
overflow: 'auto',
onClick={() => {
if (!onContentClick) return;
const selected = window.getSelection?.()?.toString().trim();
if (selected) return;
onContentClick();
}}
style={{ cursor: onContentClick ? 'text' : 'default' }}
>
{renderContent()}
</div>
@@ -120,31 +120,26 @@ export default function BookFormatter({ content, onTextSelect, highlightedText,
.filter(p => p.length > 0);
return (
<div
<div
ref={containerRef}
onMouseUp={handleMouseUp}
style={{
maxWidth: '680px',
margin: '0 auto',
padding: '24px 16px',
padding: '16px 0',
}}
>
{/* Clean text display */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5em' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.2em' }}>
{paragraphs.map((para, idx) => (
<p
key={idx}
style={{
fontFamily: "Georgia, 'Times New Roman', serif",
fontSize: '16px',
lineHeight: '1.75',
color: '#d4d4d4',
fontFamily: 'inherit',
fontSize: '15px',
lineHeight: '1.7',
color: 'var(--rah-text-base)',
margin: 0,
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
textAlign: 'justify',
textJustify: 'inter-word',
hyphens: 'auto',
}}
>
{renderWithHighlight(para)}
@@ -33,17 +33,15 @@ export default function MarkdownFormatter({ content, onTextSelect }: MarkdownFor
}, [onTextSelect]);
return (
<div
<div
ref={containerRef}
onMouseUp={handleMouseUp}
style={{
maxWidth: '680px',
margin: '0 auto',
padding: '24px 16px',
fontFamily: "Georgia, 'Times New Roman', serif",
fontSize: '16px',
lineHeight: '1.75',
color: '#d4d4d4',
padding: '16px 0',
fontFamily: 'inherit',
fontSize: '15px',
lineHeight: '1.7',
color: 'var(--rah-text-base)',
}}
>
<MarkdownWithNodeTokens content={content} />
@@ -118,26 +118,24 @@ export default function RawFormatter({ content, onTextSelect, highlightedText, h
}
return (
<div
<div
ref={containerRef}
onMouseUp={handleMouseUp}
style={{
maxWidth: '680px',
margin: '0 auto',
padding: '24px 16px',
padding: '16px 0',
}}
>
{paragraphs.map((paragraph, index) => (
<p
key={index}
style={{
fontFamily: "Georgia, 'Times New Roman', serif",
fontFamily: 'inherit',
fontSize: '15px',
lineHeight: '1.7',
color: '#d4d4d4',
color: 'var(--rah-text-base)',
margin: 0,
marginBottom: index < paragraphs.length - 1 ? '1.5em' : 0,
whiteSpace: 'pre-wrap', // Preserve single newlines within paragraphs
marginBottom: index < paragraphs.length - 1 ? '1.2em' : 0,
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
}}
>
@@ -170,41 +170,39 @@ export default function TranscriptFormatter({ content, onTextSelect, highlighted
}
return (
<div
<div
ref={containerRef}
onMouseUp={handleMouseUp}
style={{
maxWidth: '680px',
margin: '0 auto',
padding: '24px 16px',
padding: '16px 0',
}}
>
{segments.map((segment, index) => (
<div
key={index}
style={{
marginBottom: '24px',
marginBottom: '16px',
}}
>
{/* Timestamp and speaker on same line */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '8px',
gap: '8px',
marginBottom: '4px',
}}>
<span style={{
fontSize: '11px',
fontSize: '10px',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
color: '#555',
color: 'var(--rah-text-muted)',
}}>
{segment.timestamp}
</span>
{segment.speaker && (
<span style={{
fontSize: '12px',
fontSize: '11px',
fontWeight: 600,
color: '#888',
color: 'var(--rah-text-soft)',
}}>
{segment.speaker}
</span>
@@ -213,10 +211,10 @@ export default function TranscriptFormatter({ content, onTextSelect, highlighted
{/* Text content */}
<div style={{
fontFamily: "Georgia, 'Times New Roman', serif",
fontSize: '16px',
lineHeight: '1.75',
color: '#d4d4d4',
fontFamily: 'inherit',
fontSize: '15px',
lineHeight: '1.7',
color: 'var(--rah-text-base)',
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
}}>
-1
View File
@@ -522,7 +522,6 @@ export class EdgeService {
nodeId,
nodeId,
nodeId,
nodeId,
nodeId
]);
+9 -2
View File
@@ -18,10 +18,17 @@ export class AutoEmbedQueue {
private readonly cooldownMs = DEFAULT_COOLDOWN_MS;
async recoverStuckNodes(): Promise<void> {
const stuckNodes = await nodeService.getNodes({ chunkStatus: 'not_chunked', limit: 1000 });
for (const node of stuckNodes) {
const notChunked = await nodeService.getNodes({ chunkStatus: 'not_chunked', limit: 1000 });
for (const node of notChunked) {
this.enqueue(node.id, { reason: 'startup_recovery' });
}
// Recover nodes stuck in 'chunking' state — these were interrupted mid-process
// and will never complete without intervention since executeTask skips them otherwise
const stuckChunking = await nodeService.getNodes({ chunkStatus: 'chunking', limit: 1000 });
for (const node of stuckChunking) {
this.enqueue(node.id, { force: true, reason: 'stuck_chunking_recovery' });
}
}
enqueue(nodeId: number, task: Omit<AutoEmbedTask, 'nodeId'> = {}): boolean {