feat: dynamic favicon badge — amber dot overlay when sessions have unseen bells

This commit is contained in:
Brian Krabach
2026-03-30 08:06:34 -07:00
parent 261a25e6df
commit b448f120c5
2 changed files with 79 additions and 0 deletions
+56
View File
@@ -248,6 +248,7 @@ async function pollSessions() {
renderSidebar(sessions, _viewingSession); renderSidebar(sessions, _viewingSession);
handleBellTransitions(prev, sessions); handleBellTransitions(prev, sessions);
updateSessionPill(sessions); updateSessionPill(sessions);
updateFaviconBadge();
} catch (err) { } catch (err) {
_pollFailCount++; _pollFailCount++;
setConnectionStatus(_pollFailCount <= 2 ? 'warn' : 'err'); setConnectionStatus(_pollFailCount <= 2 ? 'warn' : 'err');
@@ -815,6 +816,61 @@ function updatePillBell() {
if (hasBell) el.classList.remove('hidden'); else el.classList.add('hidden'); if (hasBell) el.classList.remove('hidden'); else el.classList.add('hidden');
} }
// ---------------------------------------------------------------------------
// Dynamic favicon — activity dot overlay
// ---------------------------------------------------------------------------
var _originalFavicon = null; // cached original favicon href
/**
* Update the favicon with an activity dot if any session has unseen bells.
* Uses a 32x32 canvas to draw the original favicon + a colored circle overlay.
* Restores the original favicon when there are no unseen bells.
*/
function updateFaviconBadge() {
var hasActivity = _currentSessions && _currentSessions.some(function (s) {
return s.bell && s.bell.unseen_count > 0;
});
var link = document.querySelector('link[rel="icon"][sizes="32x32"]') ||
document.querySelector('link[rel="icon"]');
if (!link) return;
// Cache the original favicon on first call
if (!_originalFavicon) _originalFavicon = link.href;
if (!hasActivity) {
// Restore original favicon when no activity
if (link.href !== _originalFavicon) link.href = _originalFavicon;
return;
}
// Draw favicon + activity dot on canvas
var canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 32;
var ctx = canvas.getContext('2d');
if (!ctx) return;
var img = new Image();
img.crossOrigin = 'anonymous';
img.onload = function () {
ctx.drawImage(img, 0, 0, 32, 32);
// Activity dot — brand amber (same as bell indicator)
ctx.beginPath();
ctx.arc(24, 8, 7, 0, 2 * Math.PI); // top-right area
ctx.fillStyle = '#F1A640'; // var(--bell-color)
ctx.fill();
ctx.strokeStyle = '#0D1117'; // var(--bg) — border for contrast
ctx.lineWidth = 2;
ctx.stroke();
link.href = canvas.toDataURL('image/png');
};
img.src = _originalFavicon;
}
// ─── Session open / close ──────────────────────────────────────────────────── // ─── Session open / close ────────────────────────────────────────────────────
/** /**
+23
View File
@@ -2040,3 +2040,26 @@ test('applyDisplaySettings calls window._setTerminalFontSize when available', ()
}); });
// --- Issue: Dynamic favicon badge with activity dot ---
test('updateFaviconBadge function exists in app.js', () => {
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
assert.ok(
source.includes('function updateFaviconBadge'),
'app.js must define updateFaviconBadge function'
);
});
test('pollSessions calls updateFaviconBadge', () => {
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
const pollStart = source.indexOf('async function pollSessions()');
assert.ok(pollStart !== -1, 'pollSessions must exist');
// Find the closing brace of pollSessions (next line starting with "}")
const pollEnd = source.indexOf('\n}', pollStart);
const pollBody = source.substring(pollStart, pollEnd + 2);
assert.ok(
pollBody.includes('updateFaviconBadge'),
'pollSessions must call updateFaviconBadge — update favicon on every poll cycle'
);
});