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
105 changes: 96 additions & 9 deletions src/viewer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,44 @@
justify-content: flex-end;
gap: 8px;
}
.toast-region {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 220;
width: min(360px, calc(100vw - 32px));
display: flex;
flex-direction: column;
gap: 10px;
pointer-events: none;
}
.toast {
background: var(--bg);
border: 2px solid var(--border);
border-left: 4px solid var(--accent);
box-shadow: 5px 5px 0px 0px var(--border);
padding: 12px 14px;
color: var(--ink);
font-family: var(--font-ui);
pointer-events: auto;
}
.toast-title {
margin-bottom: 4px;
color: var(--accent);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.toast-message {
color: var(--ink-muted);
font-size: 12px;
line-height: 1.45;
word-break: break-word;
}
@media (max-width: 480px) {
.toast-region { left: 12px; right: 12px; bottom: 12px; width: auto; }
}
.selected-node-info {
margin-top: 16px;
padding-top: 16px;
Expand Down Expand Up @@ -981,6 +1019,8 @@ <h1>agentmemory</h1>
<div class="modal" id="modal"></div>
</div>

<div id="toast-region" class="toast-region" role="status" aria-live="polite" aria-atomic="false"></div>

<footer id="viewer-footer" class="viewer-footer">
<span>agentmemory viewer · <span id="footer-version">loading...</span></span>
<span class="footer-sep">·</span>
Expand Down Expand Up @@ -1162,20 +1202,63 @@ <h1>agentmemory</h1>
}
}

var API_TIMEOUT_MS = 10000;
var API_ERROR_TOAST_COOLDOWN_MS = 5000;
var lastApiErrorToastAt = 0;
var toastSeq = 0;

function showToast(title, message) {
var host = document.getElementById('toast-region');
if (!host) return;
var id = 'toast-' + (++toastSeq);
host.setAttribute('data-toast-id', id);
host.innerHTML =
'<div class="toast toast-error" role="alert">' +
'<div class="toast-title">' + esc(title) + '</div>' +
'<div class="toast-message">' + esc(message) + '</div>' +
'</div>';
setTimeout(function() {
if (host.getAttribute('data-toast-id') === id) host.innerHTML = '';
}, 7000);
}

function showApiErrorToast(path, fetchOpts, detail) {
var now = Date.now();
if (now - lastApiErrorToastAt < API_ERROR_TOAST_COOLDOWN_MS) return;
lastApiErrorToastAt = now;
var method = ((fetchOpts && fetchOpts.method) || 'GET').toUpperCase();
showToast('Backend API error', method + ' /agentmemory/' + path + ' - ' + detail);
}

async function api(path, opts) {
var timeoutId = null;
var fetchOpts = null;
try {
var url = REST + '/agentmemory/' + path;
var headers = Object.assign({ 'Cache-Control': 'no-cache' }, (opts && opts.headers) || {});
var fetchOpts = Object.assign({}, opts || {}, { headers: headers });
fetchOpts = Object.assign({}, opts || {}, { headers: headers });
if (typeof AbortController !== 'undefined') {
var controller = new AbortController();
timeoutId = setTimeout(function() { controller.abort(); }, API_TIMEOUT_MS);
fetchOpts.signal = controller.signal;
}
var res = await fetch(url, fetchOpts);
if (!res.ok) {
console.warn('[viewer] API ' + (fetchOpts.method || 'GET') + ' ' + path + ' returned ' + res.status);
var statusText = res.status + (res.statusText ? ' ' + res.statusText : '');
console.warn('[viewer] API ' + (fetchOpts.method || 'GET') + ' ' + path + ' returned ' + statusText);
showApiErrorToast(path, fetchOpts, 'HTTP ' + statusText);
return null;
}
return await res.json();
} catch (err) {
var detail = (err && err.name === 'AbortError')
? 'timed out after ' + (API_TIMEOUT_MS / 1000) + 's'
: ((err && err.message) ? err.message : String(err || 'request failed'));
console.warn('[viewer] API error on ' + path + ':', err);
showApiErrorToast(path, fetchOpts || opts || {}, detail);
return null;
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
}
async function apiGet(path) { return api(path); }
Expand Down Expand Up @@ -1260,9 +1343,14 @@ <h1>agentmemory</h1>
}
}

async function loadDashboard() {
async function loadDashboard(opts) {
opts = opts || {};
var el = document.getElementById('view-dashboard');
el.innerHTML = '<div class="loading">Loading dashboard...</div>';
var preserveScroll = !!opts.preserveScroll;
var priorScrollTop = preserveScroll ? el.scrollTop : 0;
if (!preserveScroll || !el.innerHTML) {
el.innerHTML = '<div class="loading">Loading dashboard...</div>';
}
try {
var results = await Promise.all([
apiGet('health'),
Expand All @@ -1288,6 +1376,7 @@ <h1>agentmemory</h1>
state.dashboard.relations = (results[7] && results[7].relations) || [];
state.dashboard.loaded = true;
renderDashboard();
if (preserveScroll) el.scrollTop = priorScrollTop;
} catch (err) {
// Without this catch, any uncaught error in the await Promise.all
// or the renderDashboard call leaves the dashboard stuck on
Expand Down Expand Up @@ -1571,7 +1660,7 @@ <h1>agentmemory</h1>
var dashboardTimer = null;
function refreshDashboard() {
state.dashboard.loaded = false;
loadDashboard();
return loadDashboard({ preserveScroll: true });
}
function startDashboardAutoRefresh() {
if (dashboardTimer) clearInterval(dashboardTimer);
Expand Down Expand Up @@ -3381,8 +3470,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 @@ -3546,8 +3634,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
85 changes: 84 additions & 1 deletion test/viewer-session-id.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,22 @@ function loadViewerSandbox() {
const attributes = new Map<string, string>();
const classes = new Set<string>();
const listeners = new Map<string, Array<(event?: unknown) => void>>();
let innerHTML = "";
return {
id,
innerHTML: "",
get innerHTML() {
return innerHTML;
},
set innerHTML(value: unknown) {
innerHTML = String(value ?? "");
if (id === "view-dashboard" && innerHTML.includes("Loading dashboard")) {
this.scrollTop = 0;
}
},
textContent: "",
value: "",
checked: false,
scrollTop: 0,
dataset: {},
style: {},
listeners,
Expand Down Expand Up @@ -186,6 +196,79 @@ describe("viewer session rendering", () => {
expect(getElement("view-dashboard").innerHTML).toContain("Unknown session");
});

it("preserves dashboard scroll position during refresh", async () => {
const { sandbox, getElement } = loadViewerSandbox();
const dashboard = getElement("view-dashboard");

sandbox.state.dashboard.loaded = true;
dashboard.innerHTML = "<div>existing dashboard</div>";
dashboard.scrollTop = 240;

await sandbox.refreshDashboard();

expect(dashboard.scrollTop).toBe(240);
});

it("shows a toast when a viewer API request returns an HTTP error", async () => {
const { sandbox, getElement } = loadViewerSandbox();
sandbox.fetch = async () => ({
ok: false,
status: 503,
statusText: "Service Unavailable",
json: async () => ({}),
});

await sandbox.apiGet("health");

const toast = getElement("toast-region").innerHTML;
expect(toast).toContain("Backend API error");
expect(toast).toContain("GET /agentmemory/health");
expect(toast).toContain("503 Service Unavailable");
});

it("shows a toast when a viewer API request cannot reach the backend", async () => {
const { sandbox, getElement } = loadViewerSandbox();
sandbox.fetch = async () => {
throw new Error("connect ECONNREFUSED");
};

await sandbox.apiGet("health");

const toast = getElement("toast-region").innerHTML;
expect(toast).toContain("Backend API error");
expect(toast).toContain("GET /agentmemory/health");
expect(toast).toContain("connect ECONNREFUSED");
});

it("shows a toast when a viewer API request times out", async () => {
const { sandbox, getElement } = loadViewerSandbox();
sandbox.AbortController = function AbortController() {
this.signal = { aborted: false };
this.abort = () => {
this.signal.aborted = true;
};
};
sandbox.setTimeout = (fn: () => void, ms?: number) => {
if (ms === 10000) fn();
return 1;
};
sandbox.fetch = async (_url: string, opts: { signal?: { aborted: boolean } }) => {
if (opts.signal?.aborted) {
const err = new Error("aborted");
err.name = "AbortError";
throw err;
}
return { ok: true, json: async () => ({}) };
};

await sandbox.apiGet("health");

const toast = getElement("toast-region").innerHTML;
expect(toast).toContain("Backend API error");
expect(toast).toContain("GET /agentmemory/health");
expect(toast).toContain("timed out after 10s");
});

it("does not throw when timeline and sessions tabs receive sessions missing ids", () => {
const { sandbox, getElement } = loadViewerSandbox();
const sessions = [{ status: "active", observationCount: 1, startedAt: "2026-05-13T12:00:00Z" }];
Expand Down