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
65 changes: 46 additions & 19 deletions src/viewer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,23 @@ <h1>agentmemory</h1>
if (!s) return '';
return s.length > n ? s.slice(0, n) + '...' : s;
}
function sessionId(s) {
return s && s.id !== undefined && s.id !== null ? String(s.id) : '';
}
function shortSessionId(s, n) {
var id = sessionId(s);
return id ? id.slice(0, n || 8) : '';
}
function sessionDisplayName(s) {
var project = s && s.project ? String(s.project).split('/').pop() : '';
if (project) return project;
return shortSessionId(s, 8) || 'Unknown session';
}
function sessionLabel(s) {
var id = shortSessionId(s, 8);
var name = sessionDisplayName(s);
return id ? name + ' (' + id + ')' : name + ' (missing id)';
}
function debounce(fn, ms) {
var t;
return function() {
Expand Down Expand Up @@ -1278,7 +1295,7 @@ <h1>agentmemory</h1>
html += '<table><tr><th>Project</th><th>Status</th><th>Obs</th><th>Started</th></tr>';
recent.forEach(function(s) {
var statusBadge = s.status === 'active' ? 'badge-green' : s.status === 'completed' ? 'badge-blue' : 'badge-muted';
html += '<tr><td style="color:var(--ink);font-weight:500;">' + esc(s.project ? s.project.split('/').pop() : s.id.slice(0,8)) + '</td>';
html += '<tr><td style="color:var(--ink);font-weight:500;">' + esc(sessionDisplayName(s)) + '</td>';
html += '<td><span class="badge ' + statusBadge + '">' + esc(s.status) + '</span></td>';
html += '<td style="color:var(--ink-muted);font-family:var(--font-mono);font-size:12px;">' + (s.observationCount || 0) + '</td>';
html += '<td style="font-family:var(--font-mono);font-size:11px;color:var(--ink-faint);">' + esc(shortTime(s.startedAt)) + '</td></tr>';
Expand Down Expand Up @@ -2192,7 +2209,8 @@ <h1>agentmemory</h1>

if (sessions.length > 0 && !state.timeline.sessionId) {
var sorted = sessions.slice().sort(function(a, b) { return (b.startedAt || '').localeCompare(a.startedAt || ''); });
state.timeline.sessionId = sorted[0].id;
var firstSelectable = sorted.find(function(s) { return sessionId(s); });
state.timeline.sessionId = firstSelectable ? sessionId(firstSelectable) : '';
}

renderTimelineToolbar(sessions);
Expand All @@ -2204,8 +2222,9 @@ <h1>agentmemory</h1>
var html = '<div class="toolbar">';
html += '<select id="tl-session"><option value="">Select session</option>';
sessions.sort(function(a, b) { return (b.startedAt || '').localeCompare(a.startedAt || ''); }).forEach(function(s) {
var label = (s.project ? s.project.split('/').pop() : s.id.slice(0,8)) + ' (' + s.id.slice(0,8) + ')';
html += '<option value="' + esc(s.id) + '"' + (state.timeline.sessionId === s.id ? ' selected' : '') + '>' + esc(label) + '</option>';
var id = sessionId(s);
var disabled = id ? '' : ' disabled';
html += '<option value="' + esc(id) + '"' + (id && state.timeline.sessionId === id ? ' selected' : '') + disabled + '>' + esc(sessionLabel(s)) + '</option>';
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
html += '</select>';
html += '<select id="tl-importance"><option value="0">All importance</option>';
Expand Down Expand Up @@ -2421,8 +2440,8 @@ <h1>agentmemory</h1>
var sorted = sessions.slice().sort(function(a, b) { return (b.startedAt || '').localeCompare(a.startedAt || ''); });
var recentSessions = sorted.slice(0, 5);

var obsResults = await Promise.all(recentSessions.map(function(s) {
return apiGet('observations?sessionId=' + encodeURIComponent(s.id));
var obsResults = await Promise.all(recentSessions.filter(function(s) { return sessionId(s); }).map(function(s) {
return apiGet('observations?sessionId=' + encodeURIComponent(sessionId(s)));
}));
obsResults.forEach(function(r) {
if (r && r.observations) allObs = allObs.concat(r.observations);
Expand Down Expand Up @@ -2565,15 +2584,16 @@ <h1>agentmemory</h1>
} else {
items.forEach(function(s) {
var statusBadge = s.status === 'active' ? 'badge-green' : s.status === 'completed' ? 'badge-blue' : 'badge-muted';
var selected = state.sessions.selectedId === s.id;
html += '<div class="session-item' + (selected ? ' selected' : '') + '" data-action="select-session" data-session-id="' + esc(s.id) + '">';
html += '<div class="session-top"><span class="session-project">' + esc(s.project ? s.project.split('/').pop() : 'Unknown') + '</span>';
var id = sessionId(s);
var selected = id && state.sessions.selectedId === id;
html += '<div class="session-item' + (selected ? ' selected' : '') + '"' + (id ? ' data-action="select-session" data-session-id="' + esc(id) + '"' : '') + '>';
html += '<div class="session-top"><span class="session-project">' + esc(sessionDisplayName(s)) + '</span>';
html += '<span class="badge ' + statusBadge + '">' + esc(s.status) + '</span></div>';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
var preview = s.firstPrompt || s.summary || '';
if (preview) {
html += '<div class="session-preview" style="font-size:13px;color:var(--ink);margin:4px 0;line-height:1.4;">' + esc(truncate(preview, 140)) + '</div>';
}
html += '<div class="session-meta">' + esc(s.id.slice(0, 12)) + ' &middot; ' + esc(formatTime(s.startedAt));
html += '<div class="session-meta">' + esc(shortSessionId(s, 12) || 'missing id') + ' &middot; ' + esc(formatTime(s.startedAt));
html += ' &middot; ' + (s.observationCount || 0) + ' obs';
if (s.model) html += ' &middot; ' + esc(s.model);
html += '</div></div>';
Expand All @@ -2594,12 +2614,13 @@ <h1>agentmemory</h1>
async function renderSessionDetail() {
var panel = document.getElementById('session-detail');
if (!panel) return;
var s = state.sessions.items.find(function(x) { return x.id === state.sessions.selectedId; });
if (!s) { panel.innerHTML = ''; return; }
var s = state.sessions.items.find(function(x) { return sessionId(x) === state.sessions.selectedId; });
var id = sessionId(s);
if (!s || !id) { panel.innerHTML = ''; return; }

panel.innerHTML = '<div class="detail-panel"><h3>Loading session details…</h3></div>';

var obsRes = await apiGet('observations?sessionId=' + encodeURIComponent(s.id));
var obsRes = await apiGet('observations?sessionId=' + encodeURIComponent(id));
var obs = (obsRes && obsRes.observations) || [];

var typeCounts = {};
Expand Down Expand Up @@ -2672,7 +2693,8 @@ <h1>agentmemory</h1>

html += '<div class="card" style="margin-bottom:12px;"><div class="card-title">Metadata</div>';
html += '<div style="font-size:12px;font-family:var(--font-mono);margin-top:8px;line-height:1.7;">';
html += '<div><span style="color:var(--ink-muted);">id:</span> ' + esc(s.id) + '</div>';
var detailId = sessionId(s);
html += '<div><span style="color:var(--ink-muted);">id:</span> ' + esc(detailId || 'missing id') + '</div>';
html += '<div><span style="color:var(--ink-muted);">cwd:</span> ' + esc(s.cwd || '-') + '</div>';
html += '<div><span style="color:var(--ink-muted);">started:</span> ' + esc(formatTime(s.startedAt)) + '</div>';
if (s.endedAt) html += '<div><span style="color:var(--ink-muted);">ended:</span> ' + esc(formatTime(s.endedAt)) + '</div>';
Expand All @@ -2681,10 +2703,14 @@ <h1>agentmemory</h1>
html += '</div></div>';

html += '<div style="display:flex;gap:8px;">';
if (s.status === 'active') {
html += '<button class="btn btn-danger" data-action="end-session" data-session-id="' + esc(s.id) + '">End Session</button>';
if (detailId && s.status === 'active') {
html += '<button class="btn btn-danger" data-action="end-session" data-session-id="' + esc(detailId) + '">End Session</button>';
}
if (detailId) {
html += '<button class="btn btn-primary" data-action="summarize-session" data-session-id="' + esc(detailId) + '">Summarize</button>';
} else {
html += '<button class="btn btn-primary" disabled>Summarize unavailable</button>';
}
html += '<button class="btn btn-primary" data-action="summarize-session" data-session-id="' + esc(s.id) + '">Summarize</button>';
html += '</div></div>';
panel.innerHTML = html;
}
Expand Down Expand Up @@ -3587,8 +3613,9 @@ <h1>agentmemory</h1>
var el = document.getElementById('view-replay');
var sessions = state.replay.sessions || [];
var options = '<option value="">— pick a session —</option>' + sessions.map(function(s) {
var label = (s.project || 'unknown') + ' · ' + (s.id || '').slice(0, 8) + ' · ' + (s.observationCount || 0) + ' obs';
return '<option value="' + esc(s.id) + '"' + (s.id === state.replay.selectedId ? ' selected' : '') + '>' + esc(label) + '</option>';
var id = sessionId(s);
var label = sessionDisplayName(s) + ' · ' + (shortSessionId(s, 8) || 'missing id') + ' · ' + (s.observationCount || 0) + ' obs';
return '<option value="' + esc(id) + '"' + (id && id === state.replay.selectedId ? ' selected' : '') + (id ? '' : ' disabled') + '>' + esc(label) + '</option>';
}).join('');

var tl = state.replay.timeline;
Expand Down
189 changes: 189 additions & 0 deletions test/viewer-session-id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import * as vm from "node:vm";
import { describe, expect, it } from "vitest";
import { renderViewerDocument } from "../src/viewer/document.js";

function htmlEscape(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

function loadViewerSandbox() {
const rendered = renderViewerDocument();
expect(rendered.found).toBe(true);
if (!rendered.found) throw new Error("viewer document not found");

const scriptMatch = rendered.html.match(/<script nonce="[^"]+">([\s\S]*?)<\/script>/);
expect(scriptMatch).not.toBeNull();
if (!scriptMatch) throw new Error("viewer script not found");

const elements = new Map<string, any>();
const createMockElement = (id = "") => {
const attributes = new Map<string, string>();
const classes = new Set<string>();
const listeners = new Map<string, Array<(event?: unknown) => void>>();
return {
id,
innerHTML: "",
textContent: "",
value: "",
checked: false,
dataset: {},
style: {},
listeners,
classList: {
add: (name: string) => classes.add(name),
remove: (name: string) => classes.delete(name),
contains: (name: string) => classes.has(name),
toggle: (name: string, force?: boolean) => {
const enabled = force ?? !classes.has(name);
if (enabled) classes.add(name);
else classes.delete(name);
return enabled;
},
},
addEventListener: (type: string, handler: (event?: unknown) => void) => {
const current = listeners.get(type) || [];
current.push(handler);
listeners.set(type, current);
},
getAttribute: (name: string) => attributes.get(name) ?? null,
setAttribute: (name: string, value: unknown) => {
attributes.set(name, String(value));
},
querySelectorAll: () => [],
};
};
const getElement = (id: string) => {
if (!elements.has(id)) elements.set(id, createMockElement(id));
return elements.get(id);
};

const tabs = [
"dashboard",
"graph",
"memories",
"timeline",
"sessions",
"lessons",
"actions",
"crystals",
"audit",
"activity",
"profile",
"replay",
];
const tabButtons = tabs.map((tab) => ({ ...createMockElement(), dataset: { tab } }));
const views = tabs.map((tab) => ({ ...createMockElement(`view-${tab}`), id: `view-${tab}` }));
const checkboxes = [createMockElement(), createMockElement()].map((el) => ({ ...el, checked: false }));
const querySelectorAll = (selector: string) => {
if (selector === ".tab-bar button") return tabButtons;
if (selector === ".view") return views;
if (selector === 'input[type="checkbox"]') return checkboxes;
return [];
};

const document = {
documentElement: { dataset: {} },
createElement: () => {
let text = "";
return {
set textContent(value: unknown) {
text = String(value ?? "");
},
get innerHTML() {
return htmlEscape(text);
},
};
},
getElementById: getElement,
querySelectorAll,
addEventListener: () => {},
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const sandbox: Record<string, any> = {
console: { log: () => {}, warn: () => {}, error: () => {} },
document,
window: {
location: {
search: "",
port: "3113",
protocol: "http:",
hostname: "localhost",
host: "localhost:3113",
origin: "http://localhost:3113",
},
matchMedia: () => ({ matches: false }),
addEventListener: () => {},
},
localStorage: { getItem: () => null, setItem: () => {} },
fetch: async () => ({ ok: true, json: async () => ({}) }),
WebSocket: function WebSocket() {},
navigator: { userAgent: "vitest" },
Element: function Element() {},
alert: () => {},
setInterval: () => 0,
clearInterval: () => {},
setTimeout: () => 0,
clearTimeout: () => {},
URLSearchParams,
Date,
Math,
Promise,
JSON,
Array,
Object,
String,
Number,
parseInt,
encodeURIComponent,
};

const scriptWithoutAutoStart = scriptMatch[1].replace(
/\n\s*loadTab\('dashboard'\);\n\s*connectWs\(\);\n\s*startDashboardAutoRefresh\(\);\s*$/,
"\n",
);

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

return { sandbox, getElement };
}

describe("viewer session rendering", () => {
it("does not throw when dashboard sessions are missing ids", () => {
const { sandbox, getElement } = loadViewerSandbox();
sandbox.state.dashboard = {
loaded: true,
health: { status: "healthy", health: {} },
sessions: [{ status: "active", observationCount: 3, startedAt: "2026-05-13T12:00:00Z" }],
memories: [],
graphStats: null,
recentAudit: [],
lessons: [],
crystals: [],
};

expect(() => sandbox.renderDashboard()).not.toThrow();
expect(getElement("view-dashboard").innerHTML).toContain("Unknown session");
});

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

expect(() => sandbox.renderTimelineToolbar(sessions)).not.toThrow();
expect(getElement("view-timeline").innerHTML).toContain("Unknown session");

sandbox.state.sessions.items = sessions;
expect(() => sandbox.renderSessions()).not.toThrow();
expect(getElement("view-sessions").innerHTML).toContain("Unknown session");

const tabButtons = sandbox.document.querySelectorAll(".tab-bar button");
expect(tabButtons.length).toBeGreaterThan(0);
expect(() => sandbox.switchTab("sessions")).not.toThrow();
expect(tabButtons.some((button: any) => button.classList.contains("active"))).toBe(true);
});
});