Skip to content

Persist notification images so history keeps avatars - #6804

Merged
dhh merged 2 commits into
quattrofrom
notification-history-images
Aug 13, 2026
Merged

Persist notification images so history keeps avatars#6804
dhh merged 2 commits into
quattrofrom
notification-history-images

Conversation

@dhh

@dhh dhh commented Aug 13, 2026

Copy link
Copy Markdown
Member

Notification history replayed entries without their avatars. The persisted JSON stored image/appIcon as URLs into resources that die with the live notification: Chromium-family senders (every Omarchy web app, WhatsApp included) pass avatars as files in a scoped /tmp dir deleted the moment the notification closes, and raw image-data hints surface as in-process image:// URLs that die with the server object. Replay then found dead references and hid the icon.

What changed

  • Copy at persist time. File-backed images are copied into ~/.local/state/omarchy/notifications/images/, named by the entry's file stem (<timestamp>-<id>-appIcon / -image), and the persisted JSON references the copy. The copy runs in the same serialized queue job, before the JSON that references it. Dead image:// URLs are blanked so the card falls back to the app icon.
  • Copies die with their JSON. Superseded-popup deletion, history trimming, and clear-history all remove the image files sharing the JSON's stem. A startup sweep collects copies orphaned by a restart killing a queued job between its cp and its JSON write.
  • DND race. A silenced notification is now held (tracked) until its history write has run, released by a per-job done callback: untracking tells the sender its notification closed, and Chromium deletes the scoped avatar file the moment it hears so.
  • Replay carry-over. On-screen rows carried into a history replay now reference the persisted copies, since the replay dismisses their live notifications first and the senders delete the originals on close.

— 🤖 Claude, posting on behalf of @dhh

Copilot AI balanced review requested due to automatic review settings August 13, 2026 14:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Persists notification images so restored history retains avatars and icons.

Changes:

  • Copies file-backed images into notification state storage.
  • Cleans image copies alongside their JSON records.
  • Delays releasing DND notifications until persistence completes.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
NotificationLogic.js Builds persisted image paths and copy operations.
Service.qml Integrates image lifecycle management and DND persistence.
notifications-test.sh Adds persistence and cleanup coverage.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +181 to +185
if (!isEphemeral(notification)) {
writeHistoryFile(snapshot, function() {
service.releaseSilenced(notification, snapshot.originalId)
})
return

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 07aa969. The DND path now goes through writeSilenced, which re-snapshots the tracked object when each write completes and writes again (reusing the original file identity) until the content is stable — only then does releaseSilenced untrack. An in-place replaces_id update that lands while a write is queued is therefore persisted rather than dropped, and the release never closes the notification on stale content.

— 🤖 Claude, posting on behalf of @dhh

Persisted popup and history entries stored image/appIcon as URLs into
resources that die with the live notification: Chromium-family senders
(every Omarchy web app, WhatsApp included) pass avatars as files in a
scoped /tmp dir deleted when the notification closes, and raw image-data
hints surface as in-process image:// URLs that die with the server
object. Replaying history then found dead references and hid the icon.

Copy file-backed images into the notification state dir when persisting,
keyed by the entry's file stem, and reference the copies from the JSON.
Blank dead image:// URLs so the card falls back to the app icon. The
copies die with their JSON: superseded-popup deletes, history trims and
clears remove them, and a startup sweep collects copies orphaned by a
restart killing a queued job mid-write.

Hold DND-silenced notifications open until their history write has run,
since untracking tells the sender to delete its avatar file, and carry
replayed on-screen rows over via their persisted copies, since the
replay dismisses their live notifications first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 13, 2026 14:31
@dhh
dhh force-pushed the notification-history-images branch from 9bed955 to 472777d Compare August 13, 2026 14:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

shell/plugins/notifications/Service.qml:183

  • Holding this object tracked changes replaces_id behavior while this queued write is pending: Quickshell updates the existing notification object instead of emitting another onNotification, but this DND branch returns without connecting watchForUpdates. An update arriving before the callback is therefore discarded, and releaseSilenced then closes the updated object with only the stale first snapshot in history. Track DND updates and defer release until the latest snapshot has been persisted, or retain the old immediate-untracking semantics while preserving the image separately; add a regression test with an update delayed behind another file job.
      if (!isEphemeral(notification)) {
        writeHistoryFile(snapshot, function() {
          service.releaseSilenced(notification, snapshot.originalId)
        })
        return

Comment thread shell/plugins/notifications/Service.qml Outdated
// queue or fill the state dir.
readonly property string copyImagesScript:
"while (( $# >= 2 )); do\n" +
" [[ -f $1 ]] && (( $(stat -c%s -- \"$1\" 2>/dev/null || echo 0) <= 5242880 )) && cp -f -- \"$1\" \"$2\" 2>/dev/null\n" +

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 07aa969. The copy no longer reopens the source with cp after the checks: it reads through timeout 5 head -c <limit+1> into a .tmp file in the images dir, validates the temp file's final size, and renames it into place atomically. A source that grows or is swapped for a FIFO mid-copy can now at most stall its own job for 5 seconds and never publishes an oversized or partial copy; the startup sweep clears any .tmp a killed job leaves behind.

— 🤖 Claude, posting on behalf of @dhh

A replaces_id update lands on a held DND notification without a second
onNotification, so releasing after the first write could persist a stale
snapshot. Re-snapshot when the write completes and write again until the
content is stable, reusing the original file identity.

The image copy reopened the sender-controlled path after checking it, so
a file growing or becoming a FIFO mid-copy defeated the size bound. Read
through head -c under a timeout into a temp file, validate its size, and
rename it into place; the startup sweep clears temp files a killed job
leaves behind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 13, 2026 15:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Suppressed comments (2)

shell/plugins/notifications/Service.qml:515

  • This queues the only read of the sender-owned image but does not tie notification closure to completion of that job. If another queue job is running (each image read may take up to 5 seconds), the toast can be dismissed or expire first; removePopup immediately calls ref.dismiss(), Chromium removes the scoped file, and this queued job later persists a missing copy. Track initial persistence completion per popup and defer closing the live notification until its image-copy job has completed.
    for (var i = 0; i < persistable.copies.length; i++)
      command.push(persistable.copies[i].from, persistable.copies[i].to)
    enqueuePopupFileJob(command)

shell/plugins/notifications/NotificationLogic.js:238

  • The persisted role is redirected before the copy outcome is known, while copyImagesScript deliberately treats missing, timed-out, unreadable, and >5 MiB sources as non-fatal. The job still writes this JSON, so these cases leave a non-empty file://... URL with no file; for image, NotificationCard.smallIconSource then does not fall back to appIcon. Only redirect a role after its temp file was successfully renamed, and persist an empty role on copy failure.
    if (source) {
      var copy = String(imagesDir || "") + imageStem(e) + "-" + role
      if (source !== copy) copies.push({ from: source, to: copy })
      out[role] = "file://" + copy
    } else if (value.indexOf("image://") === 0) {
      out[role] = ""

@dhh
dhh merged commit b2207c3 into quattro Aug 13, 2026
4 checks passed
@dhh
dhh deleted the notification-history-images branch August 13, 2026 16:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants