feat(reconciliation): implement deterministic state reconciliation engine and post-commit hook
- Implement ReconciliationEngine in server/src/handlers/reconciliation.rs - Auto-transition ADRs to implemented, resolve tech debts, and cascade task unblocking - Ingest git commit events via POST /api/git/commit and scripts/git-reconcile.py post-commit hook - Add universal pagination, search filtering, and keyboard navigation to dashboard - Implement non-destructive task TTL expiry sweeper and gate verification - Implements: ADR-0102, ADR-0103
This commit is contained in:
1 parent
ec977c24dd
commit
8f924b793a
16 files changed
+2622
-163
No files matched your search
+665
-47
@@ -157,10 +157,10 @@ function toggleFilter(type) {
|
||||
updateGraphData();
|
||||
}
|
||||
function renderFilters() {
|
||||
const container2 = document.getElementById("graph-filters");
|
||||
if (!container2)
|
||||
const container = document.getElementById("graph-filters");
|
||||
if (!container)
|
||||
return;
|
||||
container2.innerHTML = Array.from(allTypes).sort().map((type) => {
|
||||
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}')">
|
||||
@@ -227,8 +227,8 @@ async function loadGraph() {
|
||||
rawRelations = data.relations || [];
|
||||
updateGraphData();
|
||||
if (!network) {
|
||||
const container2 = document.getElementById("network-container");
|
||||
if (!container2)
|
||||
const container = document.getElementById("network-container");
|
||||
if (!container)
|
||||
return;
|
||||
const options = {
|
||||
nodes: { shape: "dot", size: 16, font: { size: 12 } },
|
||||
@@ -257,7 +257,7 @@ async function loadGraph() {
|
||||
},
|
||||
interaction: { hover: true, tooltipDelay: 100, zoomView: false }
|
||||
};
|
||||
network = new vis.Network(container2, { nodes: nodesData, edges: edgesData }, options);
|
||||
network = new vis.Network(container, { nodes: nodesData, edges: edgesData }, options);
|
||||
network.once("stabilizationIterationsDone", function() {
|
||||
network.setOptions({ physics: { enabled: false } });
|
||||
});
|
||||
@@ -267,7 +267,7 @@ async function loadGraph() {
|
||||
network.on("dragEnd", function() {
|
||||
network.setOptions({ physics: { enabled: false } });
|
||||
});
|
||||
container2.addEventListener("wheel", function(event) {
|
||||
container.addEventListener("wheel", function(event) {
|
||||
event.preventDefault();
|
||||
const direction = event.deltaY > 0 ? -0.15 : 0.15;
|
||||
zoomGraph(direction);
|
||||
@@ -283,9 +283,9 @@ async function loadGraph() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load graph", err);
|
||||
const container2 = document.getElementById("network-container");
|
||||
if (container2) {
|
||||
container2.innerHTML = '<div style="color:#e74c3c;padding:20px;font-weight:bold;">Error loading graph: ' + err.message + "</div>";
|
||||
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>";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -414,19 +414,54 @@ function buildTaskTreeHTML(tasks, parentId, depth = 0) {
|
||||
});
|
||||
return html;
|
||||
}
|
||||
var cachedTasks = [];
|
||||
var taskFilterQuery = "";
|
||||
var taskStatusFilter = "";
|
||||
function handleTaskFilter(val) {
|
||||
taskFilterQuery = val.toLowerCase().trim();
|
||||
applyTaskFilters();
|
||||
}
|
||||
function handleTaskStatusFilter(val) {
|
||||
taskStatusFilter = val.toLowerCase().trim();
|
||||
applyTaskFilters();
|
||||
}
|
||||
function applyTaskFilters() {
|
||||
const taskContainer = document.getElementById("task-tree-container");
|
||||
if (!taskContainer)
|
||||
return;
|
||||
let filtered = cachedTasks;
|
||||
if (taskFilterQuery) {
|
||||
filtered = filtered.filter((t) => t.title && t.title.toLowerCase().includes(taskFilterQuery) || t.description && t.description.toLowerCase().includes(taskFilterQuery) || t.id && t.id.toLowerCase().includes(taskFilterQuery) || t.assigned_agent && t.assigned_agent.toLowerCase().includes(taskFilterQuery));
|
||||
}
|
||||
if (taskStatusFilter) {
|
||||
if (taskStatusFilter === "blocked") {
|
||||
filtered = filtered.filter((t) => {
|
||||
const deps = t.dependencies || [];
|
||||
return deps.some((dId) => {
|
||||
const dt = cachedTasks.find((x) => x.id === dId);
|
||||
return dt && dt.status !== "completed" && dt.status !== "done";
|
||||
});
|
||||
});
|
||||
} else {
|
||||
filtered = filtered.filter((t) => (t.status || "pending").toLowerCase() === taskStatusFilter);
|
||||
}
|
||||
}
|
||||
const rootHtml = buildTaskTreeHTML(filtered, null, 0);
|
||||
if (!rootHtml) {
|
||||
if (cachedTasks.length === 0) {
|
||||
taskContainer.innerHTML = '<div class="enterprise-empty-state">No active tasks.</div>';
|
||||
} else {
|
||||
taskContainer.innerHTML = '<div class="enterprise-empty-state">No tasks matching current filter.</div>';
|
||||
}
|
||||
} else {
|
||||
taskContainer.innerHTML = rootHtml;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
cachedTasks = await res.json();
|
||||
applyTaskFilters();
|
||||
} catch (err) {
|
||||
console.error("Failed to load tasks", err);
|
||||
}
|
||||
@@ -462,14 +497,14 @@ async function handleSearch(e) {
|
||||
signal: activeSearchAbortController.signal
|
||||
});
|
||||
const data = await res.json();
|
||||
const container2 = document.getElementById("search-results");
|
||||
if (!container2)
|
||||
const container = document.getElementById("search-results");
|
||||
if (!container)
|
||||
return;
|
||||
if (!data.results || data.results.length === 0) {
|
||||
container2.innerHTML = '<div style="padding:15px;color:var(--text-secondary);">No results found.</div>';
|
||||
container.innerHTML = '<div style="padding:15px;color:var(--text-secondary);">No results found.</div>';
|
||||
return;
|
||||
}
|
||||
container2.innerHTML = data.results.map((r) => `
|
||||
container.innerHTML = data.results.map((r) => `
|
||||
<div class="search-result-item" onclick="switchTab('graph-tab'); if(rawEntities['${r.id}']){ showInspector('${r.id}'); if(network){ 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>
|
||||
@@ -775,39 +810,341 @@ function setupWS() {
|
||||
setupSSE();
|
||||
};
|
||||
}
|
||||
async function loadGenericList(endpoint, containerId, formatter) {
|
||||
var listControllers = new Map;
|
||||
function applyListFiltering(containerId) {
|
||||
const state = listControllers.get(containerId);
|
||||
if (!state)
|
||||
return;
|
||||
let result = state.items.slice();
|
||||
const q = (state.searchQuery || "").trim().toLowerCase();
|
||||
if (q) {
|
||||
result = result.filter((item) => {
|
||||
if (state.options.searchFields) {
|
||||
const fields = state.options.searchFields(item);
|
||||
return fields.some((f) => f && String(f).toLowerCase().includes(q));
|
||||
}
|
||||
return JSON.stringify(item).toLowerCase().includes(q);
|
||||
});
|
||||
}
|
||||
if (state.options.filters) {
|
||||
for (const filterDef of state.options.filters) {
|
||||
const activeVal = state.activeFilters[filterDef.id];
|
||||
if (activeVal !== undefined && activeVal !== "") {
|
||||
result = result.filter((item) => filterDef.predicate(item, activeVal));
|
||||
}
|
||||
}
|
||||
}
|
||||
const factor = state.sortOrder === "desc" ? -1 : 1;
|
||||
const extractor = state.options.sortExtractor || ((item) => {
|
||||
if (state.options.timestampExtractor) {
|
||||
const ts = state.options.timestampExtractor(item);
|
||||
if (ts != null)
|
||||
return ts;
|
||||
}
|
||||
return item.timestamp || item.created_at || item.id || 0;
|
||||
});
|
||||
result.sort((a, b) => {
|
||||
const valA = extractor(a);
|
||||
const valB = extractor(b);
|
||||
if (typeof valA === "number" && typeof valB === "number") {
|
||||
return (valA - valB) * factor;
|
||||
}
|
||||
return String(valA).localeCompare(String(valB)) * factor;
|
||||
});
|
||||
state.filteredItems = result;
|
||||
}
|
||||
function renderListCardsAndPagination(containerId) {
|
||||
const state = listControllers.get(containerId);
|
||||
if (!state)
|
||||
return;
|
||||
const totalItems = state.filteredItems.length;
|
||||
const pageSize = state.pageSize;
|
||||
const totalPages = pageSize <= 0 ? 1 : Math.max(1, Math.ceil(totalItems / pageSize));
|
||||
if (state.currentPage > totalPages) {
|
||||
state.currentPage = totalPages;
|
||||
}
|
||||
if (state.currentPage < 1) {
|
||||
state.currentPage = 1;
|
||||
}
|
||||
const startIndex = totalItems === 0 ? 0 : (state.currentPage - 1) * (pageSize > 0 ? pageSize : totalItems) + 1;
|
||||
const endIndex = pageSize <= 0 ? totalItems : Math.min(state.currentPage * pageSize, totalItems);
|
||||
const metaEl = document.getElementById(`${containerId}-meta`);
|
||||
if (metaEl) {
|
||||
const itemNoun = state.options.itemNoun || "items";
|
||||
let summaryText = "";
|
||||
if (totalItems === 0) {
|
||||
summaryText = `0 ${itemNoun}`;
|
||||
} else {
|
||||
summaryText = `Showing <strong>${startIndex}–${endIndex}</strong> of <strong>${totalItems}</strong> ${itemNoun}`;
|
||||
}
|
||||
if (totalItems !== state.items.length) {
|
||||
summaryText += ` <span style="color:var(--text-secondary);">(filtered from ${state.items.length} total)</span>`;
|
||||
}
|
||||
metaEl.innerHTML = `<div>${summaryText}</div><div>Page <strong>${state.currentPage}</strong> of <strong>${totalPages}</strong></div>`;
|
||||
}
|
||||
const cardsEl = document.getElementById(`${containerId}-cards`);
|
||||
if (cardsEl) {
|
||||
if (totalItems === 0) {
|
||||
const itemNoun = state.options.itemNoun || "items";
|
||||
if (state.items.length === 0) {
|
||||
cardsEl.innerHTML = `<div class="enterprise-empty-state">No ${itemNoun} recorded.</div>`;
|
||||
} else {
|
||||
cardsEl.innerHTML = `
|
||||
<div class="enterprise-empty-state">
|
||||
<div>\uD83D\uDD0D No matching ${itemNoun} found for current filter.</div>
|
||||
<button class="enterprise-btn" style="margin-top:12px;" onclick="onListResetFilters('${containerId}')">Reset Filters</button>
|
||||
</div>`;
|
||||
}
|
||||
} else {
|
||||
const pageSlice = pageSize <= 0 ? state.filteredItems : state.filteredItems.slice((state.currentPage - 1) * pageSize, state.currentPage * pageSize);
|
||||
cardsEl.innerHTML = pageSlice.map((item) => {
|
||||
const ts = state.options.timestampExtractor ? state.options.timestampExtractor(item) : item.timestamp || item.created_at || null;
|
||||
const dateStr = ts ? new Date(ts * 1000).toLocaleString() : "";
|
||||
const borderCol = state.options.cardBorderColor || "#9b59b6";
|
||||
return `
|
||||
<div class="task-card" style="border-left-color: ${borderCol};">
|
||||
${dateStr ? `<div style="font-size:0.75em; color:var(--text-secondary); float:right;">${dateStr}</div>` : ""}
|
||||
${state.formatter(item)}
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
}
|
||||
const paginationEl = document.getElementById(`${containerId}-pagination`);
|
||||
if (paginationEl) {
|
||||
if (totalPages <= 1) {
|
||||
paginationEl.style.display = "none";
|
||||
paginationEl.innerHTML = "";
|
||||
} else {
|
||||
paginationEl.style.display = "flex";
|
||||
let pagHtml = "";
|
||||
pagHtml += `<button onclick="onListPageChange('${containerId}', 1)" ${state.currentPage === 1 ? "disabled" : ""} title="First Page">⏮</button>`;
|
||||
pagHtml += `<button onclick="onListPageChange('${containerId}', ${state.currentPage - 1})" ${state.currentPage === 1 ? "disabled" : ""} title="Previous Page">◀</button>`;
|
||||
const cur = state.currentPage;
|
||||
const startP = Math.max(1, cur - 2);
|
||||
const endP = Math.min(totalPages, cur + 2);
|
||||
if (startP > 1) {
|
||||
pagHtml += `<button onclick="onListPageChange('${containerId}', 1)">1</button>`;
|
||||
if (startP > 2)
|
||||
pagHtml += `<span style="padding:0 4px; color:var(--text-secondary);">…</span>`;
|
||||
}
|
||||
for (let p = startP;p <= endP; p++) {
|
||||
pagHtml += `<button class="${p === cur ? "active" : ""}" onclick="onListPageChange('${containerId}', ${p})">${p}</button>`;
|
||||
}
|
||||
if (endP < totalPages) {
|
||||
if (endP < totalPages - 1)
|
||||
pagHtml += `<span style="padding:0 4px; color:var(--text-secondary);">…</span>`;
|
||||
pagHtml += `<button onclick="onListPageChange('${containerId}', ${totalPages})">${totalPages}</button>`;
|
||||
}
|
||||
pagHtml += `<button onclick="onListPageChange('${containerId}', ${state.currentPage + 1})" ${state.currentPage === totalPages ? "disabled" : ""} title="Next Page">▶</button>`;
|
||||
pagHtml += `<button onclick="onListPageChange('${containerId}', ${totalPages})" ${state.currentPage === totalPages ? "disabled" : ""} title="Last Page">⏭</button>`;
|
||||
paginationEl.innerHTML = pagHtml;
|
||||
}
|
||||
}
|
||||
const clearBtn = document.getElementById(`${containerId}-clear`);
|
||||
if (clearBtn) {
|
||||
clearBtn.style.display = state.searchQuery ? "block" : "none";
|
||||
}
|
||||
const sortBtn = document.getElementById(`${containerId}-sort-btn`);
|
||||
if (sortBtn) {
|
||||
sortBtn.innerHTML = state.sortOrder === "desc" ? "⬇️ Newest First" : "⬆️ Oldest First";
|
||||
}
|
||||
}
|
||||
function renderListStructure(containerId) {
|
||||
const state = listControllers.get(containerId);
|
||||
if (!state)
|
||||
return;
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container)
|
||||
return;
|
||||
const itemNoun = state.options.itemNoun || "items";
|
||||
let filtersHtml = "";
|
||||
if (state.options.filters && state.options.filters.length > 0) {
|
||||
for (const f of state.options.filters) {
|
||||
const activeVal = state.activeFilters[f.id] || "";
|
||||
filtersHtml += `
|
||||
<select class="enterprise-select" id="${containerId}-filter-${f.id}" onchange="onListFilterChange('${containerId}', '${f.id}', this.value)" title="Filter by ${f.label}">
|
||||
${f.options.map((opt) => `<option value="${escapeHtml(opt.value)}" ${opt.value === activeVal ? "selected" : ""}>${escapeHtml(opt.label)}</option>`).join("")}
|
||||
</select>`;
|
||||
}
|
||||
}
|
||||
const curPageSize = state.pageSize;
|
||||
const pageSizeOptions = [
|
||||
{ label: "10 / page", value: "10" },
|
||||
{ label: "25 / page", value: "25" },
|
||||
{ label: "50 / page", value: "50" },
|
||||
{ label: "100 / page", value: "100" },
|
||||
{ label: "All items", value: "-1" }
|
||||
];
|
||||
const pageSizeHtml = `
|
||||
<select class="enterprise-select" id="${containerId}-pagesize" onchange="onListPageSizeChange('${containerId}', this.value)" title="Items per page">
|
||||
${pageSizeOptions.map((opt) => `<option value="${opt.value}" ${parseInt(opt.value, 10) === curPageSize ? "selected" : ""}>${opt.label}</option>`).join("")}
|
||||
</select>`;
|
||||
container.innerHTML = `
|
||||
<div class="enterprise-toolbar" id="${containerId}-toolbar">
|
||||
<div class="enterprise-search-wrap">
|
||||
<span class="enterprise-search-icon">\uD83D\uDD0D</span>
|
||||
<input
|
||||
type="text"
|
||||
class="enterprise-search-input"
|
||||
id="${containerId}-search"
|
||||
placeholder="Filter ${itemNoun} (/)..."
|
||||
value="${escapeHtml(state.searchQuery)}"
|
||||
oninput="onListSearchInput('${containerId}', this.value)"
|
||||
/>
|
||||
<button
|
||||
class="enterprise-clear-btn"
|
||||
id="${containerId}-clear"
|
||||
onclick="onListClearSearch('${containerId}')"
|
||||
title="Clear search"
|
||||
style="display:${state.searchQuery ? "block" : "none"};"
|
||||
>✕</button>
|
||||
</div>
|
||||
<div class="enterprise-filters-group">
|
||||
${filtersHtml}
|
||||
<button
|
||||
class="enterprise-btn"
|
||||
id="${containerId}-sort-btn"
|
||||
onclick="onListToggleSort('${containerId}')"
|
||||
title="Toggle sort direction"
|
||||
>
|
||||
${state.sortOrder === "desc" ? "⬇️ Newest First" : "⬆️ Oldest First"}
|
||||
</button>
|
||||
${pageSizeHtml}
|
||||
<button
|
||||
class="enterprise-btn"
|
||||
onclick="onListResetFilters('${containerId}')"
|
||||
title="Reset all filters and sorting"
|
||||
>
|
||||
\uD83D\uDD04 Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="enterprise-meta-bar" id="${containerId}-meta"></div>
|
||||
<div class="enterprise-cards-list" id="${containerId}-cards"></div>
|
||||
<div class="enterprise-pagination" id="${containerId}-pagination"></div>
|
||||
`;
|
||||
renderListCardsAndPagination(containerId);
|
||||
}
|
||||
function onListSearchInput(containerId, value) {
|
||||
const state = listControllers.get(containerId);
|
||||
if (!state)
|
||||
return;
|
||||
if (state.debounceTimer)
|
||||
clearTimeout(state.debounceTimer);
|
||||
state.searchQuery = value;
|
||||
state.debounceTimer = setTimeout(() => {
|
||||
state.currentPage = 1;
|
||||
applyListFiltering(containerId);
|
||||
renderListCardsAndPagination(containerId);
|
||||
}, 150);
|
||||
}
|
||||
function onListClearSearch(containerId) {
|
||||
const state = listControllers.get(containerId);
|
||||
if (!state)
|
||||
return;
|
||||
state.searchQuery = "";
|
||||
const inputEl = document.getElementById(`${containerId}-search`);
|
||||
if (inputEl) {
|
||||
inputEl.value = "";
|
||||
inputEl.focus();
|
||||
}
|
||||
state.currentPage = 1;
|
||||
applyListFiltering(containerId);
|
||||
renderListCardsAndPagination(containerId);
|
||||
}
|
||||
function onListFilterChange(containerId, filterId, value) {
|
||||
const state = listControllers.get(containerId);
|
||||
if (!state)
|
||||
return;
|
||||
state.activeFilters[filterId] = value;
|
||||
state.currentPage = 1;
|
||||
applyListFiltering(containerId);
|
||||
renderListCardsAndPagination(containerId);
|
||||
}
|
||||
function onListToggleSort(containerId) {
|
||||
const state = listControllers.get(containerId);
|
||||
if (!state)
|
||||
return;
|
||||
state.sortOrder = state.sortOrder === "desc" ? "asc" : "desc";
|
||||
applyListFiltering(containerId);
|
||||
renderListCardsAndPagination(containerId);
|
||||
}
|
||||
function onListPageSizeChange(containerId, value) {
|
||||
const state = listControllers.get(containerId);
|
||||
if (!state)
|
||||
return;
|
||||
state.pageSize = parseInt(value, 10);
|
||||
state.currentPage = 1;
|
||||
renderListCardsAndPagination(containerId);
|
||||
}
|
||||
function onListPageChange(containerId, page) {
|
||||
const state = listControllers.get(containerId);
|
||||
if (!state)
|
||||
return;
|
||||
state.currentPage = page;
|
||||
renderListCardsAndPagination(containerId);
|
||||
const container = document.getElementById(containerId);
|
||||
if (container) {
|
||||
container.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
}
|
||||
function onListResetFilters(containerId) {
|
||||
const state = listControllers.get(containerId);
|
||||
if (!state)
|
||||
return;
|
||||
state.searchQuery = "";
|
||||
state.activeFilters = {};
|
||||
state.sortOrder = state.options.defaultSort || "desc";
|
||||
state.pageSize = state.options.pageSize || 25;
|
||||
state.currentPage = 1;
|
||||
applyListFiltering(containerId);
|
||||
renderListStructure(containerId);
|
||||
}
|
||||
async function loadGenericList(endpoint, containerId, formatter, options) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container)
|
||||
return;
|
||||
const resolvedOptions = options || {};
|
||||
const existingState = listControllers.get(containerId);
|
||||
const state = existingState || {
|
||||
endpoint,
|
||||
containerId,
|
||||
formatter,
|
||||
options: resolvedOptions,
|
||||
items: [],
|
||||
filteredItems: [],
|
||||
searchQuery: "",
|
||||
activeFilters: {},
|
||||
sortOrder: resolvedOptions.defaultSort || "desc",
|
||||
currentPage: 1,
|
||||
pageSize: resolvedOptions.pageSize || 25,
|
||||
debounceTimer: null
|
||||
};
|
||||
state.options = resolvedOptions;
|
||||
state.formatter = formatter;
|
||||
listControllers.set(containerId, state);
|
||||
try {
|
||||
const res = await fetch(endpoint);
|
||||
if (!res.ok) {
|
||||
if (container2) {
|
||||
container2.innerHTML = `<div style="color:var(--error-color); padding:20px; text-align:center;">⚠️ Failed to load ${endpoint} (${res.status} ${res.statusText})</div>`;
|
||||
}
|
||||
container.innerHTML = `<div style="color:var(--error-color); padding:20px; text-align:center;">⚠️ Failed to load ${escapeHtml(endpoint)} (${res.status} ${escapeHtml(res.statusText)})</div>`;
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
const container2 = document.getElementById(containerId);
|
||||
if (!container2)
|
||||
return;
|
||||
if (!data || data.length === 0) {
|
||||
container2.innerHTML = '<div style="color:var(--text-secondary); padding:20px; text-align:center;">No items recorded.</div>';
|
||||
return;
|
||||
state.items = Array.isArray(data) ? data : [];
|
||||
applyListFiltering(containerId);
|
||||
const toolbarExists = document.getElementById(`${containerId}-toolbar`);
|
||||
if (!toolbarExists) {
|
||||
renderListStructure(containerId);
|
||||
} else {
|
||||
renderListCardsAndPagination(containerId);
|
||||
}
|
||||
container2.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);
|
||||
if (container) {
|
||||
container.innerHTML = `<div style="color:var(--error-color); padding:20px; text-align:center;">⚠️ Failed to load ${endpoint} (${escapeHtml(String(err))})</div>`;
|
||||
}
|
||||
container.innerHTML = `<div style="color:var(--error-color); padding:20px; text-align:center;">⚠️ Failed to load ${escapeHtml(endpoint)} (${escapeHtml(String(err))})</div>`;
|
||||
}
|
||||
}
|
||||
function loadLedger() {
|
||||
loadGenericList("/api/ledger?limit=100", "ledger-container", (item) => {
|
||||
loadGenericList("/api/ledger?limit=500", "ledger-container", (item) => {
|
||||
const vcs = (item.vcs_type || (item.revision && item.revision.startsWith("r") ? "svn" : "git")).toUpperCase();
|
||||
const vcsColor = vcs === "SVN" ? "#e67e22" : "#3498db";
|
||||
const rev = item.revision || item.git_commit;
|
||||
@@ -848,6 +1185,48 @@ function loadLedger() {
|
||||
${authorBadge}
|
||||
</div>
|
||||
`;
|
||||
}, {
|
||||
itemNoun: "Ledger Entries",
|
||||
cardBorderColor: "#3498db",
|
||||
defaultSort: "desc",
|
||||
timestampExtractor: (item) => item.timestamp,
|
||||
searchFields: (item) => [
|
||||
item.file_path,
|
||||
item.description,
|
||||
item.author,
|
||||
item.repo_name,
|
||||
item.branch,
|
||||
item.git_commit,
|
||||
item.revision,
|
||||
Array.isArray(item.symbols) ? item.symbols.join(" ") : ""
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
id: "vcs",
|
||||
label: "VCS",
|
||||
options: [
|
||||
{ label: "All VCS", value: "" },
|
||||
{ label: "Git", value: "git" },
|
||||
{ label: "SVN", value: "svn" }
|
||||
],
|
||||
predicate: (item, val) => {
|
||||
const vcs = (item.vcs_type || (item.revision && item.revision.startsWith("r") ? "svn" : "git")).toLowerCase();
|
||||
return vcs === val.toLowerCase();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
label: "Change Kind",
|
||||
options: [
|
||||
{ label: "All Types", value: "" },
|
||||
{ label: "+ Added", value: "added" },
|
||||
{ label: "Modified", value: "modified" },
|
||||
{ label: "- Deleted", value: "deleted" },
|
||||
{ label: "→ Renamed", value: "renamed" }
|
||||
],
|
||||
predicate: (item, val) => (item.change_kind || "modified").toLowerCase() === val.toLowerCase()
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
function loadTerminal() {
|
||||
@@ -880,6 +1259,38 @@ function loadTerminal() {
|
||||
</details>
|
||||
` : ""}
|
||||
`;
|
||||
}, {
|
||||
itemNoun: "Commands",
|
||||
cardBorderColor: "#1abc9c",
|
||||
defaultSort: "desc",
|
||||
timestampExtractor: (item) => item.timestamp,
|
||||
searchFields: (item) => [
|
||||
item.command,
|
||||
item.cwd,
|
||||
item.repo_name,
|
||||
item.invoker,
|
||||
item.shell,
|
||||
item.stdout_summary,
|
||||
item.error_output
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
id: "status",
|
||||
label: "Exit Code",
|
||||
options: [
|
||||
{ label: "All Results", value: "" },
|
||||
{ label: "Success (0)", value: "0" },
|
||||
{ label: "Failed (!= 0)", value: "err" }
|
||||
],
|
||||
predicate: (item, val) => {
|
||||
if (val === "0")
|
||||
return item.exit_code === 0;
|
||||
if (val === "err")
|
||||
return item.exit_code !== 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
function loadTechDebt() {
|
||||
@@ -923,6 +1334,50 @@ function loadTechDebt() {
|
||||
<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color); color:var(--text-secondary);">\uD83C\uDFF7️ ${escapeHtml(item.namespace || "global")}</span>
|
||||
</div>
|
||||
`;
|
||||
}, {
|
||||
itemNoun: "Tech Debts",
|
||||
cardBorderColor: "#e67e22",
|
||||
defaultSort: "desc",
|
||||
timestampExtractor: (item) => item.timestamp,
|
||||
searchFields: (item) => [
|
||||
item.id,
|
||||
item.description,
|
||||
item.ideal_solution,
|
||||
item.workaround,
|
||||
item.file_path,
|
||||
item.repo_name,
|
||||
item.severity
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
options: [
|
||||
{ label: "All Statuses", value: "" },
|
||||
{ label: "Open", value: "open" },
|
||||
{ label: "Resolved", value: "resolved" }
|
||||
],
|
||||
predicate: (item, val) => {
|
||||
if (val === "resolved")
|
||||
return !!item.is_resolved;
|
||||
if (val === "open")
|
||||
return !item.is_resolved;
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "severity",
|
||||
label: "Severity",
|
||||
options: [
|
||||
{ label: "All Severities", value: "" },
|
||||
{ label: "Critical", value: "critical" },
|
||||
{ label: "High", value: "high" },
|
||||
{ label: "Medium", value: "medium" },
|
||||
{ label: "Low", value: "low" }
|
||||
],
|
||||
predicate: (item, val) => String(item.severity || "").toLowerCase() === val.toLowerCase()
|
||||
}
|
||||
]
|
||||
});
|
||||
loadGenericList("/api/error_fixes", "errorfixes-container", (item) => `
|
||||
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:8px;">
|
||||
@@ -945,7 +1400,34 @@ function loadTechDebt() {
|
||||
<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; font-family:monospace; border:1px solid var(--border-color);">Commit: ${item.git_commit ? escapeHtml(item.git_commit.substring(0, 8)) : "None"}</span>
|
||||
${item.git_branch ? `<span style="background:var(--canvas-bg); padding:2px 6px; border-radius:4px; border:1px solid var(--border-color);">\uD83C\uDF3F ${escapeHtml(item.git_branch)}</span>` : ""}
|
||||
</div>
|
||||
`);
|
||||
`, {
|
||||
itemNoun: "Error Fixes",
|
||||
cardBorderColor: "#2ecc71",
|
||||
defaultSort: "desc",
|
||||
timestampExtractor: (item) => item.timestamp,
|
||||
searchFields: (item) => [
|
||||
item.signature,
|
||||
item.solution,
|
||||
item.toolchain,
|
||||
item.error_category,
|
||||
item.repo_name,
|
||||
item.stack_trace
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
id: "category",
|
||||
label: "Category",
|
||||
options: [
|
||||
{ label: "All Categories", value: "" },
|
||||
{ label: "Compilation", value: "compilation" },
|
||||
{ label: "Runtime", value: "runtime" },
|
||||
{ label: "Test", value: "test" },
|
||||
{ label: "Configuration", value: "configuration" }
|
||||
],
|
||||
predicate: (item, val) => String(item.error_category || "").toLowerCase() === val.toLowerCase()
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
function loadADRs() {
|
||||
loadGenericList("/api/adrs", "adrs-container", (item) => {
|
||||
@@ -980,6 +1462,41 @@ function loadADRs() {
|
||||
<div style="font-size:0.9em; margin-bottom:8px;"><em>Consequence:</em> ${escapeHtml(item.consequence || "")}</div>
|
||||
${item.supersedes ? `<div style="font-size:0.9em; font-weight:bold; color:var(--warning-color);">Supersedes: ${escapeHtml(item.supersedes)}</div>` : ""}
|
||||
`;
|
||||
}, {
|
||||
itemNoun: "ADRs",
|
||||
cardBorderColor: "#3498db",
|
||||
defaultSort: "desc",
|
||||
sortExtractor: (item) => {
|
||||
const match = String(item.id || "").match(/\d+/);
|
||||
if (match)
|
||||
return parseInt(match[0], 10);
|
||||
return item.timestamp || 0;
|
||||
},
|
||||
searchFields: (item) => [
|
||||
item.id,
|
||||
item.title,
|
||||
item.context,
|
||||
item.decision,
|
||||
item.consequence,
|
||||
item.status,
|
||||
item.git_branch,
|
||||
item.task_id
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
options: [
|
||||
{ label: "All Statuses", value: "" },
|
||||
{ label: "Accepted", value: "accepted" },
|
||||
{ label: "Implemented", value: "implemented" },
|
||||
{ label: "Proposed", value: "proposed" },
|
||||
{ label: "Draft", value: "draft" },
|
||||
{ label: "Superseded", value: "superseded" }
|
||||
],
|
||||
predicate: (item, val) => String(item.status || "").toLowerCase() === val.toLowerCase()
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
function loadMemos() {
|
||||
@@ -1016,6 +1533,18 @@ function loadMemos() {
|
||||
</div>
|
||||
` : ""}
|
||||
`;
|
||||
}, {
|
||||
itemNoun: "Memos",
|
||||
cardBorderColor: "#9b59b6",
|
||||
defaultSort: "desc",
|
||||
timestampExtractor: (item) => item.timestamp,
|
||||
searchFields: (item) => [
|
||||
item.author,
|
||||
item.content,
|
||||
item.summary,
|
||||
item.namespace,
|
||||
item.git_branch
|
||||
]
|
||||
});
|
||||
loadGenericList("/api/session_summaries", "summaries-container", (item) => `
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
@@ -1026,7 +1555,18 @@ function loadMemos() {
|
||||
<div style="white-space:pre-wrap; font-size:0.9em; line-height:1.4; margin-top:8px;">${escapeHtml(item.summary || "")}</div>
|
||||
${item.tasks_completed && item.tasks_completed.length > 0 ? `<div style="margin-top:8px; font-size:0.85em;"><strong>Tasks Completed:</strong><ul style="margin:4px 0 0 16px; padding:0;">${item.tasks_completed.map((t) => `<li>${escapeHtml(t)}</li>`).join("")}</ul></div>` : ""}
|
||||
${item.next_steps && item.next_steps.length > 0 ? `<div style="margin-top:6px; font-size:0.85em;"><strong>Next Steps:</strong><ul style="margin:4px 0 0 16px; padding:0;">${item.next_steps.map((s) => `<li>${escapeHtml(s)}</li>`).join("")}</ul></div>` : ""}
|
||||
`);
|
||||
`, {
|
||||
itemNoun: "Summaries",
|
||||
cardBorderColor: "#34495e",
|
||||
defaultSort: "desc",
|
||||
timestampExtractor: (item) => item.timestamp,
|
||||
searchFields: (item) => [
|
||||
item.session_id,
|
||||
item.summary,
|
||||
item.namespace,
|
||||
item.git_branch
|
||||
]
|
||||
});
|
||||
loadGenericList("/api/milestones", "milestones-container", (item) => {
|
||||
const statusLower = (item.status || "pending").toLowerCase();
|
||||
let statusBg = "rgba(52, 152, 219, 0.15)";
|
||||
@@ -1072,6 +1612,30 @@ function loadMemos() {
|
||||
</div>
|
||||
` : ""}
|
||||
`;
|
||||
}, {
|
||||
itemNoun: "Milestones",
|
||||
cardBorderColor: "#f39c12",
|
||||
defaultSort: "desc",
|
||||
timestampExtractor: (item) => item.timestamp || item.target_date,
|
||||
searchFields: (item) => [
|
||||
item.title,
|
||||
item.name,
|
||||
item.description,
|
||||
item.status
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
options: [
|
||||
{ label: "All Statuses", value: "" },
|
||||
{ label: "Pending", value: "pending" },
|
||||
{ label: "In Progress", value: "in_progress" },
|
||||
{ label: "Completed", value: "completed" }
|
||||
],
|
||||
predicate: (item, val) => String(item.status || "pending").toLowerCase() === val.toLowerCase()
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
function loadSnippets() {
|
||||
@@ -1095,6 +1659,42 @@ function loadSnippets() {
|
||||
${tags.map((t) => `<span style="background:var(--card-bg); padding:1px 6px; border-radius:3px; font-size:0.75em; border:1px solid var(--border-color); color:var(--text-secondary);">#${escapeHtml(t)}</span>`).join(" ")}
|
||||
</div>
|
||||
`;
|
||||
}, {
|
||||
itemNoun: "Snippets",
|
||||
cardBorderColor: "#27ae60",
|
||||
defaultSort: "desc",
|
||||
timestampExtractor: (item) => item.timestamp,
|
||||
searchFields: (item) => [
|
||||
item.name,
|
||||
item.description,
|
||||
item.code,
|
||||
item.language,
|
||||
item.origin_file,
|
||||
Array.isArray(item.tags) ? item.tags.join(" ") : ""
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
id: "language",
|
||||
label: "Language",
|
||||
options: [
|
||||
{ label: "All Languages", value: "" },
|
||||
{ label: "Rust", value: "rust" },
|
||||
{ label: "TypeScript / JS", value: "typescript" },
|
||||
{ label: "Python", value: "python" },
|
||||
{ label: "SQL", value: "sql" },
|
||||
{ label: "Shell / Bash", value: "bash" },
|
||||
{ label: "Markdown / Text", value: "markdown" }
|
||||
],
|
||||
predicate: (item, val) => {
|
||||
const lang = String(item.language || "").toLowerCase();
|
||||
if (val === "typescript")
|
||||
return lang === "typescript" || lang === "javascript" || lang === "ts" || lang === "js";
|
||||
if (val === "bash")
|
||||
return lang === "bash" || lang === "sh" || lang === "pwsh" || lang === "powershell";
|
||||
return lang === val.toLowerCase();
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
function loadAllExtras() {
|
||||
@@ -1177,6 +1777,15 @@ document.addEventListener("keydown", function(e) {
|
||||
searchInput.focus();
|
||||
searchInput.select();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "/" && document.activeElement?.tagName !== "INPUT" && document.activeElement?.tagName !== "TEXTAREA") {
|
||||
const activeSearch = document.querySelector(".tab-content.active .enterprise-search-input, .tab-content.active #task-filter-input");
|
||||
if (activeSearch) {
|
||||
e.preventDefault();
|
||||
activeSearch.focus();
|
||||
activeSearch.select();
|
||||
}
|
||||
}
|
||||
});
|
||||
function refreshActiveTab() {
|
||||
@@ -1225,6 +1834,8 @@ Object.assign(window, {
|
||||
completeTask,
|
||||
buildTaskTreeHTML,
|
||||
loadTasks,
|
||||
handleTaskFilter,
|
||||
handleTaskStatusFilter,
|
||||
highlightText,
|
||||
handleSearch,
|
||||
escapeHtml,
|
||||
@@ -1234,6 +1845,13 @@ Object.assign(window, {
|
||||
handleIncomingActivity,
|
||||
setupWS,
|
||||
loadGenericList,
|
||||
onListSearchInput,
|
||||
onListClearSearch,
|
||||
onListFilterChange,
|
||||
onListToggleSort,
|
||||
onListPageSizeChange,
|
||||
onListPageChange,
|
||||
onListResetFilters,
|
||||
loadTerminal,
|
||||
loadLedger,
|
||||
loadTechDebt,
|
||||
|
||||
Reference in new issue
Block a user