feat: sync open-source context and capsule removal
- remove legacy contexts surfaces from the app, MCP bridge, standalone server, and docs - keep getContext and retrieveQueryContext while aligning the simplified graph-only contract - harden dev startup migration behavior and disable the accidental chat surface in open source Generated with Codex
This commit is contained in:
@@ -12,7 +12,6 @@ import { openExternalUrl, shouldOpenExternally } from '@/utils/openExternalUrl';
|
||||
import { normalizeNodeLink } from '@/utils/nodeLink';
|
||||
import NodeSearchModal from './edges/NodeSearchModal';
|
||||
import { getNodeProcessedState, parseNodeMetadata } from '@/services/nodes/metadata';
|
||||
import type { ContextSummary } from '@/types/database';
|
||||
|
||||
interface FocusPanelProps {
|
||||
openTabs: number[];
|
||||
@@ -68,9 +67,6 @@ export default function FocusPanel({
|
||||
const [sourceEditMode, setSourceEditMode] = useState(false);
|
||||
const [sourceEditValue, setSourceEditValue] = useState('');
|
||||
const [sourceSaving, setSourceSaving] = useState(false);
|
||||
const [availableContexts, setAvailableContexts] = useState<ContextSummary[]>([]);
|
||||
const [contextSaving, setContextSaving] = useState(false);
|
||||
const [contextMenuOpen, setContextMenuOpen] = useState(false);
|
||||
const [hoveredSection, setHoveredSection] = useState<HoverSection>(null);
|
||||
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -79,7 +75,6 @@ export default function FocusPanel({
|
||||
const sourceTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const skipTitleBlurRef = useRef(false);
|
||||
const skipLinkBlurRef = useRef(false);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentNode = activeTab !== null ? nodesData[activeTab] : undefined;
|
||||
const normalizedCurrentLink = normalizeNodeLink(currentNode?.link || null);
|
||||
@@ -105,34 +100,6 @@ export default function FocusPanel({
|
||||
if (sourceEditMode) sourceTextareaRef.current?.focus();
|
||||
}, [sourceEditMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenuOpen) return;
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!contextMenuRef.current?.contains(event.target as globalThis.Node)) {
|
||||
setContextMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown);
|
||||
}, [contextMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/contexts');
|
||||
const data = await response.json();
|
||||
if (response.ok && data.success) {
|
||||
setAvailableContexts(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch contexts:', error);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
openTabs.forEach((tabId) => {
|
||||
if (!nodesData[tabId] && !loadingNodes.has(tabId)) {
|
||||
@@ -165,7 +132,6 @@ export default function FocusPanel({
|
||||
setDescEditValue('');
|
||||
setSourceEditMode(false);
|
||||
setSourceEditValue('');
|
||||
setContextMenuOpen(false);
|
||||
setEdgeSearchOpen(false);
|
||||
setEdgeEditingId(null);
|
||||
setEdgeEditingValue('');
|
||||
@@ -389,20 +355,6 @@ export default function FocusPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const saveContext = async (value: string) => {
|
||||
if (!activeTab) return;
|
||||
setContextSaving(true);
|
||||
try {
|
||||
await updateNode(activeTab, { context_id: value ? Number(value) : null });
|
||||
setContextMenuOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Error saving context:', error);
|
||||
window.alert('Failed to save context. Please try again.');
|
||||
} finally {
|
||||
setContextSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const embedContent = async (nodeId: number) => {
|
||||
setEmbeddingNode(nodeId);
|
||||
try {
|
||||
@@ -1192,60 +1144,6 @@ export default function FocusPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="prop-row">
|
||||
<div className="prop-label">context</div>
|
||||
<div className="prop-value context-prop-value">
|
||||
<div className="context-select-shell" ref={contextMenuRef}>
|
||||
<div className="context-inline-row">
|
||||
<span className={currentNode.context ? 'context-select-name' : 'context-select-empty'}>
|
||||
{currentNode.context?.name || 'No context'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="context-inline-button"
|
||||
disabled={contextSaving}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setContextMenuOpen((prev) => !prev);
|
||||
}}
|
||||
title={currentNode.context ? 'Change context' : 'Add context'}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{contextMenuOpen ? (
|
||||
<div className="context-select-menu">
|
||||
<button
|
||||
type="button"
|
||||
className={`context-option ${currentNode.context_id == null ? 'active' : ''}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void saveContext('');
|
||||
}}
|
||||
>
|
||||
No context
|
||||
</button>
|
||||
{availableContexts.map((context) => (
|
||||
<button
|
||||
key={context.id}
|
||||
type="button"
|
||||
className={`context-option ${currentNode.context_id === context.id ? 'active' : ''}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void saveContext(String(context.id));
|
||||
}}
|
||||
>
|
||||
<span>{context.name}</span>
|
||||
<span className="context-option-count">{context.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connections */}
|
||||
<div className="prop-row prop-row-top">
|
||||
<div className="prop-label">edge</div>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { MessageSquare, X } from 'lucide-react';
|
||||
import type { ChatPanelProps } from '../panes/types';
|
||||
|
||||
export default function ChatPanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
onOpen,
|
||||
}: ChatPanelProps) {
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: 20,
|
||||
bottom: 20,
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onOpen}
|
||||
title="Open chat"
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: 'var(--rah-accent-green)',
|
||||
color: 'var(--rah-text-inverse)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: 'var(--rah-shadow-floating)',
|
||||
}}
|
||||
>
|
||||
<MessageSquare size={20} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'var(--rah-bg-surface)',
|
||||
borderRadius: 10,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 12px',
|
||||
borderBottom: '1px solid var(--rah-border)',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--rah-text-soft)', fontSize: 12, fontWeight: 500, letterSpacing: '0.05em' }}>
|
||||
Chat
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
title="Close chat"
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
background: 'var(--rah-bg-surface)',
|
||||
color: 'var(--rah-text-muted)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
color: 'var(--rah-text-muted)',
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Chat UI is not included in this open-source parity slice. The graph, MCP, retrieval, and skill surfaces remain available.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
RefreshCw,
|
||||
LayoutList,
|
||||
Map,
|
||||
Folder,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Table2,
|
||||
@@ -21,7 +20,6 @@ import {
|
||||
import type { PaneType } from '../panes/types';
|
||||
import type { FocusedSkill } from '@/types/skills';
|
||||
import type { Theme } from '@/hooks/useTheme';
|
||||
import type { ContextSummary } from '@/types/database';
|
||||
|
||||
interface LeftToolbarProps {
|
||||
onSearchClick: () => void;
|
||||
@@ -38,8 +36,6 @@ interface LeftToolbarProps {
|
||||
focusedSkill?: FocusedSkill | null;
|
||||
theme: Theme;
|
||||
onThemeToggle: () => void;
|
||||
contexts?: ContextSummary[];
|
||||
onContextQuickSelect?: (contextId: number, contextName: string) => void;
|
||||
}
|
||||
|
||||
const NAV_WIDTH_COLLAPSED = 50;
|
||||
@@ -124,10 +120,7 @@ export default function LeftToolbar({
|
||||
focusedSkill,
|
||||
theme,
|
||||
onThemeToggle,
|
||||
contexts = [],
|
||||
onContextQuickSelect,
|
||||
}: LeftToolbarProps) {
|
||||
const [contextsExpanded, setContextsExpanded] = useState(false);
|
||||
const [paneSelectorOpen, setPaneSelectorOpen] = useState(false);
|
||||
|
||||
const renderPaneGlyph = (count: 1 | 2 | 3, active: boolean) => (
|
||||
@@ -302,77 +295,6 @@ export default function LeftToolbar({
|
||||
activeTone="green"
|
||||
/>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginTop: '10px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<NavButton
|
||||
icon={Folder}
|
||||
label="Contexts"
|
||||
expanded={isExpanded}
|
||||
active={activeTabType === 'contexts' || openTabTypes.has('contexts')}
|
||||
onClick={() => onPaneTypeClick('contexts')}
|
||||
activeTone="green"
|
||||
/>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setContextsExpanded((prev) => !prev)}
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '36px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: contextsExpanded ? 'var(--rah-bg-hover)' : 'transparent',
|
||||
color: contextsExpanded ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title="Toggle context list"
|
||||
>
|
||||
{contextsExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isExpanded && contextsExpanded && contexts.length > 0 ? (
|
||||
<div style={{ marginLeft: '14px', paddingLeft: '12px', borderLeft: '1px solid var(--rah-border)', display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||
{contexts.map((context) => (
|
||||
<button
|
||||
key={context.id}
|
||||
type="button"
|
||||
onClick={() => onContextQuickSelect?.(context.id, context.name)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px',
|
||||
width: '100%',
|
||||
minHeight: '28px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--rah-text-secondary)',
|
||||
cursor: 'pointer',
|
||||
padding: '0 8px',
|
||||
borderRadius: '8px',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '12px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{context.name}
|
||||
</span>
|
||||
<span style={{ fontSize: '10px', color: 'var(--rah-text-muted)', flexShrink: 0 }}>
|
||||
{context.count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,124 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Folder } from 'lucide-react';
|
||||
import type { ContextSummary } from '@/types/database';
|
||||
import PaneHeader from './PaneHeader';
|
||||
import type { ContextsPaneProps } from './types';
|
||||
|
||||
export default function ContextsPane({
|
||||
slot,
|
||||
onCollapse,
|
||||
onSwapPanes,
|
||||
tabBar,
|
||||
onContextSelect,
|
||||
}: ContextsPaneProps) {
|
||||
const [contexts, setContexts] = useState<ContextSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/contexts');
|
||||
const payload = await response.json();
|
||||
if (response.ok && payload.success) {
|
||||
setContexts(payload.data || []);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: 'transparent', overflow: 'hidden' }}>
|
||||
<PaneHeader
|
||||
slot={slot}
|
||||
onCollapse={onCollapse}
|
||||
onSwapPanes={onSwapPanes}
|
||||
tabBar={tabBar}
|
||||
toolbarHostRef={setToolbarHost}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '12px' }}>
|
||||
<div style={{ maxWidth: '980px', margin: '0 auto', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{loading ? (
|
||||
<div style={emptyStateStyle}>Loading contexts...</div>
|
||||
) : contexts.length === 0 ? (
|
||||
<div style={emptyStateStyle}>No contexts yet. That is optional.</div>
|
||||
) : (
|
||||
contexts.map((context) => (
|
||||
<button
|
||||
key={context.id}
|
||||
type="button"
|
||||
onClick={() => onContextSelect?.(context.id, context.name)}
|
||||
style={rowStyle}
|
||||
>
|
||||
<div style={iconWrapStyle}>
|
||||
<Folder size={17} />
|
||||
</div>
|
||||
<div style={{ minWidth: 0, flex: 1, textAlign: 'left' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', minWidth: 0, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--rah-text-primary)' }}>{context.name}</span>
|
||||
<span style={countStyle}>{context.count}</span>
|
||||
</div>
|
||||
{context.description ? (
|
||||
<div style={{ marginTop: '4px', fontSize: '12px', color: 'var(--rah-text-secondary)', lineHeight: 1.45 }}>
|
||||
{context.description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rowStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '12px 14px',
|
||||
border: '1px solid var(--rah-border)',
|
||||
borderRadius: '12px',
|
||||
background: 'var(--rah-bg-card)',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
const iconWrapStyle: React.CSSProperties = {
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid var(--rah-border)',
|
||||
background: 'var(--rah-bg-panel)',
|
||||
color: 'var(--rah-text-muted)',
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const countStyle: React.CSSProperties = {
|
||||
fontSize: '10px',
|
||||
lineHeight: 1.2,
|
||||
padding: '3px 7px',
|
||||
borderRadius: '999px',
|
||||
border: '1px solid var(--rah-border)',
|
||||
background: 'var(--rah-bg-panel)',
|
||||
color: 'var(--rah-text-muted)',
|
||||
};
|
||||
|
||||
const emptyStateStyle: React.CSSProperties = {
|
||||
border: '1px dashed var(--rah-border-strong)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
color: 'var(--rah-text-muted)',
|
||||
fontSize: '13px',
|
||||
background: 'var(--rah-bg-card)',
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import PaneHeader from './PaneHeader';
|
||||
import type { BasePaneProps } from './types';
|
||||
import SkillCard from '@/components/skills/SkillCard';
|
||||
import SkillMarkdown from '@/components/skills/SkillMarkdown';
|
||||
import type { FocusedSkill } from '@/types/skills';
|
||||
|
||||
interface SkillMeta {
|
||||
name: string;
|
||||
@@ -17,12 +18,19 @@ interface Skill extends SkillMeta {
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface SkillsPaneProps extends BasePaneProps {
|
||||
focusedSkill?: FocusedSkill | null;
|
||||
onFocusSkill?: (skill: FocusedSkill | null) => void;
|
||||
autoOpenSkillName?: string | null;
|
||||
onAutoOpenHandled?: () => void;
|
||||
}
|
||||
|
||||
export default function SkillsPane({
|
||||
slot,
|
||||
onCollapse,
|
||||
onSwapPanes,
|
||||
tabBar,
|
||||
}: BasePaneProps) {
|
||||
}: SkillsPaneProps) {
|
||||
const [skills, setSkills] = useState<SkillMeta[]>([]);
|
||||
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { X, LayoutList, PanelsTopLeft, Map, FileText, Table2, BookOpen } from 'lucide-react';
|
||||
import { X, LayoutList, Map, FileText, Table2, BookOpen } from 'lucide-react';
|
||||
import type { SlotTab, PaneType, SlotId } from './types';
|
||||
|
||||
const TAB_TYPE_ICONS: Record<PaneType, typeof LayoutList> = {
|
||||
views: LayoutList,
|
||||
contexts: PanelsTopLeft,
|
||||
map: Map,
|
||||
node: FileText,
|
||||
table: Table2,
|
||||
@@ -15,7 +14,6 @@ const TAB_TYPE_ICONS: Record<PaneType, typeof LayoutList> = {
|
||||
|
||||
const TAB_TYPE_LABELS: Record<PaneType, string> = {
|
||||
views: 'Feed',
|
||||
contexts: 'Contexts',
|
||||
map: 'Map',
|
||||
node: 'Node',
|
||||
table: 'Table',
|
||||
|
||||
@@ -12,9 +12,6 @@ export interface ViewsPaneProps extends BasePaneProps {
|
||||
refreshToken?: number;
|
||||
pendingNodes?: PendingNode[];
|
||||
onDismissPending?: (id: string) => void;
|
||||
externalContextFilterId?: number | null;
|
||||
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
|
||||
onClearExternalContextFilter?: () => void;
|
||||
}
|
||||
|
||||
export default function ViewsPane({
|
||||
@@ -29,9 +26,6 @@ export default function ViewsPane({
|
||||
refreshToken,
|
||||
pendingNodes,
|
||||
onDismissPending,
|
||||
externalContextFilterId,
|
||||
onContextFilterSelect,
|
||||
onClearExternalContextFilter,
|
||||
}: ViewsPaneProps) {
|
||||
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -61,9 +55,6 @@ export default function ViewsPane({
|
||||
refreshToken={refreshToken}
|
||||
pendingNodes={pendingNodes}
|
||||
onDismissPending={onDismissPending}
|
||||
externalContextFilterId={externalContextFilterId}
|
||||
onContextFilterSelect={onContextFilterSelect}
|
||||
onClearExternalContextFilter={onClearExternalContextFilter}
|
||||
toolbarHost={toolbarHost}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export { default as NodePane } from './NodePane';
|
||||
export { default as ContextsPane } from './ContextsPane';
|
||||
export { default as MapPane } from './MapPane';
|
||||
export { default as ViewsPane } from './ViewsPane';
|
||||
export { default as TablePane } from './TablePane';
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { FocusedSkill } from '@/types/skills';
|
||||
export type SlotId = 'A' | 'B' | 'C';
|
||||
|
||||
// The pane types
|
||||
export type PaneType = 'node' | 'contexts' | 'map' | 'views' | 'table' | 'skills';
|
||||
export type PaneType = 'node' | 'map' | 'views' | 'table' | 'skills';
|
||||
|
||||
// A single tab within a slot
|
||||
export interface SlotTab {
|
||||
@@ -32,7 +32,6 @@ export function getActiveTab(state: SlotState): SlotTab | undefined {
|
||||
// Actions panes can emit to the layout
|
||||
export type PaneAction =
|
||||
| { type: 'open-node'; nodeId: number; targetSlot?: SlotId }
|
||||
| { type: 'open-context'; contextId: number | null; contextName?: string | null; targetSlot?: SlotId }
|
||||
| { type: 'switch-pane-type'; paneType: PaneType }
|
||||
| { type: 'close-pane' };
|
||||
|
||||
@@ -69,13 +68,13 @@ export interface HighlightedPassage {
|
||||
|
||||
export interface ChatPanelProps {
|
||||
isOpen: boolean;
|
||||
isSoloPane?: boolean;
|
||||
slot?: SlotId;
|
||||
onClose: () => void;
|
||||
onOpen: () => void;
|
||||
onSwapPanes?: (source: SlotId, target: SlotId) => void;
|
||||
openTabsData: Node[];
|
||||
activeTabId: number | null;
|
||||
activeContextId?: number | null;
|
||||
activeContextName?: string | null;
|
||||
onClearContext?: () => void;
|
||||
focusedSkill?: FocusedSkill | null;
|
||||
onClearFocusedSkill?: () => void;
|
||||
onNodeClick?: (nodeId: number) => void;
|
||||
@@ -99,14 +98,6 @@ export interface ViewsPaneProps extends BasePaneProps {
|
||||
onNodeClick: (nodeId: number) => void;
|
||||
onNodeOpenInOtherPane?: (nodeId: number) => void;
|
||||
refreshToken?: number;
|
||||
externalContextFilterId?: number | null;
|
||||
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
|
||||
onClearExternalContextFilter?: () => void;
|
||||
}
|
||||
|
||||
export interface ContextsPaneProps extends BasePaneProps {
|
||||
onNodeOpen: (nodeId: number) => void;
|
||||
onContextSelect?: (contextId: number | null, contextName?: string | null) => void;
|
||||
}
|
||||
|
||||
// TablePane specific props
|
||||
@@ -128,7 +119,6 @@ export interface PaneHeaderProps {
|
||||
// Labels for pane types
|
||||
export const PANE_LABELS: Record<PaneType, string> = {
|
||||
node: 'Nodes',
|
||||
contexts: 'Contexts',
|
||||
map: 'Map',
|
||||
views: 'Feed',
|
||||
table: 'Table',
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type CSSProperties } from 'react';
|
||||
import type { Node } from '@/types/database';
|
||||
|
||||
interface NodeWithMetrics extends Node {
|
||||
edge_count?: number;
|
||||
}
|
||||
|
||||
interface CapsuleData {
|
||||
userProfile: string;
|
||||
agentProfile: string;
|
||||
lastUpdatedAt: string;
|
||||
}
|
||||
|
||||
export default function ContextViewer() {
|
||||
const [nodes, setNodes] = useState<NodeWithMetrics[]>([]);
|
||||
const [capsule, setCapsule] = useState<CapsuleData | null>(null);
|
||||
const [loadingNodes, setLoadingNodes] = useState(true);
|
||||
const [loadingCapsule, setLoadingCapsule] = useState(true);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
|
||||
const loadNodes = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/nodes?sortBy=edges&limit=5');
|
||||
const payload = await res.json();
|
||||
setNodes(payload.data || []);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setNodes([]);
|
||||
} finally {
|
||||
setLoadingNodes(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCapsule = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/rah/memory');
|
||||
const payload = await res.json();
|
||||
setCapsule(payload.data?.capsule ?? null);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setCapsule(null);
|
||||
} finally {
|
||||
setLoadingCapsule(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadNodes();
|
||||
void loadCapsule();
|
||||
}, []);
|
||||
|
||||
const handleReset = async () => {
|
||||
if (!confirm('Reset the context capsule to neutral defaults?')) return;
|
||||
try {
|
||||
setResetting(true);
|
||||
await fetch('/api/rah/memory', { method: 'DELETE' });
|
||||
await loadCapsule();
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<p style={descStyle}>
|
||||
RA-H now carries one compact context capsule into every conversation. It stays neutral by default,
|
||||
updates only on meaningful changes, and sits alongside hub nodes rather than replacing them.
|
||||
</p>
|
||||
|
||||
<div style={capsuleHeaderStyle}>
|
||||
<div>
|
||||
<div style={labelStyle}>Context Capsule</div>
|
||||
<div style={subLabelStyle}>Always injected into the system prompt. Max 200 words total.</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={resetting}
|
||||
style={resetButtonStyle}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--settings-button-hover-bg)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
{resetting ? 'Resetting...' : 'Reset Capsule'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingCapsule ? (
|
||||
<div style={mutedStyle}>Loading capsule...</div>
|
||||
) : capsule ? (
|
||||
<div style={capsuleGridStyle}>
|
||||
<CapsuleSection
|
||||
title="User"
|
||||
value={capsule.userProfile}
|
||||
footer={capsule.lastUpdatedAt ? `Updated ${new Date(capsule.lastUpdatedAt).toLocaleString()}` : 'Never updated'}
|
||||
/>
|
||||
<CapsuleSection title="Agent" value={capsule.agentProfile} />
|
||||
</div>
|
||||
) : (
|
||||
<div style={mutedStyle}>Capsule unavailable.</div>
|
||||
)}
|
||||
|
||||
<div style={{ ...labelStyle, marginTop: 28 }}>Hub Nodes</div>
|
||||
<div style={subLabelStyle}>
|
||||
Your 5 most-connected nodes remain the raw graph grounding and are included in every conversation.
|
||||
</div>
|
||||
|
||||
{loadingNodes ? (
|
||||
<div style={mutedStyle}>Loading hub nodes...</div>
|
||||
) : nodes.length === 0 ? (
|
||||
<div style={mutedStyle}>No connected nodes yet.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 12 }}>
|
||||
{nodes.map((node) => (
|
||||
<div key={node.id} style={nodeCardStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={nodeTitleStyle}>{node.title || 'Untitled'}</span>
|
||||
<span style={edgeCountStyle}>{node.edge_count ?? 0}</span>
|
||||
</div>
|
||||
{node.description && (
|
||||
<div style={nodeDescriptionStyle}>{node.description}</div>
|
||||
)}
|
||||
{node.context?.name && (
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 8, flexWrap: 'wrap' }}>
|
||||
<span style={contextTagStyle}>{node.context.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapsuleSection({
|
||||
title,
|
||||
value,
|
||||
footer,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
footer?: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
<div style={labelStyle}>{title}</div>
|
||||
<div style={capsuleBodyStyle}>{value}</div>
|
||||
{footer && <div style={capsuleFooterStyle}>{footer}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const containerStyle: CSSProperties = { padding: 24, height: '100%', overflow: 'auto' };
|
||||
const descStyle: CSSProperties = { fontSize: 13, color: 'var(--settings-muted)', marginBottom: 20, lineHeight: 1.5, maxWidth: 780 };
|
||||
const capsuleHeaderStyle: CSSProperties = { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, marginBottom: 12 };
|
||||
const capsuleGridStyle: CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 12 };
|
||||
const cardStyle: CSSProperties = {
|
||||
background: 'var(--settings-card-bg)',
|
||||
border: '1px solid var(--settings-border)',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
minHeight: 140,
|
||||
};
|
||||
const labelStyle: CSSProperties = { fontSize: 13, fontWeight: 500, color: 'var(--settings-text)', marginBottom: 8 };
|
||||
const subLabelStyle: CSSProperties = { fontSize: 12, color: 'var(--settings-muted)' };
|
||||
const mutedStyle: CSSProperties = { fontSize: 13, color: 'var(--settings-muted)', marginTop: 10 };
|
||||
const capsuleBodyStyle: CSSProperties = { fontSize: 13, lineHeight: 1.6, color: 'var(--settings-subtext)', whiteSpace: 'pre-wrap' };
|
||||
const capsuleFooterStyle: CSSProperties = { fontSize: 11, color: 'var(--settings-muted)', marginTop: 12 };
|
||||
const resetButtonStyle: CSSProperties = {
|
||||
padding: '8px 14px',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--settings-border-strong)',
|
||||
borderRadius: '6px',
|
||||
color: 'var(--settings-text)',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
const nodeCardStyle: CSSProperties = {
|
||||
padding: '12px 14px',
|
||||
background: 'var(--settings-card-bg)',
|
||||
border: '1px solid var(--settings-border)',
|
||||
borderRadius: 6,
|
||||
};
|
||||
const nodeTitleStyle: CSSProperties = { fontSize: 13, fontWeight: 500, color: 'var(--settings-text)' };
|
||||
const nodeDescriptionStyle: CSSProperties = { fontSize: 12, lineHeight: 1.5, color: 'var(--settings-subtext)', marginTop: 8 };
|
||||
const edgeCountStyle: CSSProperties = { fontSize: 12, color: 'var(--settings-muted)' };
|
||||
const contextTagStyle: CSSProperties = {
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
background: 'var(--settings-chip-bg)',
|
||||
color: 'var(--settings-subtext)',
|
||||
border: '1px solid var(--settings-border)',
|
||||
};
|
||||
@@ -325,24 +325,7 @@ export default function DatabaseViewer() {
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '18%' }}>
|
||||
{node.context?.name ? (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
padding: '3px 10px',
|
||||
borderRadius: '999px',
|
||||
background: '#142817',
|
||||
color: '#c4f5d2',
|
||||
fontSize: '11px',
|
||||
border: '1px solid #1f3b23',
|
||||
}}
|
||||
>
|
||||
{node.context.name}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: '11px', color: '#555' }}>Unscoped</span>
|
||||
)}
|
||||
<span style={{ fontSize: '11px', color: '#555' }}>—</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', verticalAlign: 'top', width: '10%' }}>
|
||||
<div style={{ fontWeight: 600, color: '#e2e8f0' }}>{node.edge_count ?? 0}</div>
|
||||
|
||||
@@ -4,9 +4,8 @@ import { useEffect, useState, type CSSProperties } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import ApiKeysViewer from './ApiKeysViewer';
|
||||
import ExternalAgentsPanel from './ExternalAgentsPanel';
|
||||
import ContextViewer from './ContextViewer';
|
||||
|
||||
export type SettingsTab = 'apikeys' | 'context' | 'agents';
|
||||
export type SettingsTab = 'apikeys' | 'agents';
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -15,10 +14,10 @@ interface SettingsModalProps {
|
||||
}
|
||||
|
||||
const DEFAULT_TAB: SettingsTab = 'apikeys';
|
||||
const TAB_ORDER: SettingsTab[] = ['apikeys', 'context', 'agents'];
|
||||
const TAB_ORDER: SettingsTab[] = ['apikeys', 'agents'];
|
||||
|
||||
const TAB_LABELS: Record<SettingsTab, string> = {
|
||||
apikeys: 'API Keys',
|
||||
context: 'Context',
|
||||
agents: 'Agents',
|
||||
};
|
||||
|
||||
@@ -31,11 +30,9 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => window.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, onClose]);
|
||||
@@ -50,6 +47,7 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
|
||||
return createPortal(
|
||||
<div style={backdropStyle} onClick={onClose}>
|
||||
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
{/* Sidebar */}
|
||||
<div style={sidebarStyle}>
|
||||
<div style={logoStyle}>Settings</div>
|
||||
|
||||
@@ -70,36 +68,27 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div style={footerStyle}>
|
||||
<div style={footerLabelStyle}>Local Mode</div>
|
||||
<p style={footerTextStyle}>
|
||||
This build runs entirely on your machine. Add your API key here to unlock descriptions,
|
||||
embeddings, and agent-assisted workflows.
|
||||
</p>
|
||||
<div style={userSectionStyle}>
|
||||
<div style={userLabelStyle}>Local Mode</div>
|
||||
<div style={userEmailStyle}>Bring your own API keys for descriptions, embeddings, and agent workflows.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={contentStyle}>
|
||||
<div style={headerStyle}>
|
||||
<h2 style={titleStyle}>{TAB_LABELS[activeTab]}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={closeBtnStyle}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--settings-text)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--settings-muted)';
|
||||
}}
|
||||
title="Close (ESC)"
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--settings-text)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--settings-muted)'; }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={contentAreaStyle}>
|
||||
{activeTab === 'apikeys' && <ApiKeysViewer />}
|
||||
{activeTab === 'context' && <ContextViewer />}
|
||||
{activeTab === 'agents' && <ExternalAgentsPanel />}
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,6 +98,7 @@ export default function SettingsModal({ isOpen, onClose, initialTab }: SettingsM
|
||||
);
|
||||
}
|
||||
|
||||
// Styles
|
||||
const backdropStyle: CSSProperties = {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
@@ -133,7 +123,7 @@ const modalStyle: CSSProperties = {
|
||||
};
|
||||
|
||||
const sidebarStyle: CSSProperties = {
|
||||
width: '220px',
|
||||
width: '200px',
|
||||
background: 'var(--settings-sidebar-bg)',
|
||||
borderRight: '1px solid var(--settings-border)',
|
||||
display: 'flex',
|
||||
@@ -146,6 +136,7 @@ const logoStyle: CSSProperties = {
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--settings-text)',
|
||||
letterSpacing: '-0.01em',
|
||||
};
|
||||
|
||||
const navStyle: CSSProperties = {
|
||||
@@ -164,8 +155,7 @@ const navItemStyle: CSSProperties = {
|
||||
borderRadius: '8px',
|
||||
};
|
||||
|
||||
const footerStyle: CSSProperties = {
|
||||
marginTop: 'auto',
|
||||
const userSectionStyle: CSSProperties = {
|
||||
padding: '16px 20px',
|
||||
borderTop: '1px solid var(--settings-border)',
|
||||
display: 'flex',
|
||||
@@ -173,19 +163,31 @@ const footerStyle: CSSProperties = {
|
||||
gap: '8px',
|
||||
};
|
||||
|
||||
const footerLabelStyle: CSSProperties = {
|
||||
const userLabelStyle: CSSProperties = {
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.06em',
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
color: 'var(--settings-muted)',
|
||||
};
|
||||
|
||||
const footerTextStyle: CSSProperties = {
|
||||
const userEmailStyle: CSSProperties = {
|
||||
fontSize: '12px',
|
||||
color: 'var(--settings-subtext)',
|
||||
margin: 0,
|
||||
lineHeight: 1.5,
|
||||
wordBreak: 'break-all',
|
||||
};
|
||||
|
||||
const signOutBtnStyle: CSSProperties = {
|
||||
marginTop: '4px',
|
||||
padding: '8px 12px',
|
||||
background: 'transparent',
|
||||
color: 'var(--settings-text)',
|
||||
border: '1px solid var(--settings-border-strong)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s ease, border-color 0.15s ease',
|
||||
};
|
||||
|
||||
const contentStyle: CSSProperties = {
|
||||
@@ -211,13 +213,13 @@ const titleStyle: CSSProperties = {
|
||||
};
|
||||
|
||||
const closeBtnStyle: CSSProperties = {
|
||||
background: 'transparent',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--settings-muted)',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '24px',
|
||||
lineHeight: 1,
|
||||
padding: '4px 8px',
|
||||
lineHeight: 1,
|
||||
transition: 'color 0.15s ease',
|
||||
};
|
||||
|
||||
|
||||
@@ -322,30 +322,7 @@ export default function DatabaseTableView({ onNodeClick, refreshToken = 0, toolb
|
||||
{node.id}
|
||||
</span>
|
||||
|
||||
<span style={{ minWidth: 0 }}>
|
||||
{node.context?.name ? (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
maxWidth: '100%',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '999px',
|
||||
border: '1px solid var(--rah-border)',
|
||||
background: 'var(--rah-bg-surface)',
|
||||
color: 'var(--rah-text-soft)',
|
||||
fontSize: '11px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{node.context.name}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: '11px', color: 'var(--rah-text-muted)' }}>—</span>
|
||||
)}
|
||||
</span>
|
||||
<span style={{ minWidth: 0, fontSize: '11px', color: 'var(--rah-text-muted)' }}>—</span>
|
||||
|
||||
<span style={{ fontSize: '12px', color: node.edge_count ? 'var(--rah-text-soft)' : 'var(--rah-text-muted)' }}>
|
||||
{node.edge_count ?? 0}
|
||||
|
||||
@@ -133,27 +133,6 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer with Context */}
|
||||
{node.context?.name && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
flexWrap: 'wrap',
|
||||
marginTop: 'auto'
|
||||
}}>
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
background: '#1a1a1a',
|
||||
borderRadius: '3px',
|
||||
fontSize: '10px',
|
||||
color: '#888'
|
||||
}}
|
||||
>
|
||||
{node.context.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -146,21 +146,6 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
|
||||
}}>
|
||||
{processedState}
|
||||
</span>
|
||||
{node.context?.name && (
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
background: 'var(--rah-bg-active)',
|
||||
border: '1px solid var(--rah-border-strong)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '11px',
|
||||
color: 'var(--rah-text-base)'
|
||||
}}
|
||||
>
|
||||
{node.context.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Date */}
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Filter, ChevronDown, X, ArrowUpDown, GripVertical, Inbox, Check } from 'lucide-react';
|
||||
import type { ContextSummary, Node } from '@/types/database';
|
||||
import { ChevronDown, X, ArrowUpDown, GripVertical, Inbox, Check } from 'lucide-react';
|
||||
import type { Node } from '@/types/database';
|
||||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||||
import { usePersistentState } from '@/hooks/usePersistentState';
|
||||
import type { PendingNode } from '@/components/layout/ThreePanelLayout';
|
||||
@@ -29,9 +29,6 @@ interface ViewsOverlayProps {
|
||||
refreshToken?: number;
|
||||
pendingNodes?: PendingNode[];
|
||||
onDismissPending?: (id: string) => void;
|
||||
externalContextFilterId?: number | null;
|
||||
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
|
||||
onClearExternalContextFilter?: () => void;
|
||||
toolbarHost?: HTMLDivElement | null;
|
||||
}
|
||||
|
||||
@@ -216,15 +213,9 @@ export default function ViewsOverlay({
|
||||
refreshToken = 0,
|
||||
pendingNodes,
|
||||
onDismissPending,
|
||||
externalContextFilterId = null,
|
||||
onContextFilterSelect,
|
||||
onClearExternalContextFilter,
|
||||
toolbarHost,
|
||||
}: ViewsOverlayProps) {
|
||||
|
||||
const [contexts, setContexts] = useState<ContextSummary[]>([]);
|
||||
const [contextsLoading, setContextsLoading] = useState(true);
|
||||
|
||||
// Sort order (persisted)
|
||||
const [sortOrder, setSortOrder] = usePersistentState<SortOrder>('ui.feedSortOrder', 'updated');
|
||||
|
||||
@@ -245,22 +236,6 @@ export default function ViewsOverlay({
|
||||
? 'not_processed'
|
||||
: 'all';
|
||||
|
||||
const fetchContexts = useCallback(async () => {
|
||||
setContextsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/contexts');
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || 'Failed to fetch contexts');
|
||||
}
|
||||
setContexts(data.data || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching contexts:', error);
|
||||
} finally {
|
||||
setContextsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyProcessedFilter = useCallback((nodes: Node[]) => {
|
||||
if (processedFilter === 'all') return nodes;
|
||||
return nodes.filter((node) => getNodeProcessedState(node.metadata) === processedFilter);
|
||||
@@ -273,7 +248,7 @@ export default function ViewsOverlay({
|
||||
const apiSort = sortOrder === 'custom' || sortOrder === 'processed' || sortOrder === 'not_processed'
|
||||
? 'updated'
|
||||
: sortOrder;
|
||||
const response = await fetch(`/api/nodes?limit=500&sortBy=${apiSort}${externalContextFilterId ? `&contextId=${externalContextFilterId}` : ''}`);
|
||||
const response = await fetch(`/api/nodes?limit=500&sortBy=${apiSort}`);
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || 'Failed to fetch nodes');
|
||||
@@ -301,45 +276,28 @@ export default function ViewsOverlay({
|
||||
} finally {
|
||||
setFilteredNodesLoading(false);
|
||||
}
|
||||
}, [sortOrder, customOrder, applyProcessedFilter, externalContextFilterId]);
|
||||
}, [sortOrder, customOrder, applyProcessedFilter]);
|
||||
|
||||
// Fetch contexts on mount
|
||||
useEffect(() => {
|
||||
fetchContexts();
|
||||
}, [fetchContexts]);
|
||||
|
||||
// Fetch nodes on mount and when sort/refreshToken/context filter change
|
||||
// Fetch nodes on mount and when sort/refreshToken change
|
||||
useEffect(() => {
|
||||
if (refreshToken > 0) {
|
||||
console.log('🔄 Feed refreshing due to SSE event (refreshToken:', refreshToken, ')');
|
||||
}
|
||||
fetchAllNodes();
|
||||
}, [fetchAllNodes, refreshToken, sortOrder, externalContextFilterId]);
|
||||
|
||||
// Refresh contexts when data changes
|
||||
useEffect(() => {
|
||||
if (refreshToken > 0) {
|
||||
fetchContexts();
|
||||
}
|
||||
}, [refreshToken, fetchContexts]);
|
||||
}, [fetchAllNodes, refreshToken, sortOrder]);
|
||||
|
||||
// Close dropdowns on outside click
|
||||
const contextPickerRef = useRef<HTMLDivElement>(null);
|
||||
const sortDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [showContextPicker, setShowContextPicker] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (showContextPicker && contextPickerRef.current && !contextPickerRef.current.contains(e.target as HTMLElement)) {
|
||||
setShowContextPicker(false);
|
||||
}
|
||||
if (showSortDropdown && sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as HTMLElement)) {
|
||||
setShowSortDropdown(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showContextPicker, showSortDropdown]);
|
||||
}, [showSortDropdown]);
|
||||
|
||||
// Reorder handlers
|
||||
const handleReorderDrop = useCallback((dropIdx: number) => {
|
||||
@@ -615,107 +573,7 @@ export default function ViewsOverlay({
|
||||
gap: '10px',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', flex: 1 }}>
|
||||
{externalContextFilterId ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '5px',
|
||||
padding: '3px 8px',
|
||||
background: 'rgba(34, 197, 94, 0.06)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.12)',
|
||||
borderRadius: '5px',
|
||||
fontSize: '11px',
|
||||
color: '#5a9'
|
||||
}}
|
||||
>
|
||||
{contexts.find((context) => context.id === externalContextFilterId)?.name ?? 'Context'}
|
||||
<button
|
||||
onClick={() => onClearExternalContextFilter?.()}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#5a9',
|
||||
cursor: 'pointer',
|
||||
padding: '0',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ position: 'relative' }} ref={contextPickerRef}>
|
||||
<button
|
||||
onClick={() => setShowContextPicker(!showContextPicker)}
|
||||
title="Context filter"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 8px',
|
||||
background: externalContextFilterId ? 'rgba(34, 197, 94, 0.06)' : 'transparent',
|
||||
border: '1px solid var(--rah-border)',
|
||||
borderRadius: '5px',
|
||||
color: 'var(--rah-text-soft)',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease'
|
||||
}}
|
||||
>
|
||||
<Filter size={11} />
|
||||
Context
|
||||
</button>
|
||||
|
||||
{showContextPicker && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
marginTop: '4px',
|
||||
background: 'var(--rah-bg-panel)',
|
||||
border: '1px solid var(--rah-border)',
|
||||
borderRadius: '10px',
|
||||
padding: '6px',
|
||||
minWidth: '220px',
|
||||
maxHeight: '320px',
|
||||
overflowY: 'auto',
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.4)'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => {
|
||||
onClearExternalContextFilter?.();
|
||||
setShowContextPicker(false);
|
||||
}}
|
||||
style={pickerRowStyle}
|
||||
>
|
||||
All contexts
|
||||
</button>
|
||||
{contextsLoading ? (
|
||||
<div style={{ padding: '12px', color: 'var(--rah-text-muted)', fontSize: '12px', textAlign: 'center' }}>
|
||||
Loading contexts...
|
||||
</div>
|
||||
) : contexts.map((context) => (
|
||||
<button
|
||||
key={context.id}
|
||||
onClick={() => {
|
||||
onContextFilterSelect?.(context.id, context.name);
|
||||
setShowContextPicker(false);
|
||||
}}
|
||||
style={pickerRowStyle}
|
||||
>
|
||||
<span>{context.name}</span>
|
||||
<span style={pickerCountStyle}>{context.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap', flex: 1 }} />
|
||||
|
||||
{/* Sort dropdown */}
|
||||
<div style={{ position: 'relative' }} ref={sortDropdownRef}>
|
||||
|
||||
Reference in New Issue
Block a user