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:
“BeeRad”
2026-04-17 12:34:51 +10:00
parent 97eeb0789f
commit 07754f5030
87 changed files with 2688 additions and 4861 deletions
-102
View File
@@ -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>
+106
View File
@@ -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>
);
}
-78
View File
@@ -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
-124
View File
@@ -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)',
};
+9 -1
View File
@@ -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 -3
View File
@@ -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',
-9
View File
@@ -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
View File
@@ -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 -14
View File
@@ -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',
-198
View File
@@ -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)',
};
+1 -18
View File
@@ -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>
+35 -33
View File
@@ -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',
};
+1 -24
View File
@@ -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}
-21
View File
@@ -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>
);
})}
-15
View File
@@ -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',
+8 -150
View File
@@ -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}>