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:
1 parent
4b307b55b9
commit
19f9c7ace7
24 files changed
+2343
-1819
No files matched your search
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
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"] });
|
||||
Reference in new issue
Block a user