feat(dashboard): extract inline script to standalone dashboard.ts/js, add UI build/check recipes, and add route unit tests

This commit is contained in:
Riz Ashraf committed 2026-10-05 12:48:36 +01:00
1 parent 4b307b55b9
commit 19f9c7ace7
24 files changed
+2343 -1819

No files matched your search

+1 -1
View File
@@ -30,7 +30,7 @@ Invoke these dynamically using the `invoke_subagent` tool. Use `send_message` to
* **Role:** Project & Task Orchestrator * **Role:** Project & Task Orchestrator
* **Permissions:** You MUST define this subagent with `enable_mcp_tools: true` and `enable_write_tools: true`. * **Permissions:** You MUST define this subagent with `enable_mcp_tools: true` and `enable_write_tools: true`.
* **Trigger:** When we start a new feature or finish a task. * **Trigger:** When we start a new feature or finish a task.
* **Action:** Passively monitors the `memory://tasks/active` and `memory://milestones` MCP resources. Creates tasks, organizes milestones, and updates statuses when criteria are met using `add_task`, `update_task_status`, `add_milestone`, and `verify_acceptance_criteria`. * **Action:** Passively monitors the `memory://tasks/active` and `memory://milestones` MCP resources. Creates tasks, organizes milestones, and updates statuses when criteria are met using `tasks` and `milestones`.
## 5. DevOpsSRE ## 5. DevOpsSRE
* **Role:** Environment & Handoff Manager * **Role:** Environment & Handoff Manager
+2 -2
View File
@@ -12,5 +12,5 @@ description: Strict constraints for Model Context Protocol stdio transport and p
- **Rule:** ALWAYS use the project's native `justfile` targets (e.g., `just build-wsl`, `just deploy-stub-wsl`, `just deploy-all`) to manage building and deploying binaries across Windows and WSL boundaries. - **Rule:** ALWAYS use the project's native `justfile` targets (e.g., `just build-wsl`, `just deploy-stub-wsl`, `just deploy-all`) to manage building and deploying binaries across Windows and WSL boundaries.
# 3. Clipboard & Vision Tooling (Strict MCP Enforcement) # 3. Clipboard & Vision Tooling (Strict MCP Enforcement)
- **Rule (CRITICAL):** You MUST ALWAYS execute the `read_clipboard` MCP tool (`mcp-memory`) when reading or inspecting text or images from the OS clipboard. - **Rule (CRITICAL):** You MUST ALWAYS execute the `clipboard` MCP tool with action="read" (`mcp-memory`) when reading or inspecting text or images from the OS clipboard.
- **Rule:** NEVER run ad-hoc PowerShell (`System.Windows.Forms.Clipboard`), bash, or shell scripts to extract clipboard images or saved files. `read_clipboard` handles native clipboard image extraction, auto-resizing, and compressed JPEG (`.jpg`) encoding to minimize LLM token usage. - **Rule:** NEVER run ad-hoc PowerShell (`System.Windows.Forms.Clipboard`), bash, or shell scripts to extract clipboard images or saved files. `clipboard` (read) handles native clipboard image extraction, auto-resizing, and compressed JPEG (`.jpg`) encoding to minimize LLM token usage.
+1 -1
View File
@@ -2,6 +2,6 @@
**CRITICAL RULE:** Under NO CIRCUMSTANCES should any agent or subagent directly manipulate, edit, or write raw JSON/data to the MCP Memory Graph files (e.g., `knowledge_graph_master.json`, `.db` files, or any files inside `~/.gemini/mcp_memory/`). **CRITICAL RULE:** Under NO CIRCUMSTANCES should any agent or subagent directly manipulate, edit, or write raw JSON/data to the MCP Memory Graph files (e.g., `knowledge_graph_master.json`, `.db` files, or any files inside `~/.gemini/mcp_memory/`).
All interactions, additions, creations, and updates to the knowledge graph or task tracker MUST go through the officially exposed MCP tools (such as `add_task`, `set_acceptance_criteria`, `verify_acceptance_criteria`, `log_code_change`, `create_entities`, etc.). All interactions, additions, creations, and updates to the knowledge graph or task tracker MUST go through the officially exposed MCP tools (such as `tasks` [add, set_criteria, verify], `log_code_change`, `create_entities`, etc.).
Bypassing the MCP API by using file-editing tools (like `replace_file_content` or `write_to_file`) on the database files corrupts the state, bypasses indexing, and destroys the Redb Write-Ahead Log (WAL). If an agent attempts to do this, immediately stop them and route the request correctly through the provided MCP API tools. Bypassing the MCP API by using file-editing tools (like `replace_file_content` or `write_to_file`) on the database files corrupts the state, bypasses indexing, and destroys the Redb Write-Ahead Log (WAL). If an agent attempts to do this, immediately stop them and route the request correctly through the provided MCP API tools.
+20 -5
View File
@@ -16,7 +16,7 @@ deploy-all: deploy-win deploy-wsl
@Write-Host "Deployment complete across both OS boundaries." -ForegroundColor Green @Write-Host "Deployment complete across both OS boundaries." -ForegroundColor Green
# Build Windows-native binaries # Build Windows-native binaries
build-win: build-server build-stub-win build-nvim-win build-win: build-ui build-server build-stub-win build-nvim-win
# Deploy Windows-native binaries and rules # Deploy Windows-native binaries and rules
deploy-win: deploy-server deploy-stub-win deploy-nvim-win deploy-rules-win deploy-win: deploy-server deploy-stub-win deploy-nvim-win deploy-rules-win
@@ -57,8 +57,18 @@ run-server:
# 2. BUILD (Compile Binaries) # 2. BUILD (Compile Binaries)
# ========================================================= # =========================================================
# Build Windows-native server binary in release mode # Transpile Dashboard TypeScript (server/src/dashboard.ts) into JavaScript
build-server: build-ui:
@Write-Host "Building Dashboard JavaScript from server/src/dashboard.ts..." -ForegroundColor Cyan
bun build server/src/dashboard.ts --outfile=server/src/dashboard.js --target=browser
# Type-check Dashboard TypeScript source code
check-ui:
@Write-Host "Type-checking Dashboard TypeScript..." -ForegroundColor Cyan
deno check server/src/dashboard.ts
# Build Windows-native server binary in release mode (ensuring fresh UI build)
build-server: build-ui
@Write-Host "Building Windows Server..." -ForegroundColor Cyan @Write-Host "Building Windows Server..." -ForegroundColor Cyan
cargo build --release -p mcp-memory-server cargo build --release -p mcp-memory-server
@@ -138,11 +148,16 @@ deploy-rules-wsl:
# 4. TESTING & COVERAGE # 4. TESTING & COVERAGE
# ========================================================= # =========================================================
# Run fast parallel unit tests via cargo-nextest # Run fast parallel unit tests via cargo-nextest (and type-check UI)
test: test: check-ui
@Write-Host "Running fast parallel tests via cargo-nextest..." -ForegroundColor Cyan @Write-Host "Running fast parallel tests via cargo-nextest..." -ForegroundColor Cyan
cargo nextest run --workspace cargo nextest run --workspace
# Run dashboard UI endpoint and integrity unit tests
test-ui:
@Write-Host "Running dashboard UI endpoint and integrity unit tests..." -ForegroundColor Cyan
cargo test -p mcp-memory-server --lib api::setup::tests::test_dashboard_endpoint_and_html_integrity -- --nocapture
# Run standard sequential cargo unit tests # Run standard sequential cargo unit tests
test-cargo: test-cargo:
@Write-Host "Running standard unit tests across workspace..." -ForegroundColor Cyan @Write-Host "Running standard unit tests across workspace..." -ForegroundColor Cyan
-13
View File
@@ -385,19 +385,6 @@ async fn get_nvim_connection() -> Result<mpsc::Sender<NvimRequest>, String> {
} }
}); });
// Cleanup task for timed-out requests
let pending_clone3 = Arc::clone(&pending_requests);
tokio::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(10));
loop {
interval.tick().await;
if Arc::strong_count(&pending_clone3) <= 1 {
break; // Socket closed and other tasks finished, no need to keep cleaning up
}
pending_clone3.retain(|_, sender| !sender.is_closed());
}
});
let mut conn_lock = NVIM_CONN.lock().unwrap_or_else(|e| e.into_inner()); let mut conn_lock = NVIM_CONN.lock().unwrap_or_else(|e| e.into_inner());
if let Some(existing_sender) = conn_lock.as_ref() if let Some(existing_sender) = conn_lock.as_ref()
&& !existing_sender.is_closed() && !existing_sender.is_closed()
+38
View File
@@ -161,6 +161,12 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
"/", "/",
get(|| async move { axum::response::Html(include_str!("../dashboard.html")) }), get(|| async move { axum::response::Html(include_str!("../dashboard.html")) }),
) )
.route(
"/dashboard.js",
get(|| async move {
([(axum::http::header::CONTENT_TYPE, "application/javascript")], include_str!("../dashboard.js"))
}),
)
.route( .route(
"/api/graph", "/api/graph",
get({ get({
@@ -675,6 +681,38 @@ mod tests {
let shut_resp = app_shut.oneshot(shut_req).await.unwrap(); let shut_resp = app_shut.oneshot(shut_req).await.unwrap();
assert_eq!(shut_resp.status(), StatusCode::UNAUTHORIZED); assert_eq!(shut_resp.status(), StatusCode::UNAUTHORIZED);
} }
#[tokio::test]
async fn test_dashboard_endpoint_and_html_integrity() {
let (_app, app_state, _dir) = setup_app().await;
// Test GET / (dashboard.html)
let app_html = create_router(app_state.clone());
let req_html = Request::builder().uri("/").body(Body::empty()).unwrap();
let resp_html = app_html.oneshot(req_html).await.unwrap();
assert_eq!(resp_html.status(), StatusCode::OK);
let content_type = resp_html.headers().get(axum::http::header::CONTENT_TYPE).unwrap().to_str().unwrap();
assert!(content_type.contains("text/html"));
let body_bytes = axum::body::to_bytes(resp_html.into_body(), usize::MAX).await.unwrap();
let html_str = String::from_utf8(body_bytes.to_vec()).unwrap();
assert!(html_str.contains("<script src=\"/dashboard.js\"></script>"));
// Test GET /dashboard.js
let app_js = create_router(app_state.clone());
let req_js = Request::builder().uri("/dashboard.js").body(Body::empty()).unwrap();
let resp_js = app_js.oneshot(req_js).await.unwrap();
assert_eq!(resp_js.status(), StatusCode::OK);
let content_type_js = resp_js.headers().get(axum::http::header::CONTENT_TYPE).unwrap().to_str().unwrap();
assert!(content_type_js.contains("application/javascript"));
let js_bytes = axum::body::to_bytes(resp_js.into_body(), usize::MAX).await.unwrap();
let js_str = String::from_utf8(js_bytes.to_vec()).unwrap();
assert!(js_str.contains("escapeHtml"));
assert!(js_str.contains("setupWS"));
assert!(js_str.contains("refreshActiveTab"));
assert!(js_str.contains("parseActivityPayload"));
}
} }
+113
View File
@@ -54,6 +54,40 @@ pub async fn handle_socket(socket: WebSocket, state: Arc<AppState>, _client_type
.unwrap_or_else(|e| e.into_inner()) .unwrap_or_else(|e| e.into_inner())
.insert(session_id.clone(), tx.clone()); .insert(session_id.clone(), tx.clone());
// Reconnection Catch-Up: Replay recent TASK_EVENT notifications so client receives missed Futures
let recent_task_notifications: Vec<String> = state
.handler
.state
.telemetry
.recent_activities
.read_with(|activities| {
activities
.iter()
.filter_map(|act_val| {
if act_val["category"] == "TASK_EVENT" {
if let Some(details_str) = act_val["details"].as_str() {
if let Ok(event_val) = serde_json::from_str::<serde_json::Value>(details_str) {
return Some(
serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/task/completed",
"params": event_val
})
.to_string(),
);
}
}
}
None
})
.take(5)
.collect()
});
for notif in recent_task_notifications.into_iter().rev() {
let _ = tx.try_send(notif);
}
let (mut sender, mut receiver) = socket.split(); let (mut sender, mut receiver) = socket.split();
let send_task = tokio::spawn(async move { let send_task = tokio::spawn(async move {
@@ -185,4 +219,83 @@ mod tests {
assert!(app_state.clients.read().unwrap().is_empty()); assert!(app_state.clients.read().unwrap().is_empty());
} }
#[tokio::test]
async fn test_task_event_broadcast_and_reconnection_replay() {
let dir = tempdir().unwrap();
let mem_state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let task_event = crate::models::TaskEvent {
task_id: "task-999".to_string(),
status: "completed".to_string(),
action: Some("update".to_string()),
result: Some(serde_json::json!({"status": "completed"})),
error: None,
timestamp: 1728129000,
session_id: None,
};
// Broadcast task event
mem_state.broadcast_task_event(task_event.clone());
// Verify recent activities recorded the event
let recorded = mem_state.telemetry.recent_activities.read_with(|act| act.clone());
assert!(!recorded.is_empty());
assert_eq!(recorded[0]["category"], "TASK_EVENT");
// Verify reconnection catch-up replay fetches the notification
let (shutdown_tx, _) = tokio::sync::oneshot::channel();
let app_state = Arc::new(AppState {
handler: Arc::new(MemoryHandler::new(mem_state)),
clients: RwLock::new(HashMap::new()),
next_id: AtomicUsize::new(1),
shutdown_tx: std::sync::Mutex::new(Some(shutdown_tx)),
});
let (tx, mut rx) = mpsc::channel::<String>(10);
app_state
.clients
.write()
.unwrap()
.insert("session-1".to_string(), tx.clone());
let recent_notifications: Vec<String> = app_state
.handler
.state
.telemetry
.recent_activities
.read_with(|activities| {
activities
.iter()
.filter_map(|act_val| {
if act_val["category"] == "TASK_EVENT" {
if let Some(details_str) = act_val["details"].as_str() {
if let Ok(event_val) = serde_json::from_str::<serde_json::Value>(details_str) {
return Some(
serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/task/completed",
"params": event_val
})
.to_string(),
);
}
}
}
None
})
.take(5)
.collect()
});
for notif in recent_notifications {
let _ = tx.try_send(notif);
}
let replayed_msg = rx.recv().await.expect("Expected replayed task event notification");
let parsed: serde_json::Value = serde_json::from_str(&replayed_msg).unwrap();
assert_eq!(parsed["method"], "notifications/task/completed");
assert_eq!(parsed["params"]["task_id"], "task-999");
assert_eq!(parsed["params"]["status"], "completed");
}
} }
+4 -2
View File
@@ -7,17 +7,18 @@ pub fn spawn_watcher(state: Arc<MemoryState>) {
tokio::spawn(async move { tokio::spawn(async move {
let mut last_text = String::new(); let mut last_text = String::new();
loop { loop {
sleep(Duration::from_millis(1000)).await;
let is_enabled = { let is_enabled = {
let watch = state.clipboard_watch_mode.read().await; let watch = state.clipboard_watch_mode.read().await;
*watch *watch
}; };
if !is_enabled { if !is_enabled {
state.clipboard_notify.notified().await;
continue; continue;
} }
sleep(Duration::from_millis(1000)).await;
if let Ok(mut clipboard) = Clipboard::new() if let Ok(mut clipboard) = Clipboard::new()
&& let Ok(text) = clipboard.get_text() && let Ok(text) = clipboard.get_text()
&& text != last_text && text != last_text
@@ -69,6 +70,7 @@ mod tests {
// Enable watch mode // Enable watch mode
*state.clipboard_watch_mode.write().await = true; *state.clipboard_watch_mode.write().await = true;
state.clipboard_notify.notify_waiters();
tokio::time::sleep(Duration::from_millis(150)).await; tokio::time::sleep(Duration::from_millis(150)).await;
} }
} }
+1 -863
View File
@@ -631,868 +631,6 @@
</div> </div>
</div> </div>
<script> <script src="/dashboard.js"></script>
// --- Tabs ---
let currentTabId = 'graph-tab';
function switchTab(tabId, btn) {
currentTabId = tabId;
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.tab-button').forEach(el => el.classList.remove('active'));
document.getElementById(tabId).classList.add('active');
if (btn) btn.classList.add('active');
switch (tabId) {
case 'graph-tab':
if (network) {
network.redraw();
} else {
loadGraph();
}
break;
case 'activity-tab':
loadActivityHistory();
break;
case 'task-tab':
loadTasks();
break;
case 'preferences-tab':
loadPreferences();
break;
case 'sticky-tab':
loadStickyNotes();
break;
case 'techdebt-tab':
loadTechDebt();
break;
case 'adrs-tab':
loadADRs();
break;
case 'workspaces-tab':
loadWorkspaces();
break;
case 'pinned-tab':
loadPinned();
break;
case 'memos-tab':
loadMemos();
break;
case 'snippets-tab':
loadSnippets();
break;
case 'pr-tab':
loadPRs();
break;
case 'terminal-tab':
loadTerminal();
break;
}
}
// --- Theme Toggle ---
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
}
if (localStorage.getItem('theme') === 'dark' || (!localStorage.getItem('theme') && window.matchMatchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.setAttribute('data-theme', 'dark');
}
// --- Global Graph Data ---
let network = null;
let nodesData = new vis.DataSet();
let edgesData = new vis.DataSet();
let rawEntities = {};
let rawRelations = [];
let activeFilters = new Set();
let allTypes = new Set();
const typeColors = {
'Concept': { bg: '#9b59b6', border: '#8e44ad' },
'Technology': { bg: '#3498db', border: '#2980b9' },
'Infrastructure': { bg: '#e67e22', border: '#d35400' },
'Database_Table': { bg: '#f1c40f', border: '#f39c12' },
'Service': { bg: '#1abc9c', border: '#f39c12' },
'Tool': { bg: '#e74c3c', border: '#c0392b' },
'Integration': { bg: '#2ecc71', border: '#27ae60' },
'System': { bg: '#34495e', border: '#2c3e50' },
'Rule': { bg: '#fd79a8', border: '#e84393' }
};
function getColorForType(type) {
if (typeColors[type]) return typeColors[type];
// Hash string to color
let hash = 0;
for (let i = 0; i < type.length; i++) hash = type.charCodeAt(i) + ((hash << 5) - hash);
const hue = hash % 360;
return { background: `hsl(${hue}, 70%, 60%)`, border: `hsl(${hue}, 70%, 40%)` };
}
// --- Graph Controls ---
function zoomGraph(step) {
if (!network) return;
const currentScale = network.getScale();
network.moveTo({ scale: currentScale * (1 + step) });
}
function resetGraph() {
if (!network) return;
network.fit({ animation: { duration: 500, easingFunction: 'easeInOutQuad' } });
}
// --- Graph Inspector ---
function closeInspector() {
document.getElementById('inspector-panel').classList.remove('open');
if (network) network.unselectAll();
}
function showInspector(nodeId) {
const entity = rawEntities[nodeId];
if (!entity) return;
document.getElementById('inspector-title').innerText = entity.name;
document.getElementById('inspector-type').innerText = entity.entity_type;
document.getElementById('inspector-namespace').innerText = entity.namespace || 'global';
const obsHtml = (entity.observations || []).map(o => `<li>${o}</li>`).join('');
document.getElementById('inspector-observations').innerHTML = obsHtml || '<li><em>No observations recorded.</em></li>';
const rels = rawRelations.filter(r => r.from === nodeId || r.to === nodeId);
const relHtml = rels.map(r => {
if (r.from === nodeId) return `<li>→ <em>${r.relation_type}</em> → <strong>${r.to}</strong></li>`;
return `<li>← <em>${r.relation_type}</em> ← <strong>${r.from}</strong></li>`;
}).join('');
document.getElementById('inspector-relations').innerHTML = relHtml || '<li><em>No direct relations.</em></li>';
document.getElementById('inspector-panel').classList.add('open');
}
function toggleFilter(type) {
if (activeFilters.has(type)) activeFilters.delete(type);
else activeFilters.add(type);
renderFilters();
updateGraphData();
}
function renderFilters() {
const container = document.getElementById('graph-filters');
container.innerHTML = Array.from(allTypes).sort().map(type => {
const isActive = activeFilters.has(type);
const color = getColorForType(type).background;
return `<button class="filter-chip ${isActive ? 'active' : ''}" onclick="toggleFilter('${type}')">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${color};margin-right:4px;"></span>
${type}
</button>`;
}).join('');
}
function updateGraphData() {
const newNodes = [];
const newEdges = [];
const nodeIds = new Set();
for (const [name, entity] of Object.entries(rawEntities)) {
allTypes.add(entity.entity_type);
// If filters are active, only show matching types
if (activeFilters.size > 0 && !activeFilters.has(entity.entity_type)) continue;
const color = getColorForType(entity.entity_type);
newNodes.push({
id: name,
label: name,
title: `${name}\nType: ${entity.entity_type}`,
color: { background: color.bg || color.background, border: color.border },
font: { color: document.documentElement.getAttribute('data-theme') === 'dark' ? '#eee' : '#333' }
});
nodeIds.add(name);
}
rawRelations.forEach(r => {
if (nodeIds.has(r.from) && nodeIds.has(r.to)) {
newEdges.push({
id: r.from + '-' + r.to + '-' + r.relation_type,
from: r.from,
to: r.to,
label: r.relation_type,
arrows: 'to',
font: { color: document.documentElement.getAttribute('data-theme') === 'dark' ? '#aaa' : '#666', strokeWidth: 0 }
});
}
});
const currentNodes = nodesData.getIds();
nodesData.remove(currentNodes.filter(id => !nodeIds.has(id)));
nodesData.update(newNodes);
const currentEdges = edgesData.getIds();
const newEdgeIds = new Set(newEdges.map(e => e.id));
edgesData.remove(currentEdges.filter(id => !newEdgeIds.has(id)));
edgesData.update(newEdges);
renderFilters();
}
async function loadGraph() {
try {
const res = await fetch('/api/graph');
const data = await res.json();
rawEntities = data.entities || {};
rawRelations = data.relations || [];
updateGraphData();
if (!network) {
const container = document.getElementById('network-container');
const options = {
nodes: { shape: 'dot', size: 16, font: { size: 12 } },
edges: { color: { inherit: 'from', opacity: 0.6 }, font: { size: 10, align: 'middle' }, smooth: { type: 'continuous' } },
physics: { barnesHut: { gravitationalConstant: -2000, centralGravity: 0.3, springLength: 95 } },
interaction: { hover: true, tooltipDelay: 100, zoomView: false }
};
network = new vis.Network(container, { nodes: nodesData, edges: edgesData }, options);
container.addEventListener('wheel', function(event) {
event.preventDefault();
const direction = event.deltaY > 0 ? -0.15 : 0.15;
zoomGraph(direction);
}, { passive: false });
network.on("selectNode", function(params) {
if (params.nodes.length > 0) showInspector(params.nodes[0]);
});
network.on("deselectNode", function(params) {
if (params.nodes.length === 0) closeInspector();
});
}
} catch (err) {
console.error("Failed to load graph", err);
document.getElementById("network-container").innerHTML = '<div style="color:#e74c3c;padding:20px;font-weight:bold;">Error loading graph: ' + err.message + '</div>';
}
}
// --- Task Tree (HTN/DAG) ---
async function completeTask(id) {
try {
await fetch(`/api/tasks/${id}/complete`, { method: 'POST' });
loadTasks(); // Refresh UI instantly
} catch(e) { console.error("Failed to complete task", e); }
}
function buildTaskTreeHTML(tasks, parentId, depth = 0) {
let html = '';
const children = tasks.filter(t => {
const pid = t.parent_id;
if (!parentId) return !pid; // If looking for root, return tasks with no parent
return pid === parentId;
});
if (children.length === 0) return html;
children.forEach(t => {
const isCompleted = t.status === 'completed' || t.status === 'done';
const isCancelled = t.status === 'cancelled' || t.status === 'abandoned';
let cardClass = 'task-card';
if (isCompleted) cardClass += ' completed';
if (isCancelled) cardClass += ' cancelled';
// Find blockers
let isBlocked = false;
let blockers = [];
const deps = t.dependencies || [];
deps.forEach(depId => {
const depTask = tasks.find(dt => dt.id === depId);
if (depTask && depTask.status !== 'completed' && depTask.status !== 'done') {
isBlocked = true;
blockers.push(depTask.title);
}
});
// Child progress
const allChildren = tasks.filter(ct => (ct.parentId || ct.parent_id) === t.id);
const completedChildren = allChildren.filter(ct => ct.status === 'completed' || ct.status === 'done');
let progressHtml = '';
if (allChildren.length > 0) {
const pct = Math.round((completedChildren.length / allChildren.length) * 100);
progressHtml = `
<div style="margin-top:10px; background:#e1e8ed; border-radius:4px; height:8px; overflow:hidden;">
<div style="background:#3498db; width:${pct}%; height:100%; transition:width 0.3s;"></div>
</div>
<div style="font-size:0.8em; color:var(--text-secondary); text-align:right; margin-top:2px;">${pct}% (${completedChildren.length}/${allChildren.length} child tasks)</div>
`;
if (completedChildren.length < allChildren.length) {
isBlocked = true; // Implicitly blocked by children
}
}
html += `<div class="${cardClass}" style="margin-left: ${depth * 20}px; margin-bottom: 10px;">`;
if (isBlocked && !isCompleted && !isCancelled) {
html += `<div style="background:#e74c3c; color:white; font-size:0.75em; padding:2px 6px; border-radius:3px; display:inline-block; margin-bottom:6px; font-weight:bold;">[BLOCKED]</div>`;
if (blockers.length > 0) {
html += `<div style="font-size:0.8em; color:#e74c3c; margin-bottom:6px;">Waiting on: ${blockers.join(', ')}</div>`;
}
}
if (isCancelled) {
html += `<div style="background:#95a5a6; color:white; font-size:0.75em; padding:2px 6px; border-radius:3px; display:inline-block; margin-bottom:6px; font-weight:bold;">[CANCELLED]</div>`;
}
html += `<strong>${t.title}</strong>${t.description}`;
const criteria = t.acceptance_criteria || [];
if (criteria.length > 0) {
html += `<ul style="margin:8px 0 0 0; padding-left:20px; font-size: 0.9em; color: var(--text-secondary);">`;
let unmetCriteria = false;
criteria.forEach(c => {
const isMet = c.is_met;
if (!isMet) unmetCriteria = true;
const check = isMet ? '☑' : '☐';
const strike = isMet ? 'text-decoration: line-through;' : '';
html += `<li style="${strike}">${check} ${c.description}</li>`;
});
html += `</ul>`;
if (unmetCriteria && !isCompleted && !isCancelled) isBlocked = true;
}
html += progressHtml;
if (!isCompleted && !isCancelled && !isBlocked) {
html += `<button class="complete-btn" onclick="completeTask('${t.id}')" title="Mark Completed">✓</button>`;
}
// Recursively render children
if (allChildren.length > 0) {
html += `<div style="margin-top: 15px; border-left: 2px solid var(--border-color); padding-left: 10px;">`;
html += buildTaskTreeHTML(tasks, t.id, 0); // Reset depth since we use margin-left on wrapper
html += `</div>`;
}
html += `</div>`;
});
return html;
}
async function loadTasks() {
try {
const res = await fetch('/api/tasks');
const tasks = await res.json();
const taskContainer = document.getElementById('task-tree-container');
if (!taskContainer) return;
// Find root tasks (no parent)
const rootHtml = buildTaskTreeHTML(tasks, null, 0);
if (!rootHtml) {
taskContainer.innerHTML = '<div style="color:var(--text-secondary); padding:20px; text-align:center;">No active tasks.</div>';
} else {
taskContainer.innerHTML = rootHtml;
}
} catch (err) {
console.error("Failed to load tasks", err);
}
}
// --- Fuzzy-ish Search ---
function highlightText(text, query) {
if (!query) return text;
const regex = new RegExp(`(${query})`, 'gi');
return text.replace(regex, '<span class="search-highlight">$1</span>');
}
let searchDebounceTimer = null;
let activeSearchAbortController = null;
async function handleSearch(e) {
const q = e.target.value.trim();
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
if (!q) {
if (activeSearchAbortController) activeSearchAbortController.abort();
document.getElementById('search-results').innerHTML = '';
return;
}
const delay = (e.key === 'Enter') ? 0 : 250;
searchDebounceTimer = setTimeout(async () => {
if (activeSearchAbortController) activeSearchAbortController.abort();
activeSearchAbortController = new AbortController();
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
signal: activeSearchAbortController.signal
});
const data = await res.json();
const container = document.getElementById('search-results');
if (!data.results || data.results.length === 0) {
container.innerHTML = '<div style="padding:15px;color:var(--text-secondary);">No results found.</div>';
return;
}
container.innerHTML = data.results.map(r => `
<div class="search-result-item" onclick="if(rawEntities['${r.id}']){ showInspector('${r.id}'); network.selectNodes(['${r.id}']); network.focus('${r.id}', {scale:1.2, animation:true}); }" style="cursor:pointer;">
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
<div>
<strong>${highlightText(r.title, q)}</strong>
<span style="font-size: 0.7em; color: var(--text-secondary); background: var(--canvas-bg); padding: 2px 6px; border-radius: 4px; margin-left: 8px; text-transform: uppercase; border: 1px solid var(--border-color);">${r.type_name}</span>
</div>
<div style="font-size:0.75em; color:var(--text-secondary);">${r.score.toFixed(2)}</div>
</div>
<div style="margin-top:6px; font-size:0.9em; line-height:1.4;">${highlightText(r.content.substring(0, 150), q)}${r.content.length > 150 ? '...' : ''}</div>
<div style="font-size:0.75em; color:var(--text-secondary); margin-top:8px; font-family:monospace; background: var(--canvas-bg); padding: 2px 4px; border-radius: 3px; display: inline-block;">ID: ${r.id}</div>
</div>
`).join('');
} catch (err) {
if (err.name !== 'AbortError') {
console.error("Search error:", err);
}
}
}, delay);
}
// --- WebSocket Activity Feed ---
const MAX_ACTIVITY_HISTORY = 100;
function parseActivityPayload(item) {
let category = 'TOOL';
let summary = '';
let details = '';
let timestamp = Date.now();
if (typeof item === 'string') {
try {
const parsed = JSON.parse(item);
return parseActivityPayload(parsed);
} catch (e) {
summary = item;
}
} else if (typeof item === 'object' && item !== null) {
if (item.method === 'notifications/activity' && item.params) {
return parseActivityPayload(item.params);
}
category = (item.category || item.type || 'TOOL').toUpperCase();
summary = item.summary || item.message || item.description || item.data || '';
details = item.details || '';
timestamp = item.timestamp || item.time || item.updated_at || timestamp;
if (typeof timestamp === 'number' && timestamp < 10000000000) {
timestamp = timestamp * 1000;
}
}
const catUpper = category.toUpperCase();
const colorMap = {
'GRAPH': '#2ecc71',
'DECISION': '#f39c12',
'CODE': '#3498db',
'TASK': '#1abc9c',
'CLIPBOARD': '#9b59b6',
'STICKY_NOTE': '#e67e22',
'CHECKPOINT': '#e74c3c',
'ERROR_FIX': '#e74c3c',
'TECH_DEBT': '#d35400',
'SUBAGENT': '#8e44ad',
'SNIPPET': '#16a085',
'TOOL': '#3498db',
'SYSTEM': '#95a5a6'
};
const badgeColor = colorMap[catUpper] || '#3498db';
const dateObj = new Date(timestamp);
const validDate = isNaN(dateObj.getTime()) ? new Date() : dateObj;
const timeStr = validDate.toLocaleString([], {
month: '2-digit', day: '2-digit', hour: '2-digit', minute:'2-digit', second:'2-digit'
});
let html = `<span class="time">[${timeStr}]</span> <span style="background:var(--canvas-bg); color:${badgeColor}; padding:2px 6px; border-radius:3px; font-weight:bold; margin-right:6px; border:1px solid ${badgeColor}60; text-transform:uppercase; font-size:0.75em;">${escapeHtml(catUpper)}</span> ${escapeHtml(summary)}`;
if (details) {
html += `<div style="font-size:0.85em; color:var(--text-secondary); margin-left:20px; margin-top:2px;">${escapeHtml(details)}</div>`;
}
return html;
}
async function loadActivityHistory() {
try {
const response = await fetch('/api/activity');
const history = await response.json();
const feed = document.getElementById('activity-feed');
if (!feed) return;
feed.innerHTML = '';
// Helper to normalize timestamp to epoch milliseconds
const getMillis = (item) => {
if (!item) return 0;
if (typeof item === 'string') {
try { item = JSON.parse(item); } catch(e) { return 0; }
}
if (item.method === 'notifications/activity' && item.params) {
item = item.params;
}
let val = item.timestamp || item.time || item.updated_at || 0;
if (typeof val === 'number' && val < 10000000000) {
val = val * 1000;
}
return val;
};
// Sort history by normalized timestamp descending so newest entries are always at top
const sorted = [...history].sort((a, b) => getMillis(b) - getMillis(a));
sorted.forEach(item => {
const div = document.createElement('div');
div.className = 'feed-entry';
div.innerHTML = parseActivityPayload(item);
feed.appendChild(div);
});
if (sorted.length > 0) {
feed.scrollTop = 0;
}
} catch(e) {
console.error("Failed to load activity history", e);
}
}
function setupWS() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${location.host}/ws?client=ui`);
const feed = document.getElementById('activity-feed');
ws.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
if (data.type === 'activity') {
const isScrolledToTop = feed ? feed.scrollTop <= 20 : true;
const div = document.createElement('div');
div.className = 'feed-entry';
div.innerHTML = parseActivityPayload(data.data);
if (feed) {
feed.prepend(div);
while (feed.children.length > MAX_ACTIVITY_HISTORY) {
feed.removeChild(feed.lastChild);
}
if (isScrolledToTop) feed.scrollTop = 0;
}
}
} catch (e) {
console.error("WebSocket message parse error", e);
}
};
ws.onclose = function() {
console.log("WebSocket closed, attempting to reconnect in 3s...");
setTimeout(setupWS, 3000);
};
}
async function loadPreferences() {
try {
const res = await fetch('/api/preferences');
const data = await res.json();
const container = document.getElementById('preferences-container');
container.innerHTML = '';
if (!data || Object.keys(data).length === 0) {
container.innerHTML = '<div style="color:var(--text-secondary); text-align:center; padding:20px;">No global preferences found.</div>';
return;
}
for (const [key, pref] of Object.entries(data)) {
const date = new Date(pref.updated_at * 1000).toLocaleString();
container.innerHTML += `
<div style="background:var(--card-bg); border:1px solid var(--border-color); border-radius:6px; padding:15px; box-shadow:0 1px 3px rgba(0,0,0,0.1);">
<strong style="color:#3498db; display:block; margin-bottom:5px; font-size:1.1em;">${key}</strong>
<div style="margin-bottom:10px;">${pref.value}</div>
<div style="font-size:0.8em; color:var(--text-secondary); text-align:right;">Last Updated: ${date}</div>
</div>`;
}
} catch (e) {
console.error('Error loading preferences:', e);
}
}
async function loadStickyNotes() {
try {
const res = await fetch('/api/sticky');
const sticky = await res.json();
const container = document.getElementById('sticky-notes-container');
container.innerHTML = '';
sticky.forEach(note => {
const card = document.createElement('div');
card.className = 'sticky-note';
const date = new Date(note.timestamp * 1000).toLocaleString();
card.innerHTML = `<div style="font-size:0.75em; color:#7f8c8d; margin-bottom:8px;">${date}</div>
<div>${note.content}</div>`;
container.appendChild(card);
});
} catch (err) {
console.error("Failed to load sticky notes", err);
}
}
async function loadGenericList(endpoint, containerId, formatter) {
try {
const res = await fetch(endpoint);
const data = await res.json();
const container = document.getElementById(containerId);
if (!container) return;
if (!data || data.length === 0) {
container.innerHTML = '<div style="color:var(--text-secondary); padding:20px; text-align:center;">No items recorded.</div>';
return;
}
container.innerHTML = data.map(item => {
const date = item.timestamp ? new Date(item.timestamp * 1000).toLocaleString() : '';
return `<div class="task-card" style="border-left-color: #9b59b6;">
<div style="font-size:0.75em; color:var(--text-secondary); float:right;">${date}</div>
${formatter(item)}
</div>`;
}).join('');
} catch (err) {
console.error(`Failed to load ${endpoint}`, err);
}
}
function loadTerminal() {
loadGenericList('/api/terminal/history', 'terminal-container', item => `
<span style="font-weight:bold; font-size:1.05em; color:var(--text-primary);">${item.command}</span>
<span style="margin-left:15px; font-size:0.85em;"><span style="font-weight:bold; color:var(--text-secondary);">Exit Code:</span> <span style="color:${item.exit_code === 0 ? 'var(--success-color)' : 'var(--error-color)'}; font-weight:bold;">${item.exit_code}</span></span>
<div style="margin-top:10px; font-size:0.85em;">
<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; margin-right:8px; border:1px solid var(--border-color);">OS: ${item.os || 'Unknown'}</span>
<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; font-family:monospace; border:1px solid var(--border-color);">CWD: ${item.cwd || 'Unknown'}</span>
</div>
`);
}
function loadTechDebt() {
loadGenericList('/api/tech_debts', 'techdebt-container', item => `
<strong>${item.id}</strong> ${item.is_resolved ? '<span style="color:var(--success-color); font-weight:bold; background: rgba(46, 204, 113, 0.1); padding: 2px 6px; border-radius: 4px; border: 1px solid var(--success-color); margin-left: 8px;">(Resolved)</span>' : '<span style="color:var(--open-color); font-weight:bold; background: rgba(230, 126, 34, 0.1); padding: 2px 6px; border-radius: 4px; border: 1px solid var(--open-color); margin-left: 8px;">(Open)</span>'}
<div style="margin-top:5px;"><strong>Description:</strong> ${item.description}</div>
<div style="margin-top:5px;"><strong>Ideal Solution:</strong> ${item.ideal_solution}</div>
<div style="margin-top:10px;">
<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; font-size:0.8em; margin-right:8px; border:1px solid var(--border-color);">Git Commit: ${item.git_commit || 'None'}</span>
<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; font-size:0.8em; border:1px solid var(--border-color);">Branch: ${item.git_branch || 'None'}</span>
</div>
`);
loadGenericList('/api/error_fixes', 'errorfixes-container', item => `
<strong>${item.signature || 'Error'}</strong> <span style="color:var(--success-color); font-weight:bold; background: rgba(46, 204, 113, 0.1); padding: 2px 6px; border-radius: 4px; border: 1px solid var(--success-color); margin-left: 8px;">(Resolved)</span>
<div style="font-size:0.9em; margin-bottom:8px; margin-top:5px;"><em>Solution:</em> ${item.solution || ''}</div>
<div style="font-family:monospace; font-size:0.85em; color:#e74c3c;">Commit: ${item.git_commit || 'None'}</div>
`);
}
function loadADRs() {
loadGenericList('/api/adrs', 'adrs-container', item => `
<strong>${item.id} | ${item.title}</strong>
<span style="float:right; background:var(--canvas-bg); padding:2px 6px; border-radius:4px; font-size:0.8em; border:1px solid var(--border-color); ${item.status === 'superseded' ? 'text-decoration: line-through; color:var(--error-color);' : 'color:var(--success-color);'}">${item.status}</span>
<div style="font-size:0.9em; margin-bottom:8px; margin-top:8px;"><em>Context:</em> ${item.context || ''}</div>
<div style="font-size:0.9em; margin-bottom:8px;"><em>Decision:</em> ${item.decision || ''}</div>
<div style="font-size:0.9em; margin-bottom:8px;"><em>Consequence:</em> ${item.consequence || ''}</div>
${item.supersedes ? `<div style="font-size:0.9em; font-weight:bold; color:var(--warning-color);">Supersedes: ${item.supersedes}</div>` : ''}
`);
}
function loadWorkspaces() {
loadGenericList('/api/context_workspaces', 'workspaces-container', item => `
<strong>${item.name}</strong>
<div style="margin-bottom:8px;">${item.description || ''}</div>
<div style="font-family:monospace; font-size:0.85em; color:#3498db;">${(item.paths || []).join(', ')}</div>
`);
}
function loadPinned() {
loadGenericList('/api/pinned_files', 'pinned-container', item => `
<strong>${item.path || item.file_path || item.id}</strong>
<div style="margin-top:5px; font-size:0.85em; color:var(--text-secondary);">${item.reason || item.description || 'Pinned'}</div>
`);
}
function loadMemos() {
loadGenericList('/api/handoff_memos', 'memos-container', item => `
<strong>Memo from ${item.author || 'System'}</strong>
<div style="white-space:pre-wrap; font-size:0.9em; line-height:1.4;">${item.content || item.summary || ''}</div>
`);
loadGenericList('/api/milestones', 'milestones-container', item => `
<strong>${item.title || item.name}</strong>
<div style="margin-top:5px; font-size:0.85em;">${item.description || ''}</div>
`);
}
function loadSnippets() {
loadGenericList('/api/snippets', 'snippets-container', item => `
<strong>${item.description || 'Snippet'}</strong>
<div style="margin-bottom:8px; font-size:0.8em;">Language: ${item.language || 'txt'} | Tags: ${(item.tags || []).join(', ')}</div>
<pre style="background:#111417; color:#d1d8e0; padding:10px; border-radius:4px; overflow-x:auto; font-size:0.85em;">${item.content || item.code || ''}</pre>
`);
}
function loadPRs() {
loadGenericList('/api/pr_checklists', 'pr-container', item => `
<strong>${item.name || 'Checklist'}</strong>
<ul style="padding-left:20px; font-size:0.9em;">
${(item.items || []).map(i => {
const check = i.is_completed ? '☑' : '☐';
const strike = i.is_completed ? 'text-decoration:line-through; color:var(--text-secondary);' : '';
return `<li style="${strike}">${check} ${i.description}</li>`;
}).join('')}
</ul>
`);
}
function loadAllExtras() {
loadTerminal();
loadTechDebt();
loadADRs();
loadWorkspaces();
loadPinned();
loadMemos();
loadSnippets();
loadPRs();
}
async function testClipboard() {
const modal = document.getElementById('clipboard-modal');
const resultDiv = document.getElementById('clipboard-result');
modal.style.display = 'flex';
resultDiv.innerHTML = '<p id="clipboard-loading" style="color:var(--text-secondary);">Analyzing your clipboard...</p>';
try {
const res = await fetch('/api/clipboard/capture', { method: 'POST' });
const data = await res.json();
if (data.success) {
resultDiv.innerHTML = `<img src="${data.image}" style="max-width:100%; max-height:60vh; border-radius:4px; border:1px solid var(--border-color); box-shadow: 0 5px 15px rgba(0,0,0,0.3);" />`;
} else {
resultDiv.innerHTML = `<p style="color:var(--error-color); font-weight:bold;">Failed to read clipboard: ${data.error}</p>`;
}
} catch (err) {
resultDiv.innerHTML = `<p style="color:var(--error-color); font-weight:bold;">Error calling endpoint: ${err.message}</p>`;
}
}
async function loadVersion() {
try {
const res = await fetch('/api/version');
const data = await res.json();
const verEl = document.getElementById('app-version');
if (verEl) {
verEl.innerHTML = `<span style="background:var(--card-bg); border:1px solid var(--border-color); color:var(--text-secondary); padding:3px 10px; border-radius:12px; font-size:0.85em; font-weight:600; font-family:monospace;">v${data.version}</span>`;
}
} catch (err) {
console.error("Failed to load version", err);
}
}
function setupSSE() {
try {
const sse = new EventSource('/api/activity/stream');
sse.onmessage = function(event) {
if (event.data) {
try {
const feed = document.getElementById('activity-feed');
if (feed) {
const isScrolledToTop = feed.scrollTop <= 20;
const div = document.createElement('div');
div.className = 'feed-entry';
div.innerHTML = parseActivityPayload(event.data);
feed.prepend(div);
while (feed.children.length > MAX_ACTIVITY_HISTORY) {
feed.removeChild(feed.lastChild);
}
if (isScrolledToTop) feed.scrollTop = 0;
}
} catch(e) {}
}
};
} catch(e) { console.error('SSE initialization error', e); }
}
// Ctrl+K / Cmd+K Global Search shortcut
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
const searchTabBtn = document.querySelectorAll('.tab-button')[1];
if (searchTabBtn) {
switchTab('search-tab', searchTabBtn);
}
const searchInput = document.getElementById('search-box');
if (searchInput) {
searchInput.focus();
searchInput.select();
}
}
});
// --- Start ---
loadVersion();
loadGraph();
setupWS();
setupSSE();
// Listen to theme toggle changes to update graph font colors
const observer = new MutationObserver(() => updateGraphData());
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
// Smart background polling: refresh only the currently active tab
setInterval(() => {
switch (currentTabId) {
case 'graph-tab':
loadGraph();
break;
case 'task-tab':
loadTasks();
break;
case 'sticky-tab':
loadStickyNotes();
break;
case 'techdebt-tab':
loadTechDebt();
break;
case 'adrs-tab':
loadADRs();
break;
case 'workspaces-tab':
loadWorkspaces();
break;
case 'pinned-tab':
loadPinned();
break;
case 'memos-tab':
loadMemos();
break;
case 'snippets-tab':
loadSnippets();
break;
case 'pr-tab':
loadPRs();
break;
case 'terminal-tab':
loadTerminal();
break;
case 'preferences-tab':
loadPreferences();
break;
case 'activity-tab':
loadActivityHistory();
break;
}
}, 10000);
</script>
</body> </body>
</html> </html>
+706
View File
@@ -0,0 +1,706 @@
// server/src/dashboard.ts
var currentTabId = "graph-tab";
function switchTab(tabId, btn) {
currentTabId = tabId;
document.querySelectorAll(".tab-content").forEach((el) => el.classList.remove("active"));
document.querySelectorAll(".tab-button").forEach((el) => el.classList.remove("active"));
const targetTab = document.getElementById(tabId);
if (targetTab)
targetTab.classList.add("active");
if (btn)
btn.classList.add("active");
switch (tabId) {
case "graph-tab":
if (network) {
network.redraw();
} else {
loadGraph();
}
break;
case "activity-tab":
loadActivityHistory();
break;
case "task-tab":
loadTasks();
break;
case "preferences-tab":
loadPreferences();
break;
case "sticky-tab":
loadStickyNotes();
break;
case "techdebt-tab":
loadTechDebt();
break;
case "adrs-tab":
loadADRs();
break;
case "workspaces-tab":
loadWorkspaces();
break;
case "pinned-tab":
loadPinned();
break;
case "memos-tab":
loadMemos();
break;
case "snippets-tab":
loadSnippets();
break;
case "pr-tab":
loadPRs();
break;
case "terminal-tab":
loadTerminal();
break;
}
}
if (localStorage.getItem("theme") === "dark" || !localStorage.getItem("theme") && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
document.documentElement.setAttribute("data-theme", "dark");
}
var network = null;
var nodesData = new vis.DataSet;
var edgesData = new vis.DataSet;
var rawEntities = {};
var rawRelations = [];
var activeFilters = new Set;
var allTypes = new Set;
var typeColors = {
Concept: { bg: "#9b59b6", border: "#8e44ad" },
Technology: { bg: "#3498db", border: "#2980b9" },
Infrastructure: { bg: "#e67e22", border: "#d35400" },
Database_Table: { bg: "#f1c40f", border: "#f39c12" },
Service: { bg: "#1abc9c", border: "#f39c12" },
Tool: { bg: "#e74c3c", border: "#c0392b" },
Integration: { bg: "#2ecc71", border: "#27ae60" },
System: { bg: "#34495e", border: "#2c3e50" },
Rule: { bg: "#fd79a8", border: "#e84393" }
};
function getColorForType(type) {
if (typeColors[type])
return typeColors[type];
let hash = 0;
for (let i = 0;i < type.length; i++)
hash = type.charCodeAt(i) + ((hash << 5) - hash);
const hue = Math.abs(hash) % 360;
return { background: `hsl(${hue}, 70%, 60%)`, border: `hsl(${hue}, 70%, 40%)` };
}
function zoomGraph(step) {
if (!network)
return;
const currentScale = network.getScale();
network.moveTo({ scale: currentScale * (1 + step) });
}
function closeInspector() {
const inspector = document.getElementById("inspector-panel");
if (inspector)
inspector.classList.remove("open");
if (network)
network.unselectAll();
}
function showInspector(nodeId) {
const entity = rawEntities[nodeId];
if (!entity)
return;
const titleEl = document.getElementById("inspector-title");
if (titleEl)
titleEl.innerText = entity.name;
const typeEl = document.getElementById("inspector-type");
if (typeEl)
typeEl.innerText = entity.entity_type;
const nsEl = document.getElementById("inspector-namespace");
if (nsEl)
nsEl.innerText = entity.namespace || "global";
const obsHtml = (entity.observations || []).map((o) => `<li>${o}</li>`).join("");
const obsEl = document.getElementById("inspector-observations");
if (obsEl)
obsEl.innerHTML = obsHtml || "<li><em>No observations recorded.</em></li>";
const rels = rawRelations.filter((r) => r.from === nodeId || r.to === nodeId);
const relHtml = rels.map((r) => {
if (r.from === nodeId)
return `<li>→ <em>${r.relation_type}</em> → <strong>${r.to}</strong></li>`;
return `<li>← <em>${r.relation_type}</em> ← <strong>${r.from}</strong></li>`;
}).join("");
const relEl = document.getElementById("inspector-relations");
if (relEl)
relEl.innerHTML = relHtml || "<li><em>No direct relations.</em></li>";
const inspector = document.getElementById("inspector-panel");
if (inspector)
inspector.classList.add("open");
}
function renderFilters() {
const container = document.getElementById("graph-filters");
if (!container)
return;
container.innerHTML = Array.from(allTypes).sort().map((type) => {
const isActive = activeFilters.has(type);
const color = getColorForType(type).bg || getColorForType(type).background;
return `<button class="filter-chip ${isActive ? "active" : ""}" onclick="toggleFilter('${type}')">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${color};margin-right:4px;"></span>
${type}
</button>`;
}).join("");
}
function updateGraphData() {
const newNodes = [];
const newEdges = [];
const nodeIds = new Set;
for (const [name, entity] of Object.entries(rawEntities)) {
allTypes.add(entity.entity_type);
if (activeFilters.size > 0 && !activeFilters.has(entity.entity_type))
continue;
const color = getColorForType(entity.entity_type);
newNodes.push({
id: name,
label: name,
title: `${name}
Type: ${entity.entity_type}`,
color: { background: color.bg || color.background, border: color.border },
font: { color: document.documentElement.getAttribute("data-theme") === "dark" ? "#eee" : "#333" }
});
nodeIds.add(name);
}
rawRelations.forEach((r) => {
if (nodeIds.has(r.from) && nodeIds.has(r.to)) {
newEdges.push({
id: r.from + "-" + r.to + "-" + r.relation_type,
from: r.from,
to: r.to,
label: r.relation_type,
arrows: "to",
font: { color: document.documentElement.getAttribute("data-theme") === "dark" ? "#aaa" : "#666", strokeWidth: 0 }
});
}
});
const currentNodes = nodesData.getIds();
nodesData.remove(currentNodes.filter((id) => !nodeIds.has(String(id))));
nodesData.update(newNodes);
const currentEdges = edgesData.getIds();
const newEdgeIds = new Set(newEdges.map((e) => e.id));
edgesData.remove(currentEdges.filter((id) => !newEdgeIds.has(String(id))));
edgesData.update(newEdges);
renderFilters();
}
async function loadGraph() {
try {
const res = await fetch("/api/graph");
const data = await res.json();
rawEntities = data.entities || {};
rawRelations = data.relations || [];
updateGraphData();
if (!network) {
const container = document.getElementById("network-container");
if (!container)
return;
const options = {
nodes: { shape: "dot", size: 16, font: { size: 12 } },
edges: { color: { inherit: "from", opacity: 0.6 }, font: { size: 10, align: "middle" }, smooth: { type: "continuous" } },
physics: { barnesHut: { gravitationalConstant: -2000, centralGravity: 0.3, springLength: 95 } },
interaction: { hover: true, tooltipDelay: 100, zoomView: false }
};
network = new vis.Network(container, { nodes: nodesData, edges: edgesData }, options);
container.addEventListener("wheel", function(event) {
event.preventDefault();
const direction = event.deltaY > 0 ? -0.15 : 0.15;
zoomGraph(direction);
}, { passive: false });
network.on("selectNode", function(params) {
if (params.nodes.length > 0)
showInspector(params.nodes[0]);
});
network.on("deselectNode", function(params) {
if (params.nodes.length === 0)
closeInspector();
});
}
} catch (err) {
console.error("Failed to load graph", err);
const container = document.getElementById("network-container");
if (container) {
container.innerHTML = '<div style="color:#e74c3c;padding:20px;font-weight:bold;">Error loading graph: ' + err.message + "</div>";
}
}
}
function buildTaskTreeHTML(tasks, parentId, depth = 0) {
let html = "";
const children = tasks.filter((t) => {
const pid = t.parent_id || t.parentId;
if (!parentId)
return !pid;
return pid === parentId;
});
if (children.length === 0)
return html;
children.forEach((t) => {
const isCompleted = t.status === "completed" || t.status === "done";
const isCancelled = t.status === "cancelled" || t.status === "abandoned";
let cardClass = "task-card";
if (isCompleted)
cardClass += " completed";
if (isCancelled)
cardClass += " cancelled";
let isBlocked = false;
let blockers = [];
const deps = t.dependencies || [];
deps.forEach((depId) => {
const depTask = tasks.find((dt) => dt.id === depId);
if (depTask && depTask.status !== "completed" && depTask.status !== "done") {
isBlocked = true;
blockers.push(depTask.title);
}
});
const allChildren = tasks.filter((ct) => (ct.parentId || ct.parent_id) === t.id);
const completedChildren = allChildren.filter((ct) => ct.status === "completed" || ct.status === "done");
let progressHtml = "";
if (allChildren.length > 0) {
const pct = Math.round(completedChildren.length / allChildren.length * 100);
progressHtml = `
<div style="margin-top:10px; background:#e1e8ed; border-radius:4px; height:8px; overflow:hidden;">
<div style="background:#3498db; width:${pct}%; height:100%; transition:width 0.3s;"></div>
</div>
<div style="font-size:0.8em; color:var(--text-secondary); text-align:right; margin-top:2px;">${pct}% (${completedChildren.length}/${allChildren.length} child tasks)</div>
`;
if (completedChildren.length < allChildren.length) {
isBlocked = true;
}
}
html += `<div class="${cardClass}" style="margin-left: ${depth * 20}px; margin-bottom: 10px;">`;
if (isBlocked && !isCompleted && !isCancelled) {
html += `<div style="background:#e74c3c; color:white; font-size:0.75em; padding:2px 6px; border-radius:3px; display:inline-block; margin-bottom:6px; font-weight:bold;">[BLOCKED]</div>`;
if (blockers.length > 0) {
html += `<div style="font-size:0.8em; color:#e74c3c; margin-bottom:6px;">Waiting on: ${blockers.join(", ")}</div>`;
}
}
if (isCancelled) {
html += `<div style="background:#95a5a6; color:white; font-size:0.75em; padding:2px 6px; border-radius:3px; display:inline-block; margin-bottom:6px; font-weight:bold;">[CANCELLED]</div>`;
}
html += `<strong>${t.title}</strong>${t.description}`;
const criteria = t.acceptance_criteria || [];
if (criteria.length > 0) {
html += `<ul style="margin:8px 0 0 0; padding-left:20px; font-size: 0.9em; color: var(--text-secondary);">`;
let unmetCriteria = false;
criteria.forEach((c) => {
const isMet = c.is_met;
if (!isMet)
unmetCriteria = true;
const check = isMet ? "☑" : "☐";
const strike = isMet ? "text-decoration: line-through;" : "";
html += `<li style="${strike}">${check} ${c.description}</li>`;
});
html += `</ul>`;
if (unmetCriteria && !isCompleted && !isCancelled)
isBlocked = true;
}
html += progressHtml;
if (!isCompleted && !isCancelled && !isBlocked) {
html += `<button class="complete-btn" onclick="completeTask('${t.id}')" title="Mark Completed">✓</button>`;
}
if (allChildren.length > 0) {
html += `<div style="margin-top: 15px; border-left: 2px solid var(--border-color); padding-left: 10px;">`;
html += buildTaskTreeHTML(tasks, t.id, 0);
html += `</div>`;
}
html += `</div>`;
});
return html;
}
async function loadTasks() {
try {
const res = await fetch("/api/tasks");
const tasks = await res.json();
const taskContainer = document.getElementById("task-tree-container");
if (!taskContainer)
return;
const rootHtml = buildTaskTreeHTML(tasks, null, 0);
if (!rootHtml) {
taskContainer.innerHTML = '<div style="color:var(--text-secondary); padding:20px; text-align:center;">No active tasks.</div>';
} else {
taskContainer.innerHTML = rootHtml;
}
} catch (err) {
console.error("Failed to load tasks", err);
}
}
var MAX_ACTIVITY_HISTORY = 100;
function escapeHtml(str) {
if (str === null || str === undefined)
return "";
return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
}
function parseActivityPayload(item) {
let category = "TOOL";
let summary = "";
let details = "";
let timestamp = Date.now();
if (typeof item === "string") {
try {
const parsed = JSON.parse(item);
return parseActivityPayload(parsed);
} catch (e) {
summary = item;
}
} else if (typeof item === "object" && item !== null) {
if (item.method === "notifications/activity" && item.params) {
return parseActivityPayload(item.params);
}
category = (item.category || item.type || "TOOL").toUpperCase();
summary = item.summary || item.message || item.description || item.data || "";
details = item.details || "";
timestamp = item.timestamp || item.time || item.updated_at || timestamp;
if (typeof timestamp === "number" && timestamp < 10000000000) {
timestamp = timestamp * 1000;
}
}
const catUpper = category.toUpperCase();
const colorMap = {
GRAPH: "#2ecc71",
DECISION: "#f39c12",
CODE: "#3498db",
TASK: "#1abc9c",
CLIPBOARD: "#9b59b6",
STICKY_NOTE: "#e67e22",
CHECKPOINT: "#e74c3c",
ERROR_FIX: "#e74c3c",
TECH_DEBT: "#d35400",
SUBAGENT: "#8e44ad",
SNIPPET: "#16a085",
TOOL: "#3498db",
SYSTEM: "#95a5a6"
};
const badgeColor = colorMap[catUpper] || "#3498db";
const dateObj = new Date(timestamp);
const validDate = isNaN(dateObj.getTime()) ? new Date : dateObj;
const timeStr = validDate.toLocaleString([], {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
let html = `<span class="time">[${timeStr}]</span> <span style="background:var(--canvas-bg); color:${badgeColor}; padding:2px 6px; border-radius:3px; font-weight:bold; margin-right:6px; border:1px solid ${badgeColor}60; text-transform:uppercase; font-size:0.75em;">${escapeHtml(catUpper)}</span> ${escapeHtml(summary)}`;
if (details) {
html += `<div style="font-size:0.85em; color:var(--text-secondary); margin-left:20px; margin-top:2px;">${escapeHtml(details)}</div>`;
}
return html;
}
async function loadActivityHistory() {
try {
const response = await fetch("/api/activity");
const history = await response.json();
const feed = document.getElementById("activity-feed");
if (!feed)
return;
feed.innerHTML = "";
const getMillis = (item) => {
if (!item)
return 0;
if (typeof item === "string") {
try {
item = JSON.parse(item);
} catch (e) {
return 0;
}
}
if (item.method === "notifications/activity" && item.params) {
item = item.params;
}
let val = item.timestamp || item.time || item.updated_at || 0;
if (typeof val === "number" && val < 10000000000) {
val = val * 1000;
}
return val;
};
const sorted = [...history].sort((a, b) => getMillis(b) - getMillis(a));
sorted.forEach((item) => {
const div = document.createElement("div");
div.className = "feed-entry";
div.innerHTML = parseActivityPayload(item);
feed.appendChild(div);
});
if (sorted.length > 0) {
feed.scrollTop = 0;
}
} catch (e) {
console.error("Failed to load activity history", e);
}
}
function setupWS() {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${protocol}//${location.host}/ws?client=ui`);
const feed = document.getElementById("activity-feed");
ws.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
if (data.type === "activity" || data.method === "notifications/activity" || data.method === "notifications/task/completed" || data.method === "notifications/resources/updated") {
refreshActiveTab();
const isScrolledToTop = feed ? feed.scrollTop <= 20 : true;
const div = document.createElement("div");
div.className = "feed-entry";
const payload = data.params || data.data || data;
div.innerHTML = parseActivityPayload(payload);
if (feed) {
feed.prepend(div);
while (feed.children.length > MAX_ACTIVITY_HISTORY && feed.lastChild) {
feed.removeChild(feed.lastChild);
}
if (isScrolledToTop)
feed.scrollTop = 0;
}
}
} catch (e) {
console.error("WebSocket message parse error", e);
}
};
ws.onclose = function() {
console.log("WebSocket closed, attempting to reconnect in 3s...");
setTimeout(setupWS, 3000);
};
}
async function loadPreferences() {
try {
const res = await fetch("/api/preferences");
const data = await res.json();
const container = document.getElementById("preferences-container");
if (!container)
return;
container.innerHTML = "";
if (!data || Object.keys(data).length === 0) {
container.innerHTML = '<div style="color:var(--text-secondary); text-align:center; padding:20px;">No global preferences found.</div>';
return;
}
for (const [key, pref] of Object.entries(data)) {
const date = new Date(pref.updated_at * 1000).toLocaleString();
container.innerHTML += `
<div style="background:var(--card-bg); border:1px solid var(--border-color); border-radius:6px; padding:15px; box-shadow:0 1px 3px rgba(0,0,0,0.1);">
<strong style="color:#3498db; display:block; margin-bottom:5px; font-size:1.1em;">${key}</strong>
<div style="margin-bottom:10px;">${pref.value}</div>
<div style="font-size:0.8em; color:var(--text-secondary); text-align:right;">Last Updated: ${date}</div>
</div>`;
}
} catch (e) {
console.error("Error loading preferences:", e);
}
}
async function loadStickyNotes() {
try {
const res = await fetch("/api/sticky");
const sticky = await res.json();
const container = document.getElementById("sticky-notes-container");
if (!container)
return;
container.innerHTML = "";
sticky.forEach((note) => {
const card = document.createElement("div");
card.className = "sticky-note";
const date = new Date(note.timestamp * 1000).toLocaleString();
card.innerHTML = `<div style="font-size:0.75em; color:#7f8c8d; margin-bottom:8px;">${date}</div>
<div>${note.content}</div>`;
container.appendChild(card);
});
} catch (err) {
console.error("Failed to load sticky notes", err);
}
}
async function loadGenericList(endpoint, containerId, formatter) {
try {
const res = await fetch(endpoint);
const data = await res.json();
const container = document.getElementById(containerId);
if (!container)
return;
if (!data || data.length === 0) {
container.innerHTML = '<div style="color:var(--text-secondary); padding:20px; text-align:center;">No items recorded.</div>';
return;
}
container.innerHTML = data.map((item) => {
const date = item.timestamp ? new Date(item.timestamp * 1000).toLocaleString() : "";
return `<div class="task-card" style="border-left-color: #9b59b6;">
<div style="font-size:0.75em; color:var(--text-secondary); float:right;">${date}</div>
${formatter(item)}
</div>`;
}).join("");
} catch (err) {
console.error(`Failed to load ${endpoint}`, err);
}
}
function loadTerminal() {
loadGenericList("/api/terminal/history", "terminal-container", (item) => `
<span style="font-weight:bold; font-size:1.05em; color:var(--text-primary);">${item.command}</span>
<span style="margin-left:15px; font-size:0.85em;"><span style="font-weight:bold; color:var(--text-secondary);">Exit Code:</span> <span style="color:${item.exit_code === 0 ? "var(--success-color)" : "var(--error-color)"}; font-weight:bold;">${item.exit_code}</span></span>
<div style="margin-top:10px; font-size:0.85em;">
<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; margin-right:8px; border:1px solid var(--border-color);">OS: ${item.os || "Unknown"}</span>
<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; font-family:monospace; border:1px solid var(--border-color);">CWD: ${item.cwd || "Unknown"}</span>
</div>
`);
}
function loadTechDebt() {
loadGenericList("/api/tech_debts", "techdebt-container", (item) => `
<strong>${item.id}</strong> ${item.is_resolved ? '<span style="color:var(--success-color); font-weight:bold; background: rgba(46, 204, 113, 0.1); padding: 2px 6px; border-radius: 4px; border: 1px solid var(--success-color); margin-left: 8px;">(Resolved)</span>' : '<span style="color:var(--open-color); font-weight:bold; background: rgba(230, 126, 34, 0.1); padding: 2px 6px; border-radius: 4px; border: 1px solid var(--open-color); margin-left: 8px;">(Open)</span>'}
<div style="margin-top:5px;"><strong>Description:</strong> ${item.description}</div>
<div style="margin-top:5px;"><strong>Ideal Solution:</strong> ${item.ideal_solution}</div>
<div style="margin-top:10px;">
<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; font-size:0.8em; margin-right:8px; border:1px solid var(--border-color);">Git Commit: ${item.git_commit || "None"}</span>
<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; font-size:0.8em; border:1px solid var(--border-color);">Branch: ${item.git_branch || "None"}</span>
</div>
`);
loadGenericList("/api/error_fixes", "errorfixes-container", (item) => `
<strong>${item.signature || "Error"}</strong> <span style="color:var(--success-color); font-weight:bold; background: rgba(46, 204, 113, 0.1); padding: 2px 6px; border-radius: 4px; border: 1px solid var(--success-color); margin-left: 8px;">(Resolved)</span>
<div style="font-size:0.9em; margin-bottom:8px; margin-top:5px;"><em>Solution:</em> ${item.solution || ""}</div>
<div style="font-family:monospace; font-size:0.85em; color:#e74c3c;">Commit: ${item.git_commit || "None"}</div>
`);
}
function loadADRs() {
loadGenericList("/api/adrs", "adrs-container", (item) => `
<strong>${item.id} | ${item.title}</strong>
<span style="float:right; background:var(--canvas-bg); padding:2px 6px; border-radius:4px; font-size:0.8em; border:1px solid var(--border-color); ${item.status === "superseded" ? "text-decoration: line-through; color:var(--error-color);" : "color:var(--success-color);"}">${item.status}</span>
<div style="font-size:0.9em; margin-bottom:8px; margin-top:8px;"><em>Context:</em> ${item.context || ""}</div>
<div style="font-size:0.9em; margin-bottom:8px;"><em>Decision:</em> ${item.decision || ""}</div>
<div style="font-size:0.9em; margin-bottom:8px;"><em>Consequence:</em> ${item.consequence || ""}</div>
${item.supersedes ? `<div style="font-size:0.9em; font-weight:bold; color:var(--warning-color);">Supersedes: ${item.supersedes}</div>` : ""}
`);
}
function loadWorkspaces() {
loadGenericList("/api/context_workspaces", "workspaces-container", (item) => `
<strong>${item.name}</strong>
<div style="margin-bottom:8px;">${item.description || ""}</div>
<div style="font-family:monospace; font-size:0.85em; color:#3498db;">${(item.paths || []).join(", ")}</div>
`);
}
function loadPinned() {
loadGenericList("/api/pinned_files", "pinned-container", (item) => `
<strong>${item.path || item.file_path || item.id}</strong>
<div style="margin-top:5px; font-size:0.85em; color:var(--text-secondary);">${item.reason || item.description || "Pinned"}</div>
`);
}
function loadMemos() {
loadGenericList("/api/handoff_memos", "memos-container", (item) => `
<strong>Memo from ${item.author || "System"}</strong>
<div style="white-space:pre-wrap; font-size:0.9em; line-height:1.4;">${item.content || item.summary || ""}</div>
`);
loadGenericList("/api/milestones", "milestones-container", (item) => `
<strong>${item.title || item.name}</strong>
<div style="margin-top:5px; font-size:0.85em;">${item.description || ""}</div>
`);
}
function loadSnippets() {
loadGenericList("/api/snippets", "snippets-container", (item) => `
<strong>${item.description || "Snippet"}</strong>
<div style="margin-bottom:8px; font-size:0.8em;">Language: ${item.language || "txt"} | Tags: ${(item.tags || []).join(", ")}</div>
<pre style="background:#111417; color:#d1d8e0; padding:10px; border-radius:4px; overflow-x:auto; font-size:0.85em;">${item.content || item.code || ""}</pre>
`);
}
function loadPRs() {
loadGenericList("/api/pr_checklists", "pr-container", (item) => `
<strong>${item.name || "Checklist"}</strong>
<ul style="padding-left:20px; font-size:0.9em;">
${(item.items || []).map((i) => {
const check = i.is_completed ? "☑" : "☐";
const strike = i.is_completed ? "text-decoration:line-through; color:var(--text-secondary);" : "";
return `<li style="${strike}">${check} ${i.description}</li>`;
}).join("")}
</ul>
`);
}
async function loadVersion() {
try {
const res = await fetch("/api/version");
const data = await res.json();
const verEl = document.getElementById("app-version");
if (verEl) {
verEl.innerHTML = `<span style="background:var(--card-bg); border:1px solid var(--border-color); color:var(--text-secondary); padding:3px 10px; border-radius:12px; font-size:0.85em; font-weight:600; font-family:monospace;">v${data.version}</span>`;
}
} catch (err) {
console.error("Failed to load version", err);
}
}
function setupSSE() {
try {
const sse = new EventSource("/api/activity/stream");
sse.onmessage = function(event) {
if (event.data) {
try {
refreshActiveTab();
const feed = document.getElementById("activity-feed");
if (feed) {
const isScrolledToTop = feed.scrollTop <= 20;
const div = document.createElement("div");
div.className = "feed-entry";
div.innerHTML = parseActivityPayload(event.data);
feed.prepend(div);
while (feed.children.length > MAX_ACTIVITY_HISTORY && feed.lastChild) {
feed.removeChild(feed.lastChild);
}
if (isScrolledToTop)
feed.scrollTop = 0;
}
} catch (e) {}
}
};
} catch (e) {
console.error("SSE initialization error", e);
}
}
document.addEventListener("keydown", function(e) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
const searchTabBtn = document.querySelectorAll(".tab-button")[1];
if (searchTabBtn) {
switchTab("search-tab", searchTabBtn);
}
const searchInput = document.getElementById("search-box");
if (searchInput) {
searchInput.focus();
searchInput.select();
}
}
});
function refreshActiveTab() {
switch (currentTabId) {
case "graph-tab":
loadGraph();
break;
case "task-tab":
loadTasks();
break;
case "sticky-tab":
loadStickyNotes();
break;
case "techdebt-tab":
loadTechDebt();
break;
case "adrs-tab":
loadADRs();
break;
case "workspaces-tab":
loadWorkspaces();
break;
case "pinned-tab":
loadPinned();
break;
case "memos-tab":
loadMemos();
break;
case "snippets-tab":
loadSnippets();
break;
case "pr-tab":
loadPRs();
break;
case "terminal-tab":
loadTerminal();
break;
case "preferences-tab":
loadPreferences();
break;
case "activity-tab":
loadActivityHistory();
break;
}
}
loadVersion();
loadGraph();
loadActivityHistory();
setupWS();
setupSSE();
var observer = new MutationObserver(() => updateGraphData());
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
+934
View File
@@ -0,0 +1,934 @@
/// <reference lib="dom" />
/// <reference lib="dom.iterable" />
// --- Type Definitions for Dashboard ---
declare const vis: {
DataSet: new <T = any>(data?: T[]) => {
getIds(): (string | number)[];
remove(ids: (string | number)[]): void;
update(items: T[]): void;
};
Network: new (container: HTMLElement, data: { nodes: any; edges: any }, options: any) => {
getScale(): number;
moveTo(options: { scale?: number }): void;
fit(options?: { animation?: { duration: number; easingFunction: string } }): void;
unselectAll(): void;
selectNodes(nodeIds: string[]): void;
focus(nodeId: string, options?: { scale?: number; animation?: boolean }): void;
redraw(): void;
on(event: string, callback: (params: any) => void): void;
};
};
interface Entity {
name: string;
entity_type: string;
namespace?: string;
observations?: string[];
}
interface Relation {
from: string;
to: string;
relation_type: string;
}
interface TaskItem {
id: string;
title: string;
description: string;
status: string;
parent_id?: string;
parentId?: string;
dependencies?: string[];
acceptance_criteria?: { description: string; is_met: boolean }[];
}
interface ActivityItem {
category?: string;
type?: string;
summary?: string;
message?: string;
description?: string;
data?: any;
details?: string;
timestamp?: number;
time?: number;
updated_at?: number;
method?: string;
params?: any;
}
interface SearchResultItem {
id: string;
title: string;
type_name: string;
score: number;
content: string;
}
// --- Tabs ---
let currentTabId: string = 'graph-tab';
function switchTab(tabId: string, btn?: HTMLElement | null): void {
currentTabId = tabId;
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.tab-button').forEach(el => el.classList.remove('active'));
const targetTab = document.getElementById(tabId);
if (targetTab) targetTab.classList.add('active');
if (btn) btn.classList.add('active');
switch (tabId) {
case 'graph-tab':
if (network) {
network.redraw();
} else {
loadGraph();
}
break;
case 'activity-tab':
loadActivityHistory();
break;
case 'task-tab':
loadTasks();
break;
case 'preferences-tab':
loadPreferences();
break;
case 'sticky-tab':
loadStickyNotes();
break;
case 'techdebt-tab':
loadTechDebt();
break;
case 'adrs-tab':
loadADRs();
break;
case 'workspaces-tab':
loadWorkspaces();
break;
case 'pinned-tab':
loadPinned();
break;
case 'memos-tab':
loadMemos();
break;
case 'snippets-tab':
loadSnippets();
break;
case 'pr-tab':
loadPRs();
break;
case 'terminal-tab':
loadTerminal();
break;
}
}
// --- Theme Toggle ---
function toggleTheme(): void {
const currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
}
if (localStorage.getItem('theme') === 'dark' || (!localStorage.getItem('theme') && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.setAttribute('data-theme', 'dark');
}
// --- Global Graph Data ---
let network: any = null;
let nodesData = new vis.DataSet();
let edgesData = new vis.DataSet();
let rawEntities: Record<string, Entity> = {};
let rawRelations: Relation[] = [];
let activeFilters: Set<string> = new Set();
let allTypes: Set<string> = new Set();
const typeColors: Record<string, { bg: string; border: string }> = {
'Concept': { bg: '#9b59b6', border: '#8e44ad' },
'Technology': { bg: '#3498db', border: '#2980b9' },
'Infrastructure': { bg: '#e67e22', border: '#d35400' },
'Database_Table': { bg: '#f1c40f', border: '#f39c12' },
'Service': { bg: '#1abc9c', border: '#f39c12' },
'Tool': { bg: '#e74c3c', border: '#c0392b' },
'Integration': { bg: '#2ecc71', border: '#27ae60' },
'System': { bg: '#34495e', border: '#2c3e50' },
'Rule': { bg: '#fd79a8', border: '#e84393' }
};
function getColorForType(type: string): { bg?: string; border?: string; background?: string } {
if (typeColors[type]) return typeColors[type];
let hash = 0;
for (let i = 0; i < type.length; i++) hash = type.charCodeAt(i) + ((hash << 5) - hash);
const hue = Math.abs(hash) % 360;
return { background: `hsl(${hue}, 70%, 60%)`, border: `hsl(${hue}, 70%, 40%)` };
}
// --- Graph Controls ---
function zoomGraph(step: number): void {
if (!network) return;
const currentScale = network.getScale();
network.moveTo({ scale: currentScale * (1 + step) });
}
function resetGraph(): void {
if (!network) return;
network.fit({ animation: { duration: 500, easingFunction: 'easeInOutQuad' } });
}
// --- Graph Inspector ---
function closeInspector(): void {
const inspector = document.getElementById('inspector-panel');
if (inspector) inspector.classList.remove('open');
if (network) network.unselectAll();
}
function showInspector(nodeId: string): void {
const entity = rawEntities[nodeId];
if (!entity) return;
const titleEl = document.getElementById('inspector-title');
if (titleEl) titleEl.innerText = entity.name;
const typeEl = document.getElementById('inspector-type');
if (typeEl) typeEl.innerText = entity.entity_type;
const nsEl = document.getElementById('inspector-namespace');
if (nsEl) nsEl.innerText = entity.namespace || 'global';
const obsHtml = (entity.observations || []).map(o => `<li>${o}</li>`).join('');
const obsEl = document.getElementById('inspector-observations');
if (obsEl) obsEl.innerHTML = obsHtml || '<li><em>No observations recorded.</em></li>';
const rels = rawRelations.filter(r => r.from === nodeId || r.to === nodeId);
const relHtml = rels.map(r => {
if (r.from === nodeId) return `<li>→ <em>${r.relation_type}</em> → <strong>${r.to}</strong></li>`;
return `<li>← <em>${r.relation_type}</em> ← <strong>${r.from}</strong></li>`;
}).join('');
const relEl = document.getElementById('inspector-relations');
if (relEl) relEl.innerHTML = relHtml || '<li><em>No direct relations.</em></li>';
const inspector = document.getElementById('inspector-panel');
if (inspector) inspector.classList.add('open');
}
function toggleFilter(type: string): void {
if (activeFilters.has(type)) activeFilters.delete(type);
else activeFilters.add(type);
renderFilters();
updateGraphData();
}
function renderFilters(): void {
const container = document.getElementById('graph-filters');
if (!container) return;
container.innerHTML = Array.from(allTypes).sort().map(type => {
const isActive = activeFilters.has(type);
const color = getColorForType(type).bg || getColorForType(type).background;
return `<button class="filter-chip ${isActive ? 'active' : ''}" onclick="toggleFilter('${type}')">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${color};margin-right:4px;"></span>
${type}
</button>`;
}).join('');
}
function updateGraphData(): void {
const newNodes: any[] = [];
const newEdges: any[] = [];
const nodeIds = new Set<string>();
for (const [name, entity] of Object.entries(rawEntities)) {
allTypes.add(entity.entity_type);
if (activeFilters.size > 0 && !activeFilters.has(entity.entity_type)) continue;
const color = getColorForType(entity.entity_type);
newNodes.push({
id: name,
label: name,
title: `${name}\nType: ${entity.entity_type}`,
color: { background: color.bg || color.background, border: color.border },
font: { color: document.documentElement.getAttribute('data-theme') === 'dark' ? '#eee' : '#333' }
});
nodeIds.add(name);
}
rawRelations.forEach(r => {
if (nodeIds.has(r.from) && nodeIds.has(r.to)) {
newEdges.push({
id: r.from + '-' + r.to + '-' + r.relation_type,
from: r.from,
to: r.to,
label: r.relation_type,
arrows: 'to',
font: { color: document.documentElement.getAttribute('data-theme') === 'dark' ? '#aaa' : '#666', strokeWidth: 0 }
});
}
});
const currentNodes = nodesData.getIds();
nodesData.remove(currentNodes.filter(id => !nodeIds.has(String(id))));
nodesData.update(newNodes);
const currentEdges = edgesData.getIds();
const newEdgeIds = new Set(newEdges.map(e => e.id));
edgesData.remove(currentEdges.filter(id => !newEdgeIds.has(String(id))));
edgesData.update(newEdges);
renderFilters();
}
async function loadGraph(): Promise<void> {
try {
const res = await fetch('/api/graph');
const data = await res.json();
rawEntities = data.entities || {};
rawRelations = data.relations || [];
updateGraphData();
if (!network) {
const container = document.getElementById('network-container');
if (!container) return;
const options = {
nodes: { shape: 'dot', size: 16, font: { size: 12 } },
edges: { color: { inherit: 'from', opacity: 0.6 }, font: { size: 10, align: 'middle' }, smooth: { type: 'continuous' } },
physics: { barnesHut: { gravitationalConstant: -2000, centralGravity: 0.3, springLength: 95 } },
interaction: { hover: true, tooltipDelay: 100, zoomView: false }
};
network = new vis.Network(container, { nodes: nodesData, edges: edgesData }, options);
container.addEventListener('wheel', function(event: WheelEvent) {
event.preventDefault();
const direction = event.deltaY > 0 ? -0.15 : 0.15;
zoomGraph(direction);
}, { passive: false });
network.on("selectNode", function(params: any) {
if (params.nodes.length > 0) showInspector(params.nodes[0]);
});
network.on("deselectNode", function(params: any) {
if (params.nodes.length === 0) closeInspector();
});
}
} catch (err: any) {
console.error("Failed to load graph", err);
const container = document.getElementById("network-container");
if (container) {
container.innerHTML = '<div style="color:#e74c3c;padding:20px;font-weight:bold;">Error loading graph: ' + err.message + '</div>';
}
}
}
// --- Task Tree (HTN/DAG) ---
async function completeTask(id: string): Promise<void> {
try {
await fetch(`/api/tasks/${id}/complete`, { method: 'POST' });
loadTasks();
} catch(e) { console.error("Failed to complete task", e); }
}
function buildTaskTreeHTML(tasks: TaskItem[], parentId: string | null, depth: number = 0): string {
let html = '';
const children = tasks.filter(t => {
const pid = t.parent_id || t.parentId;
if (!parentId) return !pid;
return pid === parentId;
});
if (children.length === 0) return html;
children.forEach(t => {
const isCompleted = t.status === 'completed' || t.status === 'done';
const isCancelled = t.status === 'cancelled' || t.status === 'abandoned';
let cardClass = 'task-card';
if (isCompleted) cardClass += ' completed';
if (isCancelled) cardClass += ' cancelled';
let isBlocked = false;
let blockers: string[] = [];
const deps = t.dependencies || [];
deps.forEach(depId => {
const depTask = tasks.find(dt => dt.id === depId);
if (depTask && depTask.status !== 'completed' && depTask.status !== 'done') {
isBlocked = true;
blockers.push(depTask.title);
}
});
const allChildren = tasks.filter(ct => (ct.parentId || ct.parent_id) === t.id);
const completedChildren = allChildren.filter(ct => ct.status === 'completed' || ct.status === 'done');
let progressHtml = '';
if (allChildren.length > 0) {
const pct = Math.round((completedChildren.length / allChildren.length) * 100);
progressHtml = `
<div style="margin-top:10px; background:#e1e8ed; border-radius:4px; height:8px; overflow:hidden;">
<div style="background:#3498db; width:${pct}%; height:100%; transition:width 0.3s;"></div>
</div>
<div style="font-size:0.8em; color:var(--text-secondary); text-align:right; margin-top:2px;">${pct}% (${completedChildren.length}/${allChildren.length} child tasks)</div>
`;
if (completedChildren.length < allChildren.length) {
isBlocked = true;
}
}
html += `<div class="${cardClass}" style="margin-left: ${depth * 20}px; margin-bottom: 10px;">`;
if (isBlocked && !isCompleted && !isCancelled) {
html += `<div style="background:#e74c3c; color:white; font-size:0.75em; padding:2px 6px; border-radius:3px; display:inline-block; margin-bottom:6px; font-weight:bold;">[BLOCKED]</div>`;
if (blockers.length > 0) {
html += `<div style="font-size:0.8em; color:#e74c3c; margin-bottom:6px;">Waiting on: ${blockers.join(', ')}</div>`;
}
}
if (isCancelled) {
html += `<div style="background:#95a5a6; color:white; font-size:0.75em; padding:2px 6px; border-radius:3px; display:inline-block; margin-bottom:6px; font-weight:bold;">[CANCELLED]</div>`;
}
html += `<strong>${t.title}</strong>${t.description}`;
const criteria = t.acceptance_criteria || [];
if (criteria.length > 0) {
html += `<ul style="margin:8px 0 0 0; padding-left:20px; font-size: 0.9em; color: var(--text-secondary);">`;
let unmetCriteria = false;
criteria.forEach(c => {
const isMet = c.is_met;
if (!isMet) unmetCriteria = true;
const check = isMet ? '☑' : '☐';
const strike = isMet ? 'text-decoration: line-through;' : '';
html += `<li style="${strike}">${check} ${c.description}</li>`;
});
html += `</ul>`;
if (unmetCriteria && !isCompleted && !isCancelled) isBlocked = true;
}
html += progressHtml;
if (!isCompleted && !isCancelled && !isBlocked) {
html += `<button class="complete-btn" onclick="completeTask('${t.id}')" title="Mark Completed">✓</button>`;
}
if (allChildren.length > 0) {
html += `<div style="margin-top: 15px; border-left: 2px solid var(--border-color); padding-left: 10px;">`;
html += buildTaskTreeHTML(tasks, t.id, 0);
html += `</div>`;
}
html += `</div>`;
});
return html;
}
async function loadTasks(): Promise<void> {
try {
const res = await fetch('/api/tasks');
const tasks: TaskItem[] = await res.json();
const taskContainer = document.getElementById('task-tree-container');
if (!taskContainer) return;
const rootHtml = buildTaskTreeHTML(tasks, null, 0);
if (!rootHtml) {
taskContainer.innerHTML = '<div style="color:var(--text-secondary); padding:20px; text-align:center;">No active tasks.</div>';
} else {
taskContainer.innerHTML = rootHtml;
}
} catch (err) {
console.error("Failed to load tasks", err);
}
}
// --- Search ---
function highlightText(text: string, query: string): string {
if (!query) return text;
const regex = new RegExp(`(${query})`, 'gi');
return text.replace(regex, '<span class="search-highlight">$1</span>');
}
let searchDebounceTimer: any = null;
let activeSearchAbortController: AbortController | null = null;
async function handleSearch(e: KeyboardEvent): Promise<void> {
const input = e.target as HTMLInputElement;
const q = input.value.trim();
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
if (!q) {
if (activeSearchAbortController) activeSearchAbortController.abort();
const resultsEl = document.getElementById('search-results');
if (resultsEl) resultsEl.innerHTML = '';
return;
}
const delay = (e.key === 'Enter') ? 0 : 250;
searchDebounceTimer = setTimeout(async () => {
if (activeSearchAbortController) activeSearchAbortController.abort();
activeSearchAbortController = new AbortController();
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
signal: activeSearchAbortController.signal
});
const data = await res.json();
const container = document.getElementById('search-results');
if (!container) return;
if (!data.results || data.results.length === 0) {
container.innerHTML = '<div style="padding:15px;color:var(--text-secondary);">No results found.</div>';
return;
}
container.innerHTML = data.results.map((r: SearchResultItem) => `
<div class="search-result-item" onclick="if(rawEntities['${r.id}']){ showInspector('${r.id}'); network.selectNodes(['${r.id}']); network.focus('${r.id}', {scale:1.2, animation:true}); }" style="cursor:pointer;">
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
<div>
<strong>${highlightText(r.title, q)}</strong>
<span style="font-size: 0.7em; color: var(--text-secondary); background: var(--canvas-bg); padding: 2px 6px; border-radius: 4px; margin-left: 8px; text-transform: uppercase; border: 1px solid var(--border-color);">${r.type_name}</span>
</div>
<div style="font-size:0.75em; color:var(--text-secondary);">${r.score.toFixed(2)}</div>
</div>
<div style="margin-top:6px; font-size:0.9em; line-height:1.4;">${highlightText(r.content.substring(0, 150), q)}${r.content.length > 150 ? '...' : ''}</div>
<div style="font-size:0.75em; color:var(--text-secondary); margin-top:8px; font-family:monospace; background: var(--canvas-bg); padding: 2px 4px; border-radius: 3px; display: inline-block;">ID: ${r.id}</div>
</div>
`).join('');
} catch (err: any) {
if (err.name !== 'AbortError') {
console.error("Search error:", err);
}
}
}, delay);
}
// --- WebSocket Activity Feed ---
const MAX_ACTIVITY_HISTORY = 100;
function escapeHtml(str: any): string {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function parseActivityPayload(item: any): string {
let category = 'TOOL';
let summary = '';
let details = '';
let timestamp = Date.now();
if (typeof item === 'string') {
try {
const parsed = JSON.parse(item);
return parseActivityPayload(parsed);
} catch (e) {
summary = item;
}
} else if (typeof item === 'object' && item !== null) {
if (item.method === 'notifications/activity' && item.params) {
return parseActivityPayload(item.params);
}
category = (item.category || item.type || 'TOOL').toUpperCase();
summary = item.summary || item.message || item.description || item.data || '';
details = item.details || '';
timestamp = item.timestamp || item.time || item.updated_at || timestamp;
if (typeof timestamp === 'number' && timestamp < 10000000000) {
timestamp = timestamp * 1000;
}
}
const catUpper = category.toUpperCase();
const colorMap: Record<string, string> = {
'GRAPH': '#2ecc71',
'DECISION': '#f39c12',
'CODE': '#3498db',
'TASK': '#1abc9c',
'CLIPBOARD': '#9b59b6',
'STICKY_NOTE': '#e67e22',
'CHECKPOINT': '#e74c3c',
'ERROR_FIX': '#e74c3c',
'TECH_DEBT': '#d35400',
'SUBAGENT': '#8e44ad',
'SNIPPET': '#16a085',
'TOOL': '#3498db',
'SYSTEM': '#95a5a6'
};
const badgeColor = colorMap[catUpper] || '#3498db';
const dateObj = new Date(timestamp);
const validDate = isNaN(dateObj.getTime()) ? new Date() : dateObj;
const timeStr = validDate.toLocaleString([], {
month: '2-digit', day: '2-digit', hour: '2-digit', minute:'2-digit', second:'2-digit'
});
let html = `<span class="time">[${timeStr}]</span> <span style="background:var(--canvas-bg); color:${badgeColor}; padding:2px 6px; border-radius:3px; font-weight:bold; margin-right:6px; border:1px solid ${badgeColor}60; text-transform:uppercase; font-size:0.75em;">${escapeHtml(catUpper)}</span> ${escapeHtml(summary)}`;
if (details) {
html += `<div style="font-size:0.85em; color:var(--text-secondary); margin-left:20px; margin-top:2px;">${escapeHtml(details)}</div>`;
}
return html;
}
async function loadActivityHistory(): Promise<void> {
try {
const response = await fetch('/api/activity');
const history: ActivityItem[] = await response.json();
const feed = document.getElementById('activity-feed');
if (!feed) return;
feed.innerHTML = '';
const getMillis = (item: any): number => {
if (!item) return 0;
if (typeof item === 'string') {
try { item = JSON.parse(item); } catch(e) { return 0; }
}
if (item.method === 'notifications/activity' && item.params) {
item = item.params;
}
let val = item.timestamp || item.time || item.updated_at || 0;
if (typeof val === 'number' && val < 10000000000) {
val = val * 1000;
}
return val;
};
const sorted = [...history].sort((a, b) => getMillis(b) - getMillis(a));
sorted.forEach(item => {
const div = document.createElement('div');
div.className = 'feed-entry';
div.innerHTML = parseActivityPayload(item);
feed.appendChild(div);
});
if (sorted.length > 0) {
feed.scrollTop = 0;
}
} catch(e) {
console.error("Failed to load activity history", e);
}
}
function setupWS(): void {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${location.host}/ws?client=ui`);
const feed = document.getElementById('activity-feed');
ws.onmessage = function(event: MessageEvent) {
try {
const data = JSON.parse(event.data);
if (data.type === 'activity' || data.method === 'notifications/activity' || data.method === 'notifications/task/completed' || data.method === 'notifications/resources/updated') {
refreshActiveTab();
const isScrolledToTop = feed ? feed.scrollTop <= 20 : true;
const div = document.createElement('div');
div.className = 'feed-entry';
const payload = data.params || data.data || data;
div.innerHTML = parseActivityPayload(payload);
if (feed) {
feed.prepend(div);
while (feed.children.length > MAX_ACTIVITY_HISTORY && feed.lastChild) {
feed.removeChild(feed.lastChild);
}
if (isScrolledToTop) feed.scrollTop = 0;
}
}
} catch (e) {
console.error("WebSocket message parse error", e);
}
};
ws.onclose = function() {
console.log("WebSocket closed, attempting to reconnect in 3s...");
setTimeout(setupWS, 3000);
};
}
async function loadPreferences(): Promise<void> {
try {
const res = await fetch('/api/preferences');
const data = await res.json();
const container = document.getElementById('preferences-container');
if (!container) return;
container.innerHTML = '';
if (!data || Object.keys(data).length === 0) {
container.innerHTML = '<div style="color:var(--text-secondary); text-align:center; padding:20px;">No global preferences found.</div>';
return;
}
for (const [key, pref] of Object.entries<any>(data)) {
const date = new Date(pref.updated_at * 1000).toLocaleString();
container.innerHTML += `
<div style="background:var(--card-bg); border:1px solid var(--border-color); border-radius:6px; padding:15px; box-shadow:0 1px 3px rgba(0,0,0,0.1);">
<strong style="color:#3498db; display:block; margin-bottom:5px; font-size:1.1em;">${key}</strong>
<div style="margin-bottom:10px;">${pref.value}</div>
<div style="font-size:0.8em; color:var(--text-secondary); text-align:right;">Last Updated: ${date}</div>
</div>`;
}
} catch (e) {
console.error('Error loading preferences:', e);
}
}
async function loadStickyNotes(): Promise<void> {
try {
const res = await fetch('/api/sticky');
const sticky = await res.json();
const container = document.getElementById('sticky-notes-container');
if (!container) return;
container.innerHTML = '';
sticky.forEach((note: any) => {
const card = document.createElement('div');
card.className = 'sticky-note';
const date = new Date(note.timestamp * 1000).toLocaleString();
card.innerHTML = `<div style="font-size:0.75em; color:#7f8c8d; margin-bottom:8px;">${date}</div>
<div>${note.content}</div>`;
container.appendChild(card);
});
} catch (err) {
console.error("Failed to load sticky notes", err);
}
}
async function loadGenericList(endpoint: string, containerId: string, formatter: (item: any) => string): Promise<void> {
try {
const res = await fetch(endpoint);
const data = await res.json();
const container = document.getElementById(containerId);
if (!container) return;
if (!data || data.length === 0) {
container.innerHTML = '<div style="color:var(--text-secondary); padding:20px; text-align:center;">No items recorded.</div>';
return;
}
container.innerHTML = data.map((item: any) => {
const date = item.timestamp ? new Date(item.timestamp * 1000).toLocaleString() : '';
return `<div class="task-card" style="border-left-color: #9b59b6;">
<div style="font-size:0.75em; color:var(--text-secondary); float:right;">${date}</div>
${formatter(item)}
</div>`;
}).join('');
} catch (err) {
console.error(`Failed to load ${endpoint}`, err);
}
}
function loadTerminal(): void {
loadGenericList('/api/terminal/history', 'terminal-container', item => `
<span style="font-weight:bold; font-size:1.05em; color:var(--text-primary);">${item.command}</span>
<span style="margin-left:15px; font-size:0.85em;"><span style="font-weight:bold; color:var(--text-secondary);">Exit Code:</span> <span style="color:${item.exit_code === 0 ? 'var(--success-color)' : 'var(--error-color)'}; font-weight:bold;">${item.exit_code}</span></span>
<div style="margin-top:10px; font-size:0.85em;">
<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; margin-right:8px; border:1px solid var(--border-color);">OS: ${item.os || 'Unknown'}</span>
<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; font-family:monospace; border:1px solid var(--border-color);">CWD: ${item.cwd || 'Unknown'}</span>
</div>
`);
}
function loadTechDebt(): void {
loadGenericList('/api/tech_debts', 'techdebt-container', item => `
<strong>${item.id}</strong> ${item.is_resolved ? '<span style="color:var(--success-color); font-weight:bold; background: rgba(46, 204, 113, 0.1); padding: 2px 6px; border-radius: 4px; border: 1px solid var(--success-color); margin-left: 8px;">(Resolved)</span>' : '<span style="color:var(--open-color); font-weight:bold; background: rgba(230, 126, 34, 0.1); padding: 2px 6px; border-radius: 4px; border: 1px solid var(--open-color); margin-left: 8px;">(Open)</span>'}
<div style="margin-top:5px;"><strong>Description:</strong> ${item.description}</div>
<div style="margin-top:5px;"><strong>Ideal Solution:</strong> ${item.ideal_solution}</div>
<div style="margin-top:10px;">
<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; font-size:0.8em; margin-right:8px; border:1px solid var(--border-color);">Git Commit: ${item.git_commit || 'None'}</span>
<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; font-size:0.8em; border:1px solid var(--border-color);">Branch: ${item.git_branch || 'None'}</span>
</div>
`);
loadGenericList('/api/error_fixes', 'errorfixes-container', item => `
<strong>${item.signature || 'Error'}</strong> <span style="color:var(--success-color); font-weight:bold; background: rgba(46, 204, 113, 0.1); padding: 2px 6px; border-radius: 4px; border: 1px solid var(--success-color); margin-left: 8px;">(Resolved)</span>
<div style="font-size:0.9em; margin-bottom:8px; margin-top:5px;"><em>Solution:</em> ${item.solution || ''}</div>
<div style="font-family:monospace; font-size:0.85em; color:#e74c3c;">Commit: ${item.git_commit || 'None'}</div>
`);
}
function loadADRs(): void {
loadGenericList('/api/adrs', 'adrs-container', item => `
<strong>${item.id} | ${item.title}</strong>
<span style="float:right; background:var(--canvas-bg); padding:2px 6px; border-radius:4px; font-size:0.8em; border:1px solid var(--border-color); ${item.status === 'superseded' ? 'text-decoration: line-through; color:var(--error-color);' : 'color:var(--success-color);'}">${item.status}</span>
<div style="font-size:0.9em; margin-bottom:8px; margin-top:8px;"><em>Context:</em> ${item.context || ''}</div>
<div style="font-size:0.9em; margin-bottom:8px;"><em>Decision:</em> ${item.decision || ''}</div>
<div style="font-size:0.9em; margin-bottom:8px;"><em>Consequence:</em> ${item.consequence || ''}</div>
${item.supersedes ? `<div style="font-size:0.9em; font-weight:bold; color:var(--warning-color);">Supersedes: ${item.supersedes}</div>` : ''}
`);
}
function loadWorkspaces(): void {
loadGenericList('/api/context_workspaces', 'workspaces-container', item => `
<strong>${item.name}</strong>
<div style="margin-bottom:8px;">${item.description || ''}</div>
<div style="font-family:monospace; font-size:0.85em; color:#3498db;">${(item.paths || []).join(', ')}</div>
`);
}
function loadPinned(): void {
loadGenericList('/api/pinned_files', 'pinned-container', item => `
<strong>${item.path || item.file_path || item.id}</strong>
<div style="margin-top:5px; font-size:0.85em; color:var(--text-secondary);">${item.reason || item.description || 'Pinned'}</div>
`);
}
function loadMemos(): void {
loadGenericList('/api/handoff_memos', 'memos-container', item => `
<strong>Memo from ${item.author || 'System'}</strong>
<div style="white-space:pre-wrap; font-size:0.9em; line-height:1.4;">${item.content || item.summary || ''}</div>
`);
loadGenericList('/api/milestones', 'milestones-container', item => `
<strong>${item.title || item.name}</strong>
<div style="margin-top:5px; font-size:0.85em;">${item.description || ''}</div>
`);
}
function loadSnippets(): void {
loadGenericList('/api/snippets', 'snippets-container', item => `
<strong>${item.description || 'Snippet'}</strong>
<div style="margin-bottom:8px; font-size:0.8em;">Language: ${item.language || 'txt'} | Tags: ${(item.tags || []).join(', ')}</div>
<pre style="background:#111417; color:#d1d8e0; padding:10px; border-radius:4px; overflow-x:auto; font-size:0.85em;">${item.content || item.code || ''}</pre>
`);
}
function loadPRs(): void {
loadGenericList('/api/pr_checklists', 'pr-container', item => `
<strong>${item.name || 'Checklist'}</strong>
<ul style="padding-left:20px; font-size:0.9em;">
${(item.items || []).map((i: any) => {
const check = i.is_completed ? '☑' : '☐';
const strike = i.is_completed ? 'text-decoration:line-through; color:var(--text-secondary);' : '';
return `<li style="${strike}">${check} ${i.description}</li>`;
}).join('')}
</ul>
`);
}
function loadAllExtras(): void {
loadTerminal();
loadTechDebt();
loadADRs();
loadWorkspaces();
loadPinned();
loadMemos();
loadSnippets();
loadPRs();
}
async function testClipboard(): Promise<void> {
const modal = document.getElementById('clipboard-modal');
const resultDiv = document.getElementById('clipboard-result');
if (modal) modal.style.display = 'flex';
if (resultDiv) resultDiv.innerHTML = '<p id="clipboard-loading" style="color:var(--text-secondary);">Analyzing your clipboard...</p>';
try {
const res = await fetch('/api/clipboard/capture', { method: 'POST' });
const data = await res.json();
if (resultDiv) {
if (data.success) {
resultDiv.innerHTML = `<img src="${data.image}" style="max-width:100%; max-height:60vh; border-radius:4px; border:1px solid var(--border-color); box-shadow: 0 5px 15px rgba(0,0,0,0.3);" />`;
} else {
resultDiv.innerHTML = `<p style="color:var(--error-color); font-weight:bold;">Failed to read clipboard: ${data.error}</p>`;
}
}
} catch (err: any) {
if (resultDiv) {
resultDiv.innerHTML = `<p style="color:var(--error-color); font-weight:bold;">Error calling endpoint: ${err.message}</p>`;
}
}
}
async function loadVersion(): Promise<void> {
try {
const res = await fetch('/api/version');
const data = await res.json();
const verEl = document.getElementById('app-version');
if (verEl) {
verEl.innerHTML = `<span style="background:var(--card-bg); border:1px solid var(--border-color); color:var(--text-secondary); padding:3px 10px; border-radius:12px; font-size:0.85em; font-weight:600; font-family:monospace;">v${data.version}</span>`;
}
} catch (err) {
console.error("Failed to load version", err);
}
}
function setupSSE(): void {
try {
const sse = new EventSource('/api/activity/stream');
sse.onmessage = function(event: MessageEvent) {
if (event.data) {
try {
refreshActiveTab();
const feed = document.getElementById('activity-feed');
if (feed) {
const isScrolledToTop = feed.scrollTop <= 20;
const div = document.createElement('div');
div.className = 'feed-entry';
div.innerHTML = parseActivityPayload(event.data);
feed.prepend(div);
while (feed.children.length > MAX_ACTIVITY_HISTORY && feed.lastChild) {
feed.removeChild(feed.lastChild);
}
if (isScrolledToTop) feed.scrollTop = 0;
}
} catch(e) {}
}
};
} catch(e) { console.error('SSE initialization error', e); }
}
document.addEventListener('keydown', function(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
const searchTabBtn = document.querySelectorAll('.tab-button')[1] as HTMLElement | undefined;
if (searchTabBtn) {
switchTab('search-tab', searchTabBtn);
}
const searchInput = document.getElementById('search-box') as HTMLInputElement | null;
if (searchInput) {
searchInput.focus();
searchInput.select();
}
}
});
function refreshActiveTab(): void {
switch (currentTabId) {
case 'graph-tab': loadGraph(); break;
case 'task-tab': loadTasks(); break;
case 'sticky-tab': loadStickyNotes(); break;
case 'techdebt-tab': loadTechDebt(); break;
case 'adrs-tab': loadADRs(); break;
case 'workspaces-tab': loadWorkspaces(); break;
case 'pinned-tab': loadPinned(); break;
case 'memos-tab': loadMemos(); break;
case 'snippets-tab': loadSnippets(); break;
case 'pr-tab': loadPRs(); break;
case 'terminal-tab': loadTerminal(); break;
case 'preferences-tab': loadPreferences(); break;
case 'activity-tab': loadActivityHistory(); break;
}
}
// --- Initialization ---
loadVersion();
loadGraph();
loadActivityHistory();
setupWS();
setupSSE();
const observer = new MutationObserver(() => updateGraphData());
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
+14 -19
View File
@@ -2028,10 +2028,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_all_meta_handlers_comprehensive() { async fn test_all_meta_handlers_comprehensive() {
use crate::handlers::tasks::{ use crate::handlers::tasks::{MilestonesHandler, TasksHandler};
AddMilestoneHandler, ListMilestonesHandler, SetAcceptanceCriteriaHandler,
UpdateMilestoneHandler, VerifyAcceptanceCriteriaHandler,
};
use crate::handlers::graph::SweepGraphHealthHandler; use crate::handlers::graph::SweepGraphHealthHandler;
use crate::handlers::git::QueryGitDiffsHandler; use crate::handlers::git::QueryGitDiffsHandler;
@@ -2116,10 +2113,11 @@ mod tests {
assert!(q_hyp_res.contains("Caching improves response speed")); assert!(q_hyp_res.contains("Caching improves response speed"));
// AddMilestone & UpdateMilestone & ListMilestones // AddMilestone & UpdateMilestone & ListMilestones
let add_ms = AddMilestoneHandler; let handler_ms = MilestonesHandler;
let ms_res = add_ms let ms_res = handler_ms
.execute( .execute(
serde_json::json!({ serde_json::json!({
"action": "add",
"title": "v1.0 Release", "title": "v1.0 Release",
"description": "First major release" "description": "First major release"
}), }),
@@ -2130,10 +2128,10 @@ mod tests {
assert!(ms_res.contains("Milestone added")); assert!(ms_res.contains("Milestone added"));
let ms_id = state.project.milestones.read_with(|ms| ms[0].id.clone()); let ms_id = state.project.milestones.read_with(|ms| ms[0].id.clone());
let upd_ms = UpdateMilestoneHandler; let upd_ms_res = handler_ms
let upd_ms_res = upd_ms
.execute( .execute(
serde_json::json!({ serde_json::json!({
"action": "update",
"id": ms_id, "id": ms_id,
"status": "completed" "status": "completed"
}), }),
@@ -2142,10 +2140,7 @@ mod tests {
.await; .await;
assert!(upd_ms_res.is_ok()); assert!(upd_ms_res.is_ok());
let list_ms_res = handler_ms.execute(serde_json::json!({"action": "list"}), state.clone()).await.unwrap();
let list_ms = ListMilestonesHandler;
let list_ms_res = list_ms.execute(serde_json::json!({}), state.clone()).await.unwrap();
assert!(list_ms_res.contains("v1.0 Release")); assert!(list_ms_res.contains("v1.0 Release"));
// SetAcceptanceCriteria & VerifyAcceptanceCriteria // SetAcceptanceCriteria & VerifyAcceptanceCriteria
@@ -2164,11 +2159,12 @@ mod tests {
}; };
state.project.tasks.modify(|t| t.push(task)); state.project.tasks.modify(|t| t.push(task));
let set_ac = SetAcceptanceCriteriaHandler; let handler_tasks = TasksHandler;
let set_ac_res = set_ac let set_ac_res = handler_tasks
.execute( .execute(
serde_json::json!({ serde_json::json!({
"task_title": "AC Task", "action": "set_criteria",
"id": "t_ac_1",
"criteria": ["Code compiles cleanly", "Tests pass"] "criteria": ["Code compiles cleanly", "Tests pass"]
}), }),
state.clone(), state.clone(),
@@ -2177,12 +2173,11 @@ mod tests {
.unwrap(); .unwrap();
assert!(set_ac_res.contains("Acceptance criteria set successfully.")); assert!(set_ac_res.contains("Acceptance criteria set successfully."));
let ver_ac = VerifyAcceptanceCriteriaHandler; let ver_ac_res = handler_tasks
let ver_ac_res = ver_ac
.execute( .execute(
serde_json::json!({ serde_json::json!({
"task_id": "t_ac_1", "action": "verify",
"criteria": "Code compiles cleanly", "id": "t_ac_1",
"proof": "cargo test passed" "proof": "cargo test passed"
}), }),
state.clone(), state.clone(),
+9 -133
View File
@@ -91,130 +91,7 @@ impl McpTool for StickyNotesHandler {
} }
} }
pub struct AddStickyNoteHandler;
#[async_trait]
impl McpTool for AddStickyNoteHandler {
fn name(&self) -> &'static str {
"add_sticky_note"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<AddStickyNoteTool>("add_sticky_note", "Execute add_sticky_note")
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: AddStickyNoteTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let now = crate::handlers::utils::now_secs();
let expires_at = if let Some(ttl) = req.ttl_seconds {
Some(now + ttl)
} else if req.session_only.unwrap_or(false) {
Some(now + 14400) // Default 4-hour session TTL
} else {
None
};
state.code.sticky.modify(|notes| {
notes.push(StickyNote {
timestamp: now,
content: req.content,
expires_at,
});
});
Ok("Sticky note added.".to_string())
}
}
pub struct ReadStickyNotesHandler;
#[async_trait]
impl McpTool for ReadStickyNotesHandler {
fn name(&self) -> &'static str {
"read_sticky_notes"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ReadStickyNotesTool>(
"read_sticky_notes",
"Execute read_sticky_notes",
)
}
async fn execute(&self, _args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let now = crate::handlers::utils::now_secs();
let mut active_notes = Vec::new();
state.code.sticky.modify(|notes| {
notes.retain(|n| {
if let Some(exp) = n.expires_at {
exp > now
} else {
true
}
});
active_notes = notes.clone();
});
Ok(serde_json::to_string(&active_notes)?)
}
}
pub struct DeleteStickyNoteHandler;
#[async_trait]
impl McpTool for DeleteStickyNoteHandler {
fn name(&self) -> &'static str {
"delete_sticky_note"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<DeleteStickyNoteTool>(
"delete_sticky_note",
"Execute delete_sticky_note",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: DeleteStickyNoteTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let mut success = false;
state.code.sticky.modify(|notes| {
if req.index > 0 && req.index <= notes.len() {
notes.remove(req.index - 1);
success = true;
}
});
if success {
Ok("Sticky note deleted.".to_string())
} else {
Err(crate::error::AppError::Internal(
"Invalid sticky note index.".to_string(),
))
}
}
}
pub struct ClearStickyNotesHandler;
#[async_trait]
impl McpTool for ClearStickyNotesHandler {
fn name(&self) -> &'static str {
"clear_sticky_notes"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ClearStickyNotesTool>(
"clear_sticky_notes",
"Execute clear_sticky_notes",
)
}
async fn execute(&self, _args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
state.code.sticky.modify(|notes| {
notes.clear();
});
Ok("All sticky notes cleared.".to_string())
}
}
pub struct HandoffMemosHandler; pub struct HandoffMemosHandler;
@@ -414,37 +291,36 @@ mod tests {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap())); let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let add_handler = AddStickyNoteHandler; let handler = StickyNotesHandler;
let args = json!({ let args = json!({
"action": "add",
"content": "Buy milk", "content": "Buy milk",
}); });
let res = add_handler let res = handler
.execute(args, state.clone()) .execute(args, state.clone())
.await .await
.map_err(|e| crate::error::AppError::Internal(e.to_string())) .map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap(); .unwrap();
assert!(res.contains("Sticky note added")); assert!(res.contains("Sticky note added"));
let read_handler = ReadStickyNotesHandler; let res2 = handler
let res2 = read_handler .execute(json!({"action": "read"}), state.clone())
.execute(json!({}), state.clone())
.await .await
.map_err(|e| crate::error::AppError::Internal(e.to_string())) .map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap(); .unwrap();
assert!(res2.contains("Buy milk")); assert!(res2.contains("Buy milk"));
let delete_handler = DeleteStickyNoteHandler; let args2 = json!({"action": "delete", "index": 1});
let args2 = json!({"index": 1}); let res3 = handler
let res3 = delete_handler
.execute(args2, state.clone()) .execute(args2, state.clone())
.await .await
.map_err(|e| crate::error::AppError::Internal(e.to_string())) .map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap(); .unwrap();
assert_eq!(res3, "Sticky note deleted."); assert_eq!(res3, "Sticky note deleted.");
let res4 = read_handler let res4 = handler
.execute(json!({}), state.clone()) .execute(json!({"action": "read"}), state.clone())
.await .await
.map_err(|e| crate::error::AppError::Internal(e.to_string())) .map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap(); .unwrap();
File diff suppressed because it is too large. Load diff
+2
View File
@@ -206,6 +206,8 @@ impl McpTool for ToggleClipboardWatchModeHandler {
let mut watch_mode = state.clipboard_watch_mode.write().await; let mut watch_mode = state.clipboard_watch_mode.write().await;
*watch_mode = tool_args.enable; *watch_mode = tool_args.enable;
drop(watch_mode);
state.clipboard_notify.notify_waiters();
let status_msg = if tool_args.enable { let status_msg = if tool_args.enable {
"Clipboard watch mode enabled. Changes will be ingested as StickyNotes." "Clipboard watch mode enabled. Changes will be ingested as StickyNotes."
+27 -21
View File
@@ -13,32 +13,38 @@ pub async fn start_background_indexer(state: Arc<MemoryState>) {
tokio::spawn(async move { tokio::spawn(async move {
tracing::info!("Starting background indexer in {:?}", workspace_root); tracing::info!("Starting background indexer in {:?}", workspace_root);
let walker = WalkBuilder::new(&workspace_root) let root_clone = workspace_root.clone();
.hidden(true) let files_to_process = tokio::task::spawn_blocking(move || {
.git_ignore(true) let walker = WalkBuilder::new(&root_clone)
.build(); .hidden(true)
.git_ignore(true)
.build();
let mut files_to_process = Vec::new(); let mut files = Vec::new();
for result in walker { for result in walker {
match result { match result {
Ok(entry) => { Ok(entry) => {
if entry.file_type().is_some_and(|ft| ft.is_file()) { if entry.file_type().is_some_and(|ft| ft.is_file()) {
let path = entry.path().to_path_buf(); let path = entry.path().to_path_buf();
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if [ if [
"rs", "ts", "js", "jsx", "tsx", "py", "java", "c", "cpp", "go", "rs", "ts", "js", "jsx", "tsx", "py", "java", "c", "cpp", "go",
] ]
.contains(&ext) .contains(&ext)
{ {
files_to_process.push(path); files.push(path);
}
} }
} }
} Err(e) => {
Err(e) => { tracing::warn!("Error walking directory: {}", e);
tracing::warn!("Error walking directory: {}", e); }
} }
} }
} files
})
.await
.unwrap_or_default();
let idx = state.get_search_index(); let idx = state.get_search_index();
+7 -3
View File
@@ -98,7 +98,7 @@ pub struct AppState {
pub async fn ttl_sweeper_worker(state: Arc<MemoryState>) { pub async fn ttl_sweeper_worker(state: Arc<MemoryState>) {
loop { loop {
tokio::time::sleep(Duration::from_secs(3600)).await; state.ttl_notify.notified().await;
let now = std::time::SystemTime::now() let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default() .unwrap_or_default()
@@ -121,7 +121,7 @@ pub async fn ttl_sweeper_worker(state: Arc<MemoryState>) {
pub async fn index_committer_worker(state: Arc<MemoryState>) { pub async fn index_committer_worker(state: Arc<MemoryState>) {
loop { loop {
tokio::time::sleep(Duration::from_secs(5)).await; state.index_commit_notify.notified().await;
let idx_opt = state.search_index.read().ok().map(|idx| idx.clone()); let idx_opt = state.search_index.read().ok().map(|idx| idx.clone());
if let Some(idx) = idx_opt { if let Some(idx) = idx_opt {
let _ = idx.commit().await; let _ = idx.commit().await;
@@ -131,7 +131,7 @@ pub async fn index_committer_worker(state: Arc<MemoryState>) {
pub async fn condense_graph_worker(state: Arc<MemoryState>) { pub async fn condense_graph_worker(state: Arc<MemoryState>) {
loop { loop {
tokio::time::sleep(Duration::from_secs(3600)).await; state.condense_notify.notified().await;
let threshold: usize = std::env::var("MCP_MEMORY_CONDENSE_THRESHOLD") let threshold: usize = std::env::var("MCP_MEMORY_CONDENSE_THRESHOLD")
.unwrap_or_else(|_| "100".to_string()) .unwrap_or_else(|_| "100".to_string())
@@ -659,6 +659,10 @@ mod tests {
let handle2 = tokio::spawn(index_committer_worker(state.clone())); let handle2 = tokio::spawn(index_committer_worker(state.clone()));
let handle3 = tokio::spawn(condense_graph_worker(state.clone())); let handle3 = tokio::spawn(condense_graph_worker(state.clone()));
state.ttl_notify.notify_one();
state.index_commit_notify.notify_one();
state.condense_notify.notify_one();
tokio::time::sleep(Duration::from_millis(50)).await; tokio::time::sleep(Duration::from_millis(50)).await;
handle1.abort(); handle1.abort();
+11
View File
@@ -309,6 +309,17 @@ pub struct AgentSignal {
pub ttl_seconds: Option<u64>, pub ttl_seconds: Option<u64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TaskEvent {
pub task_id: String,
pub status: String,
pub action: Option<String>,
pub result: Option<serde_json::Value>,
pub error: Option<String>,
pub timestamp: u64,
pub session_id: Option<String>,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use axum::http::StatusCode; use axum::http::StatusCode;
+47 -125
View File
@@ -495,94 +495,46 @@ impl MemoryHandler {
register!(tasks::TasksHandler); register!(tasks::TasksHandler);
register!(tasks::MilestonesHandler); register!(tasks::MilestonesHandler);
register!(tasks::AddTaskHandler);
register!(tasks::DeleteTaskHandler);
register!(tasks::UpdateTaskStatusHandler);
register!(tasks::ListActiveTasksHandler);
register!(tasks::SetAcceptanceCriteriaHandler);
register!(tasks::VerifyAcceptanceCriteriaHandler);
register!(tasks::AddMilestoneHandler);
register!(tasks::UpdateMilestoneHandler);
register!(tasks::ListMilestonesHandler);
register!(notes::StickyNotesHandler); register!(notes::StickyNotesHandler);
register!(notes::HandoffMemosHandler); register!(notes::HandoffMemosHandler);
register!(notes::AddStickyNoteHandler);
register!(notes::ReadStickyNotesHandler);
register!(notes::DeleteStickyNoteHandler);
register!(notes::ClearStickyNotesHandler);
register!(notes::AddSessionSummaryHandler); register!(notes::AddSessionSummaryHandler);
register!(notes::GenerateStandupReportHandler); register!(notes::GenerateStandupReportHandler);
register!(notes::PromoteToEntityHandler); register!(notes::PromoteToEntityHandler);
register!(meta::DecisionsHandler); register!(meta::DecisionsHandler);
register!(meta::TechDebtHandler); register!(meta::TechDebtHandler);
register!(meta::LogDecisionHandler);
register!(meta::QueryDecisionsHandler);
register!(meta::DeleteDecisionHandler);
register!(meta::LogErrorFixHandler); register!(meta::LogErrorFixHandler);
register!(meta::SearchErrorFixesHandler); register!(meta::SearchErrorFixesHandler);
register!(meta::LogCodeChangeHandler); register!(meta::LogCodeChangeHandler);
register!(meta::QueryRecentChangesHandler); register!(meta::QueryRecentChangesHandler);
register!(meta::LearnPreferenceHandler); register!(meta::LearnPreferenceHandler);
register!(meta::ReadPreferencesHandler); register!(meta::ReadPreferencesHandler);
register!(meta::LogTechDebtHandler);
register!(meta::ResolveTechDebtHandler);
register!(meta::ListTechDebtHandler);
register!(meta::OmniSearchHandler); register!(meta::OmniSearchHandler);
register!(meta::GetProjectHealthHandler); register!(meta::GetProjectHealthHandler);
register!(env::EnvironmentHandler); register!(env::EnvironmentHandler);
register!(env::UpdateEnvFingerprintHandler);
register!(env::ReadEnvFingerprintHandler);
register!(env::LogEnvRequirementHandler);
register!(env::RegisterEnvironmentHandler);
register!(env::GetEnvironmentDetailsHandler);
register!(workspaces::PinnedFilesHandler); register!(workspaces::PinnedFilesHandler);
register!(workspaces::ContextWorkspacesHandler); register!(workspaces::ContextWorkspacesHandler);
register!(workspaces::PrChecklistHandler); register!(workspaces::PrChecklistHandler);
register!(workspaces::SnippetsHandler); register!(workspaces::SnippetsHandler);
register!(workspaces::PinFileHandler);
register!(workspaces::UnpinFileHandler);
register!(workspaces::ListPinnedFilesHandler);
register!(workspaces::StoreSnippetHandler);
register!(workspaces::SearchSnippetsHandler);
register!(workspaces::DeleteSnippetHandler);
register!(workspaces::SaveContextWorkspaceHandler);
register!(workspaces::LoadContextWorkspaceHandler);
register!(workspaces::ListContextWorkspacesHandler);
register!(workspaces::DeleteContextWorkspaceHandler);
register!(workspaces::AddPrChecklistItemHandler);
register!(workspaces::GetPrChecklistHandler);
register!(workspaces::ClearPrChecklistHandler);
register!(vision::ClipboardHandler); register!(vision::ClipboardHandler);
register!(vision::ReadClipboardHandler);
register!(vision::WriteClipboardHandler);
register!(git::GetActiveWorktreeContextHandler); register!(git::GetActiveWorktreeContextHandler);
register!(git::QueryGitDiffsHandler); register!(git::QueryGitDiffsHandler);
register!(logs::WatchProcessLogsHandler); register!(logs::WatchProcessLogsHandler);
register!(logs::GetRecentLogsHandler); register!(logs::GetRecentLogsHandler);
register!(ast::ReadFileSkeletonHandler); register!(ast::ReadFileSkeletonHandler);
register!(vision::ToggleClipboardWatchModeHandler);
register!(ast::ReplaceAstNodeHandler); register!(ast::ReplaceAstNodeHandler);
register!(ast::FindSymbolReferencesHandler); register!(ast::FindSymbolReferencesHandler);
register!(ast::GetCallersHandler); register!(ast::GetCallersHandler);
register!(ast::AnalyzeImpactHandler); register!(ast::AnalyzeImpactHandler);
register!(workspaces::ReadDirectoryArchitectureHandler); register!(workspaces::ReadDirectoryArchitectureHandler);
register!(workspaces::SemanticCodeSearchHandler); register!(workspaces::SemanticCodeSearchHandler);
register!(workspaces::CreateSnapshotHandler);
register!(workspaces::RestoreSnapshotHandler);
register!(workspaces::CreateSubagentNamespaceHandler);
register!(workspaces::ManageSubagentNamespaceHandler); register!(workspaces::ManageSubagentNamespaceHandler);
register!(graph::GetSubgraphHandler); register!(graph::GetSubgraphHandler);
register!(meta::SuggestErrorFixHandler);
register!(meta::CheckpointStateHandler);
register!(meta::ManageCheckpointHandler); register!(meta::ManageCheckpointHandler);
register!(meta::RestoreStateHandler);
register!(workspaces::TagSnippetHandler);
register!(workspaces::PurgeSubagentNamespaceHandler);
register!(workspaces::CondenseSubagentNamespaceHandler);
register!(graph::SweepGraphHealthHandler); register!(graph::SweepGraphHealthHandler);
register!(meta::QueryLineageHandler); register!(meta::QueryLineageHandler);
register!(meta::GetNextActionableTasksHandler); register!(meta::GetNextActionableTasksHandler);
@@ -595,7 +547,6 @@ impl MemoryHandler {
register!(meta::BroadcastAgentSignalHandler); register!(meta::BroadcastAgentSignalHandler);
register!(meta::QueryAgentSignalsHandler); register!(meta::QueryAgentSignalsHandler);
register!(meta::AutoSessionCheckpointHandler); register!(meta::AutoSessionCheckpointHandler);
register!(meta::SearchSnippetsHybridHandler);
Self { Self {
state, state,
@@ -763,19 +714,21 @@ impl MemoryHandler {
.unwrap_or(serde_json::Value::Object(Default::default())); .unwrap_or(serde_json::Value::Object(Default::default()));
let category = match name { let category = match name {
"read_clipboard" | "write_clipboard" | "toggle_clipboard_watch_mode" => "CLIPBOARD", "clipboard" => "CLIPBOARD",
"create_entities" | "create_relations" | "read_graph" | "get_subgraph" | "search_graph" | "get_schema" => "GRAPH", "create_entities" | "create_relations" | "read_graph" | "get_subgraph" | "search_graph" | "get_schema" => "GRAPH",
"log_decision" => "DECISION", "decisions" => "DECISION",
"log_code_change" => "CODE", "log_code_change" => "CODE",
"log_error_fix" => "ERROR_FIX", "log_error_fix" => "ERROR_FIX",
"log_tech_debt" => "TECH_DEBT", "tech_debt" => "TECH_DEBT",
"add_task" | "update_task_status" | "delete_task" | "add_milestone" => "TASK", "tasks" | "milestones" => "TASK",
"manage_sticky_notes" | "read_notes" => "STICKY_NOTE", "sticky_notes" | "handoff_memos" => "STICKY_NOTE",
"manage_checkpoint" => "CHECKPOINT", "manage_checkpoint" => "CHECKPOINT",
"manage_subagent_namespace" => "SUBAGENT", "manage_subagent_namespace" => "SUBAGENT",
"search_snippets" => "SNIPPET", "snippets" => "SNIPPET",
"search_web" => "WEB_SEARCH", "search_web" => "WEB_SEARCH",
"omni_search" => "OMNI_SEARCH", "omni_search" => "OMNI_SEARCH",
"environment" => "ENVIRONMENT",
"pinned_files" | "context_workspaces" | "pr_checklist" => "WORKSPACE",
_ => "TOOL", _ => "TOOL",
}; };
@@ -829,10 +782,38 @@ impl MemoryHandler {
pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) -> String { pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) -> String {
let (action, detail) = match name { let (action, detail) = match name {
"tasks" => {
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("manage");
let title = args.get("title").or_else(|| args.get("id")).and_then(|v| v.as_str()).unwrap_or("");
("Tasks", format!("{}: {}", act, title).trim_end_matches(": ").to_string())
}
"decisions" => {
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("log");
let title = args.get("title").or_else(|| args.get("query")).and_then(|v| v.as_str()).unwrap_or("");
("Decisions", format!("{}: {}", act, title).trim_end_matches(": ").to_string())
}
"tech_debt" => {
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("log");
let desc = args.get("description").or_else(|| args.get("id")).and_then(|v| v.as_str()).unwrap_or("");
("Tech Debt", format!("{}: {}", act, desc).trim_end_matches(": ").to_string())
}
"sticky_notes" => {
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("add");
let preview = args.get("content").and_then(|v| v.as_str()).map(|c| c.chars().take(40).collect::<String>()).unwrap_or_default();
("Sticky Notes", format!("{}: {}", act, preview).trim_end_matches(": ").to_string())
}
"clipboard" => {
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("read");
("Clipboard", act.to_string())
}
"snippets" => {
let act = args.get("action").and_then(|v| v.as_str()).unwrap_or("search");
let q = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
("Snippets", format!("{}: {}", act, q).trim_end_matches(": ").to_string())
}
"log_code_change" => { "log_code_change" => {
let file = args.get("file_path") let file = args.get("file_path")
.or_else(|| args.get("file")) .or_else(|| args.get("file"))
.or_else(|| args.get("path"))
.or_else(|| args.get("target_file")) .or_else(|| args.get("target_file"))
.and_then(|v| v.as_str()); .and_then(|v| v.as_str());
let summary = args.get("summary") let summary = args.get("summary")
@@ -847,15 +828,6 @@ pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) ->
}; };
("Log Code Change", d) ("Log Code Change", d)
} }
"log_decision" => {
let d = args.get("title")
.or_else(|| args.get("decision"))
.or_else(|| args.get("summary"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
("Log Decision", d)
}
"log_error_fix" => { "log_error_fix" => {
let d = args.get("error") let d = args.get("error")
.or_else(|| args.get("summary")) .or_else(|| args.get("summary"))
@@ -865,16 +837,6 @@ pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) ->
.to_string(); .to_string();
("Log Error Fix", d) ("Log Error Fix", d)
} }
"log_tech_debt" => {
let d = if let Some(summary) = args.get("summary").or_else(|| args.get("description")).and_then(|v| v.as_str()) {
summary.to_string()
} else if let Some(file) = args.get("file_path").or_else(|| args.get("file")).and_then(|v| v.as_str()) {
file.to_string()
} else {
String::new()
};
("Log Tech Debt", d)
}
"create_entities" => { "create_entities" => {
let d = if let Some(entities) = args.get("entities").and_then(|v| v.as_array()) { let d = if let Some(entities) = args.get("entities").and_then(|v| v.as_array()) {
let names: Vec<&str> = entities let names: Vec<&str> = entities
@@ -917,54 +879,17 @@ pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) ->
}; };
("Create Relations", d) ("Create Relations", d)
} }
"add_task" => { "omni_search" | "search_graph" | "search_web" => {
let d = args.get("title")
.or_else(|| args.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
("Add Task", d)
}
"update_task_status" => {
let d = if let (Some(id), Some(status)) = (
args.get("task_id").or_else(|| args.get("id")).and_then(|v| v.as_str()),
args.get("status").and_then(|v| v.as_str()),
) {
format!("Task {} -> {}", id, status)
} else {
String::new()
};
("Update Task Status", d)
}
"omni_search" | "search_graph" | "search_snippets" | "search_web" => {
let d = args.get("query") let d = args.get("query")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|q| format!("\"{}\"", q)) .map(|q| format!("\"{}\"", q))
.unwrap_or_default(); .unwrap_or_default();
("Search", d) ("Search", d)
} }
"manage_sticky_notes" | "add_sticky_note" => {
let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("add");
let d = if let Some(content) = args.get("content").and_then(|v| v.as_str()) {
let preview: String = content.chars().take(40).collect();
format!("{} \"{}\"", action, preview)
} else {
action.to_string()
};
("Sticky Note", d)
}
"write_clipboard" => {
let d = if let Some(text) = args.get("text").or_else(|| args.get("content")).and_then(|v| v.as_str()) {
let preview: String = text.chars().take(40).collect();
format!("\"{}\"", preview)
} else {
String::new()
};
("Write Clipboard", d)
}
_ => { _ => {
let d = if let Some(title) = args let d = args
.get("title") .get("title")
.or_else(|| args.get("action"))
.or_else(|| args.get("summary")) .or_else(|| args.get("summary"))
.or_else(|| args.get("description")) .or_else(|| args.get("description"))
.or_else(|| args.get("name")) .or_else(|| args.get("name"))
@@ -973,12 +898,8 @@ pub fn format_tool_activity_description(name: &str, args: &serde_json::Value) ->
.or_else(|| args.get("file")) .or_else(|| args.get("file"))
.or_else(|| args.get("path")) .or_else(|| args.get("path"))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
{ .unwrap_or("");
title.to_string() (name, d.to_string())
} else {
String::new()
};
(name, d)
} }
}; };
@@ -1021,7 +942,7 @@ mod tests {
// Assert some known tools are registered // Assert some known tools are registered
assert!(handler.tools.contains_key("create_entities")); assert!(handler.tools.contains_key("create_entities"));
assert!(handler.tools.contains_key("add_task")); assert!(handler.tools.contains_key("tasks"));
// Ensure we can fetch list of tools // Ensure we can fetch list of tools
let list_tools_req = json!({ let list_tools_req = json!({
@@ -1145,10 +1066,11 @@ mod tests {
"id": 3, "id": 3,
"method": "tools/call", "method": "tools/call",
"params": { "params": {
"name": "update_task_status", "name": "tasks",
"arguments": { "arguments": {
"action": "update",
"id": "nonexistent_task_123", "id": "nonexistent_task_123",
"status": "in_progress" "status": "completed"
} }
} }
}); });
+52
View File
@@ -51,6 +51,10 @@ pub struct TelemetryStores {
pub struct MemoryState { pub struct MemoryState {
pub base_dir: PathBuf, pub base_dir: PathBuf,
pub clipboard_watch_mode: tokio::sync::RwLock<bool>, pub clipboard_watch_mode: tokio::sync::RwLock<bool>,
pub clipboard_notify: Arc<tokio::sync::Notify>,
pub index_commit_notify: Arc<tokio::sync::Notify>,
pub ttl_notify: Arc<tokio::sync::Notify>,
pub condense_notify: Arc<tokio::sync::Notify>,
pub graph: Store<KnowledgeGraph>, pub graph: Store<KnowledgeGraph>,
pub search_index: RwLock<MemoryIndex>, pub search_index: RwLock<MemoryIndex>,
pub vector_db: tokio::sync::RwLock<Option<VectorDB>>, pub vector_db: tokio::sync::RwLock<Option<VectorDB>>,
@@ -75,6 +79,10 @@ impl MemoryState {
let state = Self { let state = Self {
ollama: Arc::new(crate::ollama::OllamaClient::new_from_env()), ollama: Arc::new(crate::ollama::OllamaClient::new_from_env()),
clipboard_watch_mode: tokio::sync::RwLock::new(false), clipboard_watch_mode: tokio::sync::RwLock::new(false),
clipboard_notify: Arc::new(tokio::sync::Notify::new()),
index_commit_notify: Arc::new(tokio::sync::Notify::new()),
ttl_notify: Arc::new(tokio::sync::Notify::new()),
condense_notify: Arc::new(tokio::sync::Notify::new()),
graph: Store::new("knowledge_graph_master", db.clone()), graph: Store::new("knowledge_graph_master", db.clone()),
base_dir: base.clone(), base_dir: base.clone(),
search_index: RwLock::new(match crate::search::MemoryIndex::new(&base) { search_index: RwLock::new(match crate::search::MemoryIndex::new(&base) {
@@ -207,6 +215,7 @@ impl MemoryState {
if let Ok(mut w) = self.search_index.write() { if let Ok(mut w) = self.search_index.write() {
*w = idx; *w = idx;
} }
self.index_commit_notify.notify_waiters();
} }
pub fn record_activity(&self, category: &str, summary: &str, details: Option<&str>) { pub fn record_activity(&self, category: &str, summary: &str, details: Option<&str>) {
@@ -243,6 +252,49 @@ impl MemoryState {
let _ = self.activity_tx.send(payload); let _ = self.activity_tx.send(payload);
} }
pub fn broadcast_task_event(&self, event: TaskEvent) {
let payload_val = serde_json::to_value(&event).unwrap_or_default();
let summary_str = format!("Task {} -> {}", event.task_id, event.status);
self.telemetry.recent_activities.modify(|activities| {
let activity = ActivityRecord {
timestamp: event.timestamp,
category: "TASK_EVENT".to_string(),
summary: summary_str,
details: Some(payload_val.to_string()),
};
activities.push_front(serde_json::to_value(&activity).unwrap_or_default());
if activities.len() > 100 {
activities.pop_back();
}
});
let generic_ev = GenericEvent {
topic: "task:event".to_string(),
session_id: event.session_id.clone(),
payload: payload_val,
};
let _ = self.event_bus_tx.send(generic_ev);
let ws_notification = serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/task/completed",
"params": event
})
.to_string();
let _ = self.activity_tx.send(ws_notification);
let ws_resource_notification = serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/resources/updated",
"params": {
"uri": "memory://tasks/active"
}
})
.to_string();
let _ = self.activity_tx.send(ws_resource_notification);
}
pub fn record_terminal_history(&self, payload: TerminalHistory) { pub fn record_terminal_history(&self, payload: TerminalHistory) {
self.telemetry.terminal_history.modify(|history| { self.telemetry.terminal_history.modify(|history| {
history.push_front(payload); history.push_front(payload);
+14 -19
View File
@@ -6,6 +6,7 @@ pub const STORE_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("stor
pub struct Store<T> { pub struct Store<T> {
pub cache: Arc<RwLock<T>>, pub cache: Arc<RwLock<T>>,
pub flushed: Arc<tokio::sync::Notify>,
tx: tokio::sync::mpsc::Sender<()>, tx: tokio::sync::mpsc::Sender<()>,
} }
@@ -13,21 +14,22 @@ impl<T: DeserializeOwned + Default + Serialize + Clone + Send + Sync + 'static>
pub fn new(key: &str, db: Arc<Database>) -> Self { pub fn new(key: &str, db: Arc<Database>) -> Self {
let initial_data = Self::load_from_db(key, &db); let initial_data = Self::load_from_db(key, &db);
let cache = Arc::new(RwLock::new(initial_data)); let cache = Arc::new(RwLock::new(initial_data));
let flushed = Arc::new(tokio::sync::Notify::new());
let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1); let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
let db_clone = db.clone(); let db_clone = db.clone();
let key_clone = key.to_string(); let key_clone = key.to_string();
let cache_clone = cache.clone(); let cache_clone = cache.clone();
let flushed_clone = flushed.clone();
tokio::spawn(async move { tokio::spawn(async move {
while rx.recv().await.is_some() { while rx.recv().await.is_some() {
// Debounce window: wait 150ms to batch rapid sequential mutations // Drain any pending notifications accumulated
tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
// Drain any pending notifications accumulated during the debounce window
while rx.try_recv().is_ok() {} while rx.try_recv().is_ok() {}
let db_inner = db_clone.clone(); let db_inner = db_clone.clone();
let key_inner = key_clone.clone(); let key_inner = key_clone.clone();
let flushed_inner = flushed_clone.clone();
let json_data = { let json_data = {
let lock = cache_clone.read().unwrap_or_else(|e| e.into_inner()); let lock = cache_clone.read().unwrap_or_else(|e| e.into_inner());
serde_json::to_vec(&*lock) serde_json::to_vec(&*lock)
@@ -37,7 +39,7 @@ impl<T: DeserializeOwned + Default + Serialize + Clone + Send + Sync + 'static>
if let Some(json_data) = json_data { if let Some(json_data) = json_data {
let _ = tokio::task::spawn_blocking(move || { let _ = tokio::task::spawn_blocking(move || {
// Retry up to 10 times with 30ms backoff if another Store holds write transaction // Retry up to 10 times if another Store holds write transaction
for _ in 0..10 { for _ in 0..10 {
match db_inner.begin_write() { match db_inner.begin_write() {
Ok(write_txn) => { Ok(write_txn) => {
@@ -48,17 +50,18 @@ impl<T: DeserializeOwned + Default + Serialize + Clone + Send + Sync + 'static>
break; break;
} }
Err(_) => { Err(_) => {
std::thread::sleep(std::time::Duration::from_millis(30)); std::thread::yield_now();
} }
} }
} }
}) })
.await; .await;
flushed_inner.notify_waiters();
} }
} }
}); });
Self { cache, tx } Self { cache, flushed, tx }
} }
fn load_from_db(key: &str, db: &Database) -> T { fn load_from_db(key: &str, db: &Database) -> T {
@@ -123,18 +126,10 @@ mod tests {
data.value = 42; data.value = 42;
}); });
// Wait and poll for persistence completion // Event-driven wait for persistence completion
let mut store2 = None; store.flushed.notified().await;
for _ in 0..20 {
let s = Store::<TestData>::new("test_key", db.clone());
if s.read_with(|data| data.value) == 42 {
store2 = Some(s);
break;
}
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
}
let store2 = store2.expect("Timed out waiting for async store persistence"); let store2 = Store::<TestData>::new("test_key", db.clone());
assert_eq!( assert_eq!(
store2.read_with(|s| s.clone()), store2.read_with(|s| s.clone()),
TestData { TestData {
@@ -172,8 +167,8 @@ mod tests {
h.await.unwrap(); h.await.unwrap();
} }
// Wait for all blocking writes to flush // Event-driven wait for blocking writes to flush
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; store.flushed.notified().await;
assert_eq!(store.read_with(|s| s.value), 50); assert_eq!(store.read_with(|s| s.value), 50);
} }
+2
View File
@@ -1157,6 +1157,8 @@ pub struct TasksTool {
pub git_branch: Option<String>, pub git_branch: Option<String>,
/// Acceptance criteria (required for 'set_criteria'). /// Acceptance criteria (required for 'set_criteria').
pub criteria: Option<Vec<String>>, pub criteria: Option<Vec<String>>,
/// Verification proof or details (optional for 'verify').
pub proof: Option<String>,
/// Summary level: 'compact', 'detailed', or 'full' (for 'list'). /// Summary level: 'compact', 'detailed', or 'full' (for 'list').
pub summary_level: Option<String>, pub summary_level: Option<String>,
/// Maximum tokens budget cap (for 'list'). /// Maximum tokens budget cap (for 'list').
+1 -1
View File
@@ -13,7 +13,7 @@ pub fn spawn_watcher(_state: Arc<MemoryState>) {
let mut watcher = match RecommendedWatcher::new( let mut watcher = match RecommendedWatcher::new(
move |res| { move |res| {
let _ = tx.blocking_send(res); let _ = tx.try_send(res);
}, },
Config::default(), Config::default(),
) { ) {
-2
View File
@@ -139,13 +139,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
tokio::select! { tokio::select! {
_ = shutdown_rx.recv() => { _ = shutdown_rx.recv() => {
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
tracing::info!("Shutdown received while connected"); tracing::info!("Shutdown received while connected");
break; break;
} }
_ = &mut send_task => { _ = &mut send_task => {
tracing::error!("Send task exited"); tracing::error!("Send task exited");
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
recv_task.abort(); recv_task.abort();
} }
_ = &mut recv_task => { _ = &mut recv_task => {