diff --git a/frontend/app.js b/frontend/app.js
index 52c709a..00b8640 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -301,6 +301,44 @@ function buildTileHTML(session, index, mobile) {
);
}
+/**
+ * Build the HTML string for a single session sidebar card.
+ * @param {object} session
+ * @param {string} currentSession - name of the currently active session
+ * @returns {string}
+ */
+function buildSidebarHTML(session, currentSession) {
+ const name = session.name || '';
+ const escapedName = escapeHtml(name);
+ const isActive = name === currentSession;
+
+ let classes = 'sidebar-item';
+ if (isActive) classes += ' sidebar-item--active';
+
+ const unseen = session.bell && session.bell.unseen_count;
+
+ // Bell indicator
+ let bellHtml = '';
+ if (unseen && unseen > 0) {
+ const countStr = unseen > 9 ? '9+' : String(unseen);
+ bellHtml = `${countStr}`;
+ }
+
+ // Last 20 lines of snapshot
+ const snapshot = session.snapshot || '';
+ const lastLines = snapshot.split('\n').slice(-20).join('\n');
+
+ return (
+ `` +
+ `` +
+ `` +
+ ``
+ );
+}
+
/**
* Render the session grid. Shows empty state when no sessions exist.
* On mobile, sorts sessions by priority before rendering.
@@ -898,6 +936,7 @@ if (typeof module !== 'undefined' && module.exports) {
startPolling,
escapeHtml,
buildTileHTML,
+ buildSidebarHTML,
renderGrid,
requestNotificationPermission,
handleBellTransitions,
diff --git a/frontend/tests/test_app.mjs b/frontend/tests/test_app.mjs
index 656a7c5..bf7426f 100644
--- a/frontend/tests/test_app.mjs
+++ b/frontend/tests/test_app.mjs
@@ -1646,3 +1646,49 @@ test('closeBottomSheet does not call removeEventListener on sheet-backdrop', ()
assert.strictEqual(backdropRemoveCalled, false, 'closeBottomSheet must not call removeEventListener on sheet-backdrop');
globalThis.document.getElementById = origGetById;
});
+
+// --- buildSidebarHTML ---
+
+test('buildSidebarHTML adds sidebar-item--active class for active session', () => {
+ const session = { name: 'my-session', snapshot: '', bell: { unseen_count: 0 } };
+ const html = app.buildSidebarHTML(session, 'my-session');
+ assert.ok(html.includes('sidebar-item--active'), 'active session should have sidebar-item--active class');
+});
+
+test('buildSidebarHTML does not add sidebar-item--active class for inactive session', () => {
+ const session = { name: 'my-session', snapshot: '', bell: { unseen_count: 0 } };
+ const html = app.buildSidebarHTML(session, 'other-session');
+ assert.ok(!html.includes('sidebar-item--active'), 'inactive session should not have sidebar-item--active class');
+});
+
+test('buildSidebarHTML renders bell badge with tile-bell class and count when unseen_count > 0', () => {
+ const session = { name: 's', snapshot: '', bell: { unseen_count: 3 } };
+ const html = app.buildSidebarHTML(session, '');
+ assert.ok(html.includes('tile-bell'), 'should contain tile-bell class when unseen_count > 0');
+ assert.ok(html.includes('>3<'), 'should contain unseen count text');
+});
+
+test('buildSidebarHTML omits bell badge when unseen_count is 0', () => {
+ const session = { name: 's', snapshot: '', bell: { unseen_count: 0 } };
+ const html = app.buildSidebarHTML(session, '');
+ assert.ok(!html.includes('tile-bell'), 'should not contain tile-bell class when unseen_count is 0');
+});
+
+test('buildSidebarHTML HTML-escapes session name to prevent XSS', () => {
+ const session = { name: '', snapshot: '', bell: { unseen_count: 0 } };
+ const html = app.buildSidebarHTML(session, '');
+ assert.ok(!html.includes('