Add query bar, maintenance panel, and heartbeat system

- Query bar with organized/grouped search results in portal
- Maintenance panel UI for triggering and viewing maintenance status
- Heartbeat service with periodic maintenance and dirty-tracking
- Query organizer for grouping search results by tag/kind/parent
- Slide-up animation for query panel
This commit is contained in:
2026-02-03 00:55:08 +01:00
parent f65653e260
commit af568f81c2
11 changed files with 785 additions and 2 deletions

View File

@@ -4,8 +4,10 @@ import GraphView from './components/GraphView';
import Sidebar from './components/Sidebar';
import NodePanel from './components/NodePanel';
import AddNodeModal from './components/AddNodeModal';
import QueryBar from './components/QueryBar';
import LinkModal from './components/LinkModal';
import Toast from './components/Toast';
import MaintenancePanel from './components/MaintenancePanel';
export default function App() {
const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -13,6 +15,8 @@ export default function App() {
const [linkFromId, setLinkFromId] = useState<string | null>(null);
const [toast, setToast] = useState<string | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [showQuery, setShowQuery] = useState(false);
const [showMaintenance, setShowMaintenance] = useState(false);
const qc = useQueryClient();
const refresh = useCallback(() => {
@@ -34,12 +38,13 @@ export default function App() {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (selectedId) setSelectedId(null);
else if (showQuery) setShowQuery(false);
else if (drawerOpen) setDrawerOpen(false);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [selectedId, drawerOpen]);
}, [selectedId, drawerOpen, showQuery]);
return (
<div className="h-screen w-screen overflow-hidden relative">
@@ -64,6 +69,20 @@ export default function App() {
>
+
</button>
<button
onClick={() => setShowQuery(!showQuery)}
className="w-12 h-12 rounded-full bg-purple-600/90 backdrop-blur border border-purple-500 text-white hover:bg-purple-500 shadow-lg flex items-center justify-center text-lg font-bold"
title="Query memory"
>
?
</button>
<button
onClick={() => setShowMaintenance(!showMaintenance)}
className="w-12 h-12 rounded-full bg-emerald-600/90 backdrop-blur border border-emerald-500 text-white hover:bg-emerald-500 shadow-lg flex items-center justify-center text-lg"
title="Maintenance"
>
&#x2699;
</button>
</div>
{/* Sidebar drawer — slides in from left */}
@@ -106,6 +125,22 @@ export default function App() {
</div>
)}
{/* Query panel — slides up from bottom */}
{showQuery && (
<div className="fixed inset-0 z-40" onClick={() => setShowQuery(false)}>
<div className="absolute inset-0 bg-black/40" />
<div
className="absolute bottom-0 left-0 right-0 h-[70vh] animate-slide-in-up"
onClick={(e) => e.stopPropagation()}
>
<QueryBar
onSelectNode={(id) => { setShowQuery(false); selectNode(id); }}
onClose={() => setShowQuery(false)}
/>
</div>
</div>
)}
{showAddNode && (
<AddNodeModal
onClose={() => setShowAddNode(false)}
@@ -119,6 +154,11 @@ export default function App() {
onCreated={() => { refresh(); notify('Edge created'); }}
/>
)}
{showMaintenance && (
<div className="fixed bottom-20 left-5 z-50">
<MaintenancePanel onClose={() => setShowMaintenance(false)} onNotify={(msg) => { notify(msg); refresh(); }} />
</div>
)}
{toast && <Toast message={toast} />}
</div>
);

View File

@@ -1,4 +1,4 @@
import type { CortexNode, CortexEdge, GraphData, NodeWithConnections, SearchResult, NodeKind, EdgeType } from './types';
import type { CortexNode, CortexEdge, GraphData, NodeWithConnections, SearchResult, NodeKind, EdgeType, GroupedQueryResult } from './types';
const BASE = '/api';
@@ -41,4 +41,13 @@ export const api = {
search: (text: string, options?: Record<string, any>) =>
request<SearchResult[]>('/search', { method: 'POST', body: JSON.stringify({ text, options }) }),
queryOrganized: (text: string) =>
request<GroupedQueryResult>('/query/organize', { method: 'POST', body: JSON.stringify({ text }) }),
getMaintenanceStatus: () =>
request<Record<string, any>>('/maintenance/status'),
runMaintenance: () =>
request<Record<string, any>>('/maintenance/run', { method: 'POST' }),
};

View File

@@ -18,3 +18,12 @@
.animate-slide-in-right {
animation: slide-in-right 0.2s ease-out;
}
@keyframes slide-in-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.animate-slide-in-up {
animation: slide-in-up 0.2s ease-out;
}

View File

@@ -0,0 +1,103 @@
import { useState, useEffect } from 'react';
import { api } from '../api';
interface Report {
ranAt: number;
deduped: number;
autoTagged: number;
autoOrganized: number;
pruned: number;
summarized?: number;
merged?: number;
split?: number;
archived?: number;
aiAvailable?: boolean;
skipped: boolean;
message?: string;
}
export default function MaintenancePanel({ onClose, onNotify }: { onClose: () => void; onNotify: (msg: string) => void }) {
const [report, setReport] = useState<Report | null>(null);
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.getMaintenanceStatus().then(r => setReport(r as any)).catch(() => {});
}, []);
const run = async () => {
setRunning(true);
setError(null);
try {
const r = await api.runMaintenance();
setReport(r as Report);
onNotify('Maintenance complete');
} catch (e: any) {
setError(e.message);
} finally {
setRunning(false);
}
};
const stats: { label: string; key: keyof Report }[] = [
{ label: 'Deduped', key: 'deduped' },
{ label: 'Auto-tagged', key: 'autoTagged' },
{ label: 'Organized', key: 'autoOrganized' },
{ label: 'Pruned', key: 'pruned' },
{ label: 'Summarized', key: 'summarized' },
{ label: 'Merged', key: 'merged' },
{ label: 'Split', key: 'split' },
{ label: 'Archived', key: 'archived' },
];
return (
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 w-80 max-w-[90vw] shadow-2xl text-gray-200">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-400">Maintenance</h2>
<button onClick={onClose} className="text-gray-500 hover:text-white text-lg leading-none">&times;</button>
</div>
{report && !report.message && (
<div className="mb-4 space-y-1 text-xs">
<div className="flex items-center gap-2 mb-2">
<span className={`inline-block w-2 h-2 rounded-full ${report.aiAvailable ? 'bg-green-400' : 'bg-yellow-500'}`} />
<span className="text-gray-400">{report.aiAvailable ? 'AI available (Ollama)' : 'AI unavailable — cosine fallback'}</span>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
{stats.map(s => {
const val = report[s.key];
if (val === undefined) return null;
return (
<div key={s.key} className="flex justify-between">
<span className="text-gray-400">{s.label}</span>
<span className="font-mono text-gray-200">{val as number}</span>
</div>
);
})}
</div>
<div className="text-gray-500 mt-2">
{report.skipped ? 'Skipped (no changes)' : `Ran ${new Date(report.ranAt).toLocaleTimeString()}`}
</div>
</div>
)}
{report?.message && (
<p className="text-xs text-gray-500 mb-4">{report.message}</p>
)}
{!report && !error && (
<p className="text-xs text-gray-500 mb-4">No maintenance has run yet.</p>
)}
{error && <p className="text-xs text-red-400 mb-4">{error}</p>}
<button
onClick={run}
disabled={running}
className="w-full py-2 rounded-lg text-sm font-medium bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-wait transition"
>
{running ? 'Running...' : 'Run Maintenance'}
</button>
</div>
);
}

View File

@@ -0,0 +1,109 @@
import { useState } from 'react';
import { api } from '../api';
import type { GroupedQueryResult } from '../types';
interface Props {
onSelectNode: (id: string) => void;
onClose: () => void;
}
export default function QueryBar({ onSelectNode, onClose }: Props) {
const [text, setText] = useState('');
const [result, setResult] = useState<GroupedQueryResult | null>(null);
const [loading, setLoading] = useState(false);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const submit = async () => {
if (!text.trim()) return;
setLoading(true);
try {
const data = await api.queryOrganized(text);
setResult(data);
setExpanded(new Set(data.groups.map(g => g.label)));
} catch {
setResult(null);
} finally {
setLoading(false);
}
};
const toggle = (label: string) => {
setExpanded(prev => {
const next = new Set(prev);
next.has(label) ? next.delete(label) : next.add(label);
return next;
});
};
return (
<div className="h-full flex flex-col bg-gray-900/95 backdrop-blur border-t border-purple-500/30 rounded-t-xl">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700/50">
<span className="text-purple-300 font-medium text-sm">Query Memory</span>
<button onClick={onClose} className="text-gray-500 hover:text-gray-300 text-lg">&times;</button>
</div>
{/* Input */}
<div className="px-4 py-3 flex gap-2">
<input
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={e => e.key === 'Enter' && submit()}
placeholder='e.g. "group all git commands by tag"'
className="flex-1 bg-gray-800 border border-gray-600 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-purple-500"
autoFocus
/>
<button
onClick={submit}
disabled={loading}
className="px-4 py-2 bg-purple-600 hover:bg-purple-500 disabled:opacity-50 rounded-lg text-sm text-white font-medium"
>
{loading ? '...' : 'Search'}
</button>
</div>
{/* Results */}
<div className="flex-1 overflow-y-auto px-4 pb-4">
{!result && !loading && (
<div className="text-gray-500 text-sm mt-4 space-y-1">
<p>Example queries:</p>
<p className="text-gray-600 ml-2">"all decisions about architecture"</p>
<p className="text-gray-600 ml-2">"group tasks by tag"</p>
<p className="text-gray-600 ml-2">"show components under parent"</p>
</div>
)}
{result && (
<div className="space-y-2">
<p className="text-xs text-gray-500">{result.totalResults} results &middot; grouped by {result.strategy}</p>
{result.groups.map(group => (
<div key={group.label} className="border border-gray-700/50 rounded-lg overflow-hidden">
<button
onClick={() => toggle(group.label)}
className="w-full flex items-center justify-between px-3 py-2 bg-gray-800/50 hover:bg-gray-800 text-sm text-gray-300"
>
<span className="font-medium">{group.label} ({group.items.length})</span>
<span className="text-xs">{expanded.has(group.label) ? '▼' : '▶'}</span>
</button>
{expanded.has(group.label) && (
<div className="divide-y divide-gray-800">
{group.items.map(item => (
<button
key={item.node.id}
onClick={() => onSelectNode(item.node.id)}
className="w-full text-left px-3 py-2 hover:bg-gray-800/50 flex items-center gap-2"
>
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-700 text-gray-400">{item.node.kind}</span>
<span className="text-sm text-gray-200 truncate">{item.node.title}</span>
<span className="text-xs text-gray-600 ml-auto">{(item.score * 100).toFixed(0)}%</span>
</button>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -43,3 +43,14 @@ export interface SearchResult {
node: CortexNode;
score: number;
}
export interface ResultGroup {
label: string;
items: { node: { id: string; kind: string; title: string; content: string; status?: string; tags: string[] }; score: number }[];
}
export interface GroupedQueryResult {
strategy: 'tag' | 'kind' | 'parent' | 'flat';
groups: ResultGroup[];
totalResults: number;
}