feat: port contexts layer and MCP parity

This commit is contained in:
“BeeRad”
2026-04-10 19:41:26 +10:00
parent a8c5506fb7
commit 35f9ecf89c
62 changed files with 4206 additions and 1224 deletions
+124
View File
@@ -0,0 +1,124 @@
"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.</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)',
};
+76 -78
View File
@@ -17,6 +17,7 @@ export default function NodePane({
onPaneAction,
onCollapse,
onSwapPanes,
tabBar,
openTabs,
activeTab,
onTabSelect,
@@ -92,76 +93,76 @@ export default function NodePane({
background: 'transparent',
overflow: 'hidden',
}}>
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes}>
{/* Tabs rendered inline */}
{openTabs.length === 0 ? (
<span style={{ fontSize: '12px', color: 'var(--rah-text-muted)' }}>No tabs open</span>
) : (
openTabs.map((tabId) => {
const title = nodeTitles[tabId] || 'Loading...';
const isActiveTab = activeTab === tabId;
return (
<div
key={tabId}
draggable
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'copyMove';
e.dataTransfer.setData('application/x-rah-tab', JSON.stringify({ id: tabId, title, sourceSlot: slot }));
e.dataTransfer.setData('text/plain', `[NODE:${tabId}:"${title}"]`);
}}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, tabId });
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
background: isActiveTab ? 'var(--rah-bg-active)' : 'transparent',
borderRadius: '4px',
cursor: 'grab',
flexShrink: 0,
}}
>
<button
onClick={() => onTabSelect(tabId)}
style={{
fontSize: '11px',
color: isActiveTab ? 'var(--rah-text-active)' : 'var(--rah-text-muted)',
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: 0,
whiteSpace: 'nowrap',
<PaneHeader slot={slot} onCollapse={onCollapse} onSwapPanes={onSwapPanes} tabBar={tabBar ? tabBar : (
/* Legacy node tabs (fallback when no tabBar prop) */
openTabs.length === 0 ? (
<span style={{ fontSize: '12px', color: '#666' }}>No tabs open</span>
) : (
openTabs.map((tabId) => {
const title = nodeTitles[tabId] || 'Loading...';
const isActiveTab = activeTab === tabId;
return (
<div
key={tabId}
draggable
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'copyMove';
e.dataTransfer.setData('application/x-rah-tab', JSON.stringify({ id: tabId, title, sourceSlot: slot }));
e.dataTransfer.setData('text/plain', `[NODE:${tabId}:"${title}"]`);
}}
>
{truncateTitle(title)}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onTabClose(tabId);
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, tabId });
}}
style={{
fontSize: '12px',
color: 'var(--rah-text-muted)',
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '0 2px',
lineHeight: 1,
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
background: isActiveTab ? '#1f1f1f' : 'transparent',
borderRadius: '4px',
cursor: 'grab',
flexShrink: 0,
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--rah-text-active)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
>
×
</button>
</div>
);
})
)}
</PaneHeader>
<button
onClick={() => onTabSelect(tabId)}
style={{
fontSize: '11px',
color: isActiveTab ? '#fff' : '#888',
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: 0,
whiteSpace: 'nowrap',
}}
>
{truncateTitle(title)}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onTabClose(tabId);
}}
style={{
fontSize: '12px',
color: '#666',
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '0 2px',
lineHeight: 1,
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
>
×
</button>
</div>
);
})
)
)} />
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<FocusPanel
@@ -171,11 +172,8 @@ export default function NodePane({
onNodeClick={onNodeClick}
onTabClose={onTabClose}
refreshTrigger={refreshTrigger}
onReorderTabs={onReorderTabs}
onOpenInOtherSlot={onOpenInOtherSlot}
onTextSelect={onTextSelect}
highlightedPassage={highlightedPassage}
hideTabBar
/>
</div>
@@ -186,8 +184,8 @@ export default function NodePane({
position: 'fixed',
top: contextMenu.y,
left: contextMenu.x,
background: 'var(--rah-bg-active)',
border: '1px solid var(--rah-border-strong)',
background: '#1a1a1a',
border: '1px solid #2a2a2a',
borderRadius: '6px',
padding: '4px',
zIndex: 9999,
@@ -211,18 +209,18 @@ export default function NodePane({
background: 'transparent',
border: 'none',
borderRadius: '4px',
color: 'var(--rah-text-secondary)',
color: '#ccc',
fontSize: '12px',
cursor: 'pointer',
textAlign: 'left',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--rah-bg-active)';
e.currentTarget.style.color = 'var(--rah-text-active)';
e.currentTarget.style.background = '#2a2a2a';
e.currentTarget.style.color = '#fff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--rah-text-secondary)';
e.currentTarget.style.color = '#ccc';
}}
>
<span style={{ fontSize: '14px' }}></span>
@@ -243,18 +241,18 @@ export default function NodePane({
background: 'transparent',
border: 'none',
borderRadius: '4px',
color: 'var(--rah-text-secondary)',
color: '#ccc',
fontSize: '12px',
cursor: 'pointer',
textAlign: 'left',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--rah-bg-active)';
e.currentTarget.style.color = 'var(--rah-text-active)';
e.currentTarget.style.background = '#2a2a2a';
e.currentTarget.style.color = '#fff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--rah-text-secondary)';
e.currentTarget.style.color = '#ccc';
}}
>
<span style={{ fontSize: '14px' }}>×</span>
+9
View File
@@ -12,7 +12,10 @@ export interface ViewsPaneProps extends BasePaneProps {
refreshToken?: number;
pendingNodes?: PendingNode[];
onDismissPending?: (id: string) => void;
externalContextFilterId?: number | null;
externalDimensionFilter?: string | null;
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
onClearExternalContextFilter?: () => void;
onClearExternalDimensionFilter?: () => void;
}
@@ -28,7 +31,10 @@ export default function ViewsPane({
refreshToken,
pendingNodes,
onDismissPending,
externalContextFilterId,
externalDimensionFilter,
onContextFilterSelect,
onClearExternalContextFilter,
onClearExternalDimensionFilter,
}: ViewsPaneProps) {
const [toolbarHost, setToolbarHost] = useState<HTMLDivElement | null>(null);
@@ -58,7 +64,10 @@ export default function ViewsPane({
refreshToken={refreshToken}
pendingNodes={pendingNodes}
onDismissPending={onDismissPending}
externalContextFilterId={externalContextFilterId}
externalDimensionFilter={externalDimensionFilter}
onContextFilterSelect={onContextFilterSelect}
onClearExternalContextFilter={onClearExternalContextFilter}
onClearExternalDimensionFilter={onClearExternalDimensionFilter}
toolbarHost={toolbarHost}
/>
+1
View File
@@ -1,4 +1,5 @@
export { default as NodePane } from './NodePane';
export { default as ContextsPane } from './ContextsPane';
export { default as DimensionsPane } from './DimensionsPane';
export { default as MapPane } from './MapPane';
export { default as ViewsPane } from './ViewsPane';
+10 -1
View File
@@ -18,7 +18,7 @@ export type AgentDelegation = {
};
// Pane types (chat removed in rah-light)
export type PaneType = 'node' | 'dimensions' | 'map' | 'views' | 'table' | 'skills';
export type PaneType = 'node' | 'contexts' | 'dimensions' | 'map' | 'views' | 'table' | 'skills';
// State for each slot
export interface SlotState {
@@ -34,6 +34,7 @@ export interface SlotState {
// 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: 'open-dimension'; dimension: string; targetSlot?: SlotId }
| { type: 'switch-pane-type'; paneType: PaneType }
| { type: 'close-pane' };
@@ -90,10 +91,17 @@ export interface ViewsPaneProps extends BasePaneProps {
onNodeClick: (nodeId: number) => void;
onNodeOpenInOtherPane?: (nodeId: number) => void;
refreshToken?: number;
externalContextFilterId?: number | null;
externalDimensionFilter?: string | null;
onContextFilterSelect?: (contextId: number | null, contextName?: string | null) => void;
onClearExternalContextFilter?: () => void;
onClearExternalDimensionFilter?: () => void;
}
export interface ContextsPaneProps extends BasePaneProps {
onContextSelect?: (contextId: number | null, contextName?: string | null) => void;
}
// TablePane specific props
export interface TablePaneProps extends BasePaneProps {
onNodeClick: (nodeId: number) => void;
@@ -113,6 +121,7 @@ export interface PaneHeaderProps {
// Labels for pane types
export const PANE_LABELS: Record<PaneType, string> = {
node: 'Nodes',
contexts: 'Contexts',
dimensions: 'Dimensions',
map: 'Map',
views: 'Feed',