Make notification history the last ten notifications on disk

History was a pair of in-memory lists mirrored into notifications.json, split
into "pending" and "past" by a seen/unseen distinction no surface exposed,
capped at 100, deduped by an id that repeats across server generations, and
pruned by a 15-minute TTL. Replaying it showed five rows drawn from whichever
list happened to hold them.

Every toast already writes a file under ~/.local/state/omarchy/notifications
so it can survive a shell restart. That file is now the history record: when
the popup leaves the screen it moves into notifications/history instead of
being deleted, the newest ten are kept, and showHistory replays exactly what
is in there, including the toasts still on screen when it is asked for. A
notification DND silenced is written straight into the same directory, since
a toast that never showed is the one worth looking back at.

That leaves the models, notifications.json history payload, past pruning, and
the /tmp image cache that existed to keep century-old history thumbnails alive
with nothing to do, so they go.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Heinemeier Hansson
2026-08-12 00:58:14 -07:00
co-authored by Claude Opus 5
parent d6b21f8075
commit ab57ad65fd
4 changed files with 368 additions and 644 deletions
+76 -74
View File
@@ -124,64 +124,55 @@ assertDeepEqual(
'notifications create stable snapshots'
)
const history = notifications.parseHistory(JSON.stringify({
dnd: true,
pending: [
{ id: 1, originalId: 10, summary: 'old', timestamp: 100 },
{ id: 2, originalId: 10, summary: 'new', timestamp: 200 },
{ id: 3, originalId: 11, summary: 'other', timestamp: 150 }
],
past: [
{ id: 4, summary: 'past', timestamp: 50 }
],
entries: [
{ id: 5, summary: 'legacy', timestamp: 75 }
]
}), 1, 100)
const settings = notifications.parseSettings(JSON.stringify({ version: 3, dnd: true }))
assertEqual(settings.dnd, true, 'notifications parse the persisted DND state')
assertEqual(settings.legacy, false, 'notifications do not flag a current settings file as legacy')
assertEqual(notifications.parseSettings('').dnd, null, 'notifications leave DND unset without a settings file')
assertEqual(
notifications.parseSettings(JSON.stringify({ dnd: false, pending: [], past: [] })).legacy,
true,
'notifications flag a settings file still carrying the retired history rows'
)
assert(notifications.parseSettings('{').error, 'notifications flag invalid settings JSON')
assertEqual(history.dnd, true, 'notifications parse persisted DND state')
assertEqual(history.hadDuplicates, true, 'notifications report duplicate history rows')
assertDeepEqual(
history.pending.map(row => ({ id: row.id, originalId: row.originalId, summary: row.summary, urgency: row.urgency, timestamp: row.timestamp })),
[
{ id: 2, originalId: 10, summary: 'new', urgency: 1, timestamp: 200 },
{ id: 3, originalId: 11, summary: 'other', urgency: 1, timestamp: 150 }
],
'notifications dedupe pending history by original id'
)
assertDeepEqual(
history.past.map(row => row.summary),
['legacy', 'past'],
'notifications merge legacy entries into past history'
)
assertDeepEqual(
notifications.parseHistory(JSON.stringify({ pending: [{ id: 1, timestamp: 1 }] }), 1, 0).pending,
[],
'notifications history parser supports zero result cap'
)
assert(notifications.parseHistory('{', 1, 100).error, 'notifications flag invalid history JSON')
// History is the notification files moved into the history dir, read back
// exactly like live popup files.
const archived = [
notifications.serializePopup({ id: 1, originalId: 1, summary: 'oldest', timestamp: 100 }, 1),
notifications.serializePopup({ id: 2, originalId: 2, summary: 'newest', timestamp: 900, expireTimeout: 30000, deadline: 5000 }, 1),
notifications.serializePopup({ id: 3, originalId: 3, summary: 'middle', timestamp: 500 }, 1)
].join('\n')
const recentRows = notifications.recentHistoryRows(
[
{ id: 1, originalId: 10, summary: 'pending-old', timestamp: 100 },
{ id: 2, originalId: 11, summary: 'pending-new', timestamp: 700 },
{ id: 3, originalId: 12, summary: 'pending-mid', timestamp: 300 }
],
[
{ id: 4, originalId: 13, summary: 'past-newest', timestamp: 900 },
{ id: 5, originalId: 14, summary: 'past-second', timestamp: 800 },
{ id: 6, originalId: 10, summary: 'past-replaced', timestamp: 200 },
{ id: 7, originalId: 15, summary: 'past-extra', timestamp: 50 }
],
5,
1
const historyReplay = notifications.historyRows(archived, [], 1, 2)
assertDeepEqual(
historyReplay.map(row => row.summary),
['newest', 'middle'],
'notifications replay the newest history rows up to the limit'
)
assertEqual(historyReplay[0].expireTimeout, 0, 'notifications replay history rows with the standard toast lifetime')
assertEqual('deadline' in historyReplay[0], false, 'notifications drop the restore deadline from replayed history rows')
assertDeepEqual(notifications.historyRows('', [], 1, 10), [], 'notifications replay nothing from an empty history dir')
// A toast still on screen is the newest notification there is, and its move
// into the history dir races the read, so the replay takes it from memory.
assertDeepEqual(
notifications.historyRows(archived, [{ id: 4, originalId: 4, summary: 'on screen', timestamp: 1500 }], 1, 10)
.map(row => row.summary),
['on screen', 'newest', 'middle', 'oldest'],
'notifications replay the toasts still on screen alongside the archived ones'
)
assertDeepEqual(
recentRows.map(row => row.summary),
['past-newest', 'past-second', 'pending-new', 'pending-mid', 'past-replaced'],
'notifications pick the last five history rows across pending and past'
notifications.historyRows(archived, [{ id: 2, originalId: 2, summary: 'newest', timestamp: 900 }], 1, 10)
.map(row => row.summary),
['newest', 'middle', 'oldest'],
'notifications replay a toast once when its archived file already landed'
)
assertDeepEqual(
notifications.historyRows('', [{ id: 4, originalId: 4, summary: 'on screen', timestamp: 1500 }], 1, 10)
.map(row => row.summary),
['on screen'],
'notifications replay an on-screen toast even when nothing is archived yet'
)
assertEqual(recentRows.length, 5, 'notifications history replay is capped at five rows')
const popup = {
id: 7,
@@ -286,20 +277,11 @@ assertEqual(
'xdg-open /tmp/received',
'notifications keep the click command on history rows'
)
assertEqual(
notifications.dumpRows([{ id: 1, exec: 'xdg-open /tmp/received' }])[0].exec,
'xdg-open /tmp/received',
'notifications write the click command back out with history'
)
assertEqual(notifications.imageExtension('/tmp/screenshot.PNG'), 'png', 'notifications normalize image extensions')
assertEqual(notifications.imageExtension('/tmp/no-extension'), 'png', 'notifications default missing image extension')
assertEqual(notifications.imageExtension('/tmp/archive.reallylong'), 'png', 'notifications reject suspicious image extensions')
const serviceQml = fs.readFileSync(path.join(root, 'shell/plugins/notifications/Service.qml'), 'utf8')
assert(
/readonly property int historyReplayLimit: 5/.test(serviceQml),
'notifications service limits history replay to five rows'
/readonly property int historyLimit: 10/.test(serviceQml),
'notifications service keeps the last ten notifications in history'
)
assert(
/function showHistory\(\): string \{\s*return service\.showRecentHistory\(\)\s*\}/.test(serviceQml),
@@ -310,12 +292,32 @@ assert(
'notifications service persists popups under the omarchy state dir'
)
assert(
serviceQml.split('persistPopupFile(snapshot)').length === 4,
'notifications service persists both ephemeral and regular popups'
/readonly property string historyDir: popupStateDir \+ "history\/"/.test(serviceQml),
'notifications service keeps history in a subdirectory of the popup state dir'
)
assert(
/if \(entry\) \{\s*\n\s*deletePopupFileFor\(entry\)[\s\S]{0,200}?popupModel\.remove\(index\)/.test(serviceQml),
'notifications service deletes the popup file when a popup leaves the screen'
/if \(entry\) \{\s*\n\s*archivePopupFileFor\(entry\)[\s\S]{0,200}?popupModel\.remove\(index\)/.test(serviceQml),
'notifications service archives the popup file when a popup leaves the screen'
)
assert(
/mv -f \\"\$4\/\$3\\" \\"\$1\/\$3\\"/.test(serviceQml),
'notifications service archives by moving the popup file into the history dir'
)
assert(
/head -n \\"-\$2\\"/.test(serviceQml),
'notifications service trims history to the newest entries in the same job'
)
assert(
/if \(!isEphemeral\(notification\)\) writeHistoryFile\(snapshot\)/.test(serviceQml),
'notifications service records DND-silenced notifications straight into history'
)
assert(
/service\.replayCarryOver = liveRowsForReplay\(\)/.test(serviceQml),
'notifications service carries the toasts still on screen into the replay'
)
assert(
/awk 1 \\"\$1\\"\/\*\.json 2>\/dev\/null \|\| true", "--", historyDir/.test(serviceQml),
'notifications service replays history by reading the archived files'
)
assert(
/restorePopupsProc\.running = true/.test(serviceQml),
@@ -330,8 +332,8 @@ assert(
'notifications service never resolves a restored popup to a live server object'
)
assert(
/markSeenByOriginalId\(originalId, timestamp\)/.test(serviceQml),
'notifications service archives pending rows by id and timestamp'
/service\.restoredPopups\[NotificationLogic\.popupFileName\(rows\[i\]\)\] = true/.test(serviceQml),
'notifications service treats replayed history rows as restored, never as live notifications'
)
assert(
/popupFileName\(row\) !== keepFileName/.test(serviceQml),
@@ -346,11 +348,11 @@ assert(
'notifications service runs the popup click command itself instead of a libnotify action'
)
assert(
/exec: row\.exec \|\| ""/.test(serviceQml),
'notifications service carries the click command between models'
/function clear\(\): string \{\s*service\.clearHistory\(\)/.test(serviceQml),
'notifications clear IPC forgets the recorded history'
)
assert(
/exec: r\.exec \|\| ""/.test(serviceQml),
'notifications service saves the click command with history'
!/pendingModel|pastModel/.test(serviceQml),
'notifications service keeps no in-memory history models'
)
JS