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

+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"] });