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
46 changes: 44 additions & 2 deletions src/viewer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,10 @@
.btn-danger:hover { background: var(--accent); color: white; box-shadow: 3px 3px 0px 0px var(--border); }
.btn-primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.btn-primary:hover { background: var(--ink-secondary); box-shadow: 3px 3px 0px 0px var(--ink-muted); }
.sort-toggle { display: inline-flex; gap: 0; }
.sort-toggle .btn { padding: 7px 10px; }
.sort-toggle .btn.active { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.sort-toggle .btn + .btn { border-left: none; }

.graph-container {
display: flex;
Expand Down Expand Up @@ -1071,7 +1075,7 @@ <h1>agentmemory</h1>
dashboard: { loaded: false, health: null, sessions: [], memories: [], graphStats: null, recentAudit: [], lessons: [], crystals: [] },
graph: { loaded: false, nodes: [], edges: [], stats: null, filters: {}, selectedNode: null },
memories: { loaded: false, items: [], search: '', typeFilter: '' },
timeline: { loaded: false, observations: [], sessionId: '', minImportance: 0, page: 0, pageSize: 50 },
timeline: { loaded: false, observations: [], sessionId: '', minImportance: 0, page: 0, pageSize: 50, sortOrder: 'desc' },
sessions: { loaded: false, items: [], selectedId: null },
audit: { loaded: false, entries: [], opFilter: '' },
activity: { loaded: false, observations: [], sessions: [], typeFilter: '' },
Expand Down Expand Up @@ -2427,7 +2431,11 @@ <h1>agentmemory</h1>
for (var i = 1; i <= 9; i++) {
html += '<option value="' + i + '"' + (state.timeline.minImportance === i ? ' selected' : '') + '>&ge; ' + i + '</option>';
}
html += '</select></div>';
html += '</select>';
html += '<div class="sort-toggle" role="group" aria-label="Timeline sort order">';
html += '<button type="button" class="btn' + (timelineSortOrder() === 'desc' ? ' active' : '') + '" data-action="timeline-sort" data-sort-order="desc" aria-pressed="' + (timelineSortOrder() === 'desc' ? 'true' : 'false') + '">NEWEST FIRST</button>';
html += '<button type="button" class="btn' + (timelineSortOrder() === 'asc' ? ' active' : '') + '" data-action="timeline-sort" data-sort-order="asc" aria-pressed="' + (timelineSortOrder() === 'asc' ? 'true' : 'false') + '">OLDEST FIRST</button>';
html += '</div></div>';
html += '<div id="tl-content"></div>';
el.innerHTML = html;

Expand Down Expand Up @@ -2457,6 +2465,28 @@ <h1>agentmemory</h1>

var tlTypeFilter = '';

function timelineSortOrder() {
return state.timeline.sortOrder === 'asc' ? 'asc' : 'desc';
}

function sortedTimelineObservations(items) {
var order = timelineSortOrder();
return items.slice().sort(function(a, b) {
var at = a && a.timestamp ? String(a.timestamp) : '';
var bt = b && b.timestamp ? String(b.timestamp) : '';
if (at === bt) return 0;
return order === 'asc' ? at.localeCompare(bt) : bt.localeCompare(at);
});
}

function updateTimelineSortButtons() {
document.querySelectorAll('[data-action="timeline-sort"]').forEach(function(button) {
var active = button.getAttribute('data-sort-order') === timelineSortOrder();
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
}

function renderObservations() {
var content = document.getElementById('tl-content');
if (!content) return;
Expand All @@ -2479,6 +2509,7 @@ <h1>agentmemory</h1>
return t === tlTypeFilter;
});
}
filtered = sortedTimelineObservations(filtered);

var pageSize = state.timeline.pageSize;
var page = state.timeline.page;
Expand Down Expand Up @@ -2622,6 +2653,13 @@ <h1>agentmemory</h1>
renderObservations();
}

function setTimelineSortOrder(order) {
state.timeline.sortOrder = order === 'asc' ? 'asc' : 'desc';
state.timeline.page = 0;
updateTimelineSortButtons();
renderObservations();
}

async function loadActivity() {
var el = document.getElementById('view-activity');
el.innerHTML = '<div class="loading">Loading activity...</div>';
Expand Down Expand Up @@ -3763,6 +3801,10 @@ <h1>agentmemory</h1>
setTlTypeFilter(target.getAttribute('data-type-filter') || '');
return;
}
if (action === 'timeline-sort') {
setTimelineSortOrder(target.getAttribute('data-sort-order') || 'desc');
return;
}
if (action === 'timeline-page') {
var page = parseInt(target.getAttribute('data-page') || '', 10);
if (!Number.isNaN(page)) tlPage(page);
Expand Down
81 changes: 81 additions & 0 deletions test/viewer-session-id.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,18 @@ function loadViewerSandbox() {
return { sandbox, getElement };
}

function expectTextOrder(html: string, labels: string[]) {
const positions = labels.map((label) => html.indexOf(label));
positions.forEach((position, index) => {
expect(position, `${labels[index]} should be rendered`)
.toBeGreaterThanOrEqual(0);
});
for (let i = 1; i < positions.length; i++) {
expect(positions[i - 1], `${labels[i - 1]} should render before ${labels[i]}`)
.toBeLessThan(positions[i]);
}
}

describe("viewer session rendering", () => {
it("does not throw when dashboard sessions are missing ids", () => {
const { sandbox, getElement } = loadViewerSandbox();
Expand Down Expand Up @@ -203,3 +215,72 @@ describe("viewer session rendering", () => {
expect(tabButtons.some((button: any) => button.classList.contains("active"))).toBe(true);
});
});

describe("viewer timeline sort order", () => {
it("renders newest-first and oldest-first sort buttons", () => {
const { sandbox, getElement } = loadViewerSandbox();

sandbox.renderTimelineToolbar([
{
id: "ses_1",
status: "completed",
observationCount: 3,
startedAt: "2026-05-13T12:00:00Z",
},
]);

const html = getElement("view-timeline").innerHTML;
expect(html).toContain('data-action="timeline-sort"');
expect(html).toContain("NEWEST FIRST");
expect(html).toContain("OLDEST FIRST");
});

it("orders observations by selected timeline sort direction", () => {
const { sandbox, getElement } = loadViewerSandbox();
sandbox.state.timeline.observations = [
{
id: "old",
sessionId: "ses_1",
timestamp: "2026-05-13T10:00:00Z",
title: "Oldest observation",
type: "conversation",
importance: 5,
},
{
id: "mid",
sessionId: "ses_1",
timestamp: "2026-05-13T11:00:00Z",
title: "Middle observation",
type: "conversation",
importance: 5,
},
{
id: "new",
sessionId: "ses_1",
timestamp: "2026-05-13T12:00:00Z",
title: "Newest observation",
type: "conversation",
importance: 5,
},
];
sandbox.state.timeline.sortOrder = "desc";

sandbox.renderObservations();
expectTextOrder(getElement("tl-content").innerHTML, [
"Newest observation",
"Middle observation",
"Oldest observation",
]);

sandbox.state.timeline.page = 2;
sandbox.setTimelineSortOrder("asc");

expect(sandbox.state.timeline.sortOrder).toBe("asc");
expect(sandbox.state.timeline.page).toBe(0);
expectTextOrder(getElement("tl-content").innerHTML, [
"Oldest observation",
"Middle observation",
"Newest observation",
]);
});
});