Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 43 additions & 34 deletions src/viewer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1467,37 +1467,44 @@ <h1>agentmemory</h1>
}
}

var dashboardLoadPromise = null;
var dashboardRefreshPending = false;
async function loadDashboard() {
if (dashboardLoadPromise) return dashboardLoadPromise;
dashboardLoadPromise = (async function() {
do {
dashboardRefreshPending = false;
await loadDashboardOnce();
} while (dashboardRefreshPending);
})();
try {
await dashboardLoadPromise;
} finally {
dashboardLoadPromise = null;
}
}

async function loadDashboardOnce() {
var el = document.getElementById('view-dashboard');
if (!state.dashboard.loaded) el.innerHTML = '<div class="loading">Loading dashboard...</div>';
try {
var results = await Promise.all([
api('health', { readErrorBody: true }),
apiGet('sessions'),
apiGet('memories?latest=true&limit=500'),
apiGet('graph/stats'),
apiGet('audit?limit=5'),
apiGet('semantic'),
apiGet('procedural'),
apiGet('relations'),
apiGet('lessons'),
apiGet('crystals')
]);
var results = [
await api('health', { readErrorBody: true }),
await apiGet('sessions'),
await apiGet('memories?latest=true&limit=500'),
await apiGet('graph/stats'),
await apiGet('audit?limit=5')
];
state.dashboard.health = results[0];
state.dashboard.sessions = (results[1] && results[1].sessions) || [];
state.dashboard.memories = (results[2] && results[2].memories) || [];
state.dashboard.graphStats = results[3];
state.dashboard.recentAudit = (results[4] && results[4].entries) || [];
state.dashboard.semantic = (results[5] && results[5].facts) || (results[5] && results[5].semantic) || [];
state.dashboard.procedural = (results[6] && results[6].procedures) || (results[6] && results[6].procedural) || [];
state.dashboard.lessons = (results[8] && results[8].lessons) || [];
state.dashboard.crystals = (results[9] && results[9].crystals) || [];
state.dashboard.relations = (results[7] && results[7].relations) || [];
state.dashboard.loaded = true;
renderDashboard();
} catch (err) {
// Without this catch, any uncaught error in the await Promise.all
// or the renderDashboard call leaves the dashboard stuck on
// Without this catch, an uncaught request or render error leaves
// the dashboard stuck on
// "Loading dashboard..." forever with no indication to the user
// (#323). apiGet() already swallows network/HTTP errors and
// returns null, but renderDashboard can still throw on shape
Expand Down Expand Up @@ -1545,10 +1552,8 @@ <h1>agentmemory</h1>
html += '<div class="stats-grid">';
html += '<div class="stat-card" data-action="goto-tab" data-tab="sessions" role="link" tabindex="0"><div class="label">Sessions</div><div class="value">' + d.sessions.length + '</div><div class="sub">' + activeSessions + ' active</div></div>';
html += '<div class="stat-card" data-action="goto-tab" data-tab="memories" role="link" tabindex="0"><div class="label">Memories</div><div class="value">' + d.memories.length + '</div><div class="sub">latest versions</div></div>';
var lessonCount = (d.lessons || []).length;
var crystalCount = (d.crystals || []).length;
html += '<div class="stat-card" data-action="goto-tab" data-tab="lessons" role="link" tabindex="0"><div class="label">Lessons</div><div class="value">' + lessonCount + '</div><div class="sub">confidence-scored</div></div>';
html += '<div class="stat-card" data-action="goto-tab" data-tab="crystals" role="link" tabindex="0"><div class="label">Crystals</div><div class="value">' + crystalCount + '</div><div class="sub">action digests</div></div>';
html += '<div class="stat-card" data-action="goto-tab" data-tab="lessons" role="link" tabindex="0"><div class="label">Lessons</div><div class="value">&mdash;</div><div class="sub">load tab to view</div></div>';
html += '<div class="stat-card" data-action="goto-tab" data-tab="crystals" role="link" tabindex="0"><div class="label">Crystals</div><div class="value">&mdash;</div><div class="sub">load tab to view</div></div>';
html += '<div class="stat-card" data-action="goto-tab" data-tab="graph" role="link" tabindex="0"><div class="label">Graph Nodes</div><div class="value">' + nodeCount + '</div><div class="sub">' + edgeCount + ' edges</div></div>';
html += '<div class="stat-card"><div class="label">Health</div><div class="value"><div class="health-bar"><span class="health-dot ' + dotClass + '"></span> ' + esc(healthStatus) + '</div></div>';
html += '<div class="sub">' + esc(snap.connectionState || 'unknown') + '</div></div>';
Expand Down Expand Up @@ -1706,12 +1711,15 @@ <h1>agentmemory</h1>
var semFacts = d.semantic || [];
var procItems = d.procedural || [];
var relItems = d.relations || [];
var consolidationDeferred = d.semantic === undefined;

html += '<hr class="section-rule">';
html += '<div class="two-col">';

html += '<div class="card"><div class="card-title">Semantic Memory</div>';
if (semFacts.length === 0) {
if (consolidationDeferred) {
html += '<div style="font-size:13px;color:var(--ink-faint);font-style:italic;">Not loaded on the dashboard to keep large memory stores responsive.</div>';
} else if (semFacts.length === 0) {
html += '<div style="font-size:13px;color:var(--ink-faint);font-style:italic;">No semantic facts yet. Observations will be consolidated into semantic memories over time.</div>';
} else {
semFacts.slice(0, 5).forEach(function(f) {
Expand All @@ -1729,7 +1737,9 @@ <h1>agentmemory</h1>
html += '</div>';

html += '<div class="card"><div class="card-title">Procedural Memory</div>';
if (procItems.length === 0) {
if (consolidationDeferred) {
html += '<div style="font-size:13px;color:var(--ink-faint);font-style:italic;">Not loaded on the dashboard to keep large memory stores responsive.</div>';
} else if (procItems.length === 0) {
html += '<div style="font-size:13px;color:var(--ink-faint);font-style:italic;">No procedures yet. Repeated patterns will be extracted as procedures.</div>';
} else {
procItems.slice(0, 5).forEach(function(p) {
Expand All @@ -1751,10 +1761,10 @@ <h1>agentmemory</h1>
html += '</div>';

html += '<div class="card" style="margin-top:16px;"><div class="card-title">Consolidation Status</div>';
html += '<div class="consolidation-row"><span class="cl">Semantic facts</span><span class="cv">' + semFacts.length + '</span></div>';
html += '<div class="consolidation-row"><span class="cl">Procedures</span><span class="cv">' + procItems.length + '</span></div>';
html += '<div class="consolidation-row"><span class="cl">Relations</span><span class="cv">' + relItems.length + '</span></div>';
if (semFacts.length === 0 && procItems.length === 0 && relItems.length === 0) {
html += '<div class="consolidation-row"><span class="cl">Semantic facts</span><span class="cv">' + (consolidationDeferred ? '&mdash;' : semFacts.length) + '</span></div>';
html += '<div class="consolidation-row"><span class="cl">Procedures</span><span class="cv">' + (consolidationDeferred ? '&mdash;' : procItems.length) + '</span></div>';
html += '<div class="consolidation-row"><span class="cl">Relations</span><span class="cv">' + (consolidationDeferred ? '&mdash;' : relItems.length) + '</span></div>';
if (!consolidationDeferred && semFacts.length === 0 && procItems.length === 0 && relItems.length === 0) {
html += '<div style="font-size:12px;color:var(--ink-faint);margin-top:8px;line-height:1.5;">Consolidation distills session observations into durable facts and repeatable procedures. It runs on a schedule when <code>CONSOLIDATION_ENABLED=true</code> and an LLM provider key are set, or on demand via <code>memory_consolidate</code>.</div>';
}
html += '</div>';
Expand Down Expand Up @@ -1782,7 +1792,8 @@ <h1>agentmemory</h1>
var dashboardTimer = null;
function refreshDashboard() {
state.dashboard.loaded = false;
loadDashboard();
if (dashboardLoadPromise) dashboardRefreshPending = true;
return loadDashboard();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
function startDashboardAutoRefresh() {
if (dashboardTimer) clearInterval(dashboardTimer);
Expand Down Expand Up @@ -3799,8 +3810,7 @@ <h1>agentmemory</h1>
pollTimer = setInterval(function() {
tick++;
if (state.activeTab === 'dashboard') {
state.dashboard.loaded = false;
loadDashboard();
refreshDashboard();
} else if (state.activeTab === 'memories') {
state.memories.loaded = false;
loadMemories();
Expand Down Expand Up @@ -3964,8 +3974,7 @@ <h1>agentmemory</h1>
}
}
if (state.activeTab === 'dashboard') {
state.dashboard.loaded = false;
loadDashboard();
refreshDashboard();
}
if (state.activeTab === 'activity' && msg.observation) {
state.activity.observations.unshift(msg.observation);
Expand Down
101 changes: 99 additions & 2 deletions test/viewer-session-id.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as vm from "node:vm";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { renderViewerDocument } from "../src/viewer/document.js";

function htmlEscape(value: string): string {
Expand Down Expand Up @@ -166,17 +166,114 @@ function loadViewerSandbox() {
};

const scriptWithoutAutoStart = scriptMatch[1].replace(
/\n\s*loadTab\('dashboard'\);\n\s*connectWs\(\);\n\s*startDashboardAutoRefresh\(\);\s*$/,
/\n switchTab\(tabFromRoute\(\), \{ replaceRoute: true \}\);\n \/\/ Resolve[\s\S]*?\n startDashboardAutoRefresh\(\);/,
"\n",
);
expect(scriptWithoutAutoStart).not.toBe(scriptMatch[1]);

vm.createContext(sandbox);
vm.runInContext(scriptWithoutAutoStart, sandbox);

return { sandbox, getElement };
}

const DASHBOARD_PATHS = [
"/agentmemory/health",
"/agentmemory/sessions",
"/agentmemory/memories",
"/agentmemory/graph/stats",
"/agentmemory/audit",
] as const;

describe("viewer session rendering", () => {
it("deduplicates dashboard loads and fetches every endpoint serially", async () => {
const { sandbox } = loadViewerSandbox();
let active = 0;
let maxActive = 0;
const paths: string[] = [];
sandbox.fetch = async (url: string) => {
active += 1;
maxActive = Math.max(maxActive, active);
paths.push(new URL(url).pathname);
await Promise.resolve();
active -= 1;
return { ok: true, json: async () => ({}) };
};

await Promise.all([sandbox.loadDashboard(), sandbox.loadDashboard()]);

expect(maxActive).toBe(1);
expect(paths).toEqual(DASHBOARD_PATHS);
});

it("runs one pending refresh after an in-flight dashboard load", async () => {
const { sandbox } = loadViewerSandbox();
const paths: string[] = [];
let active = 0;
let maxActive = 0;
let releaseSessions = () => {
throw new Error("sessions request did not start");
};
let sessionsStarted = false;
sandbox.fetch = async (url: string) => {
active += 1;
maxActive = Math.max(maxActive, active);
paths.push(new URL(url).pathname);
if (paths.length === 2) {
await new Promise<void>((resolve) => {
releaseSessions = resolve;
sessionsStarted = true;
});
}
active -= 1;
return { ok: true, json: async () => ({}) };
};

const initialLoad = sandbox.loadDashboard();
await vi.waitFor(() => expect(sessionsStarted).toBe(true), { timeout: 1000 });
const pendingRefresh = sandbox.refreshDashboard();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
releaseSessions();
await pendingRefresh;
const pathsWhenRefreshResolved = paths.slice();
await initialLoad;

expect(maxActive).toBe(1);
expect(pathsWhenRefreshResolved).toEqual([...DASHBOARD_PATHS, ...DASHBOARD_PATHS]);
expect(paths).toEqual([...DASHBOARD_PATHS, ...DASHBOARD_PATHS]);
});

it("starts a fresh dashboard cycle after a failed load", async () => {
const { sandbox } = loadViewerSandbox();
const getElementById = sandbox.document.getElementById;
sandbox.document.getElementById = () => null;

await expect(sandbox.loadDashboard()).rejects.toThrow();

sandbox.document.getElementById = getElementById;
const paths: string[] = [];
sandbox.fetch = async (url: string) => {
paths.push(new URL(url).pathname);
return { ok: true, json: async () => ({}) };
};
await sandbox.loadDashboard();

expect(paths).toEqual(DASHBOARD_PATHS);
});

it("marks lesson and crystal counts as deferred", () => {
const { sandbox, getElement } = loadViewerSandbox();

sandbox.renderDashboard();

const html = getElement("view-dashboard").innerHTML;
expect(html).toContain(
'<div class="label">Lessons</div><div class="value">&mdash;</div><div class="sub">load tab to view</div>',
);
expect(html).toContain(
'<div class="label">Crystals</div><div class="value">&mdash;</div><div class="sub">load tab to view</div>',
);
});

Comment thread
coderabbitai[bot] marked this conversation as resolved.
it("attaches the saved viewer bearer to API calls", async () => {
const { sandbox } = loadViewerSandbox();
const requests: Array<{ url: string; opts: { headers?: Record<string, string> } }> = [];
Expand Down