* Replay the history a dismissal or a clear was still being written into
The popup files a replay reads are written by a serialized queue of shell
jobs, and the read ran as its own process alongside it. A dismissal issued a
moment earlier could still be queued when the directory was read, leaving the
notification out of the replay it was the newest entry of, and a clear issued
a moment earlier could still be queued too, replaying entries it was about to
remove.
The read now waits for the queue to go idle, so the replay shows the history
as of the moment it was asked for rather than whichever jobs happened to have
landed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Catch up on an update that arrived before its popup had a row
Watching a notification for in-place updates starts the moment it is handed
over, but the row those updates write to is inserted a tick later, deferred to
keep a mid-incubation Repeater from being mutated underneath. A client fast
enough to update inside that window found no row to write to, and a property
that has already changed does not change again — so the toast and its file sat
on the superseded content until something else moved.
The row is now refreshed from the live notification once it exists. That reads
the same object the signals would have, so an update that beat the insert is
picked up and one that did not costs nothing: a refresh whose content matches
the row it would write is dropped, which also collapses the several signals a
single multi-property update emits into one rewrite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Hold queued file work behind the replay's read, not just ahead of it
The read waited for everything queued before it, but nothing stopped the queue
from running on while it worked. A clear or an archive issued during the read
could delete or move files out from under awk mid-glob, so a replay could still
show a partial history — some of what a clear was in the middle of emptying.
The read is a barrier in both directions now: the queue holds until it exits,
and it releases on exit rather than on output, so a read that comes back empty
or fails cannot park the queue behind it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Queue the replay's read instead of waiting for the queue to empty
Waiting for the queue to go idle before starting the read still let work
overtake it. A clear or an archive enqueued after the replay was asked for,
while the current job was running, was dequeued the moment that job exited —
the read only starts once nothing is left — so the replay showed the state
after those jobs, which is the race this was meant to close. Unbroken file
traffic could postpone the read indefinitely for the same reason.
The read is now an entry in that queue rather than a process running beside
it. It takes its place in line behind the work queued before the request and
ahead of everything queued after, so no later job can overtake it and no
amount of traffic can push it back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
A client that updates a notification through replaces_id does not produce a
second onNotification: Quickshell writes the new content onto the Notification
object the shell is already holding. The card draws a snapshot copied out of
that object — deliberately, since a live QObject in a ListModel role becomes a
dangling pointer the moment the server destroys it — so the toast kept showing
the superseded text, and archived it to history when it left the screen. A
Slack thread that updates in place read as stuck.
Every property the card draws is now watched on the notification we hold, and
a change rewrites both the model row and the file the popup was persisted
under. The file name is that popup's identity, so the rewrite lands in place:
a shell restart restores the version last shown, and so does the copy that
reaches history.
The countdown starts over when the content changes. New text arriving a second
before the toast was due to expire deserves a full look, not the remainder of
the clock the text it replaced had nearly run through.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
* Keep clicking a notification working after a shell restart
Notification actions lived only in the sending process: `-a` appended
`-A default=default`, so notify-send blocked on a D-Bus ActionInvoked signal and
the caller ran the command when it arrived. Nothing about that reached disk, so a
restored popup had no action to run and its sender stayed blocked forever.
Replace `-a` with `--exec <command>`, carried as an `omarchy-exec` hint into the
snapshot's `exec` role. It travels through the popup files and history, and the
shell runs it on click, so restored toasts behave exactly like live ones and the
sender exits immediately.
That drops the scaffolding whose only job was keeping a blocked sender alive: the
first-run invitations lose their `--show` re-entry and two transient units each,
omarchy-migrate-notify loses its transient service, and the screenshot,
recording, download, and taildrop toasts lose their wrapper subshells.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Keep a failed toast from failing the work it announces
Moving these sends out of their backgrounded subshells put a fallible command
on the foreground path, where the `&` used to swallow its exit status. A
notification outage — including the shell restart this branch targets — now
propagates:
- taildrop's receiver dies under `set -e` mid-delivery
- omarchy-capture-screenshot reports failure for a screenshot it already saved
- a completed download exits before scheduling its thumbnail cleanup, leaking
the mktemp file
Announcing is best-effort in all three: the work is already done by the time
the toast goes out.
Also drop the first-run sleep that spaced out the welcome and Wi-Fi toasts.
It compensated for the background notify-send processes this branch removes;
each send now returns only once the server has taken the toast, so sending in
order is enough to stack them newest-on-top.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Stop tying the preview cleanup to the toast's expiry
The shell loads a notification thumbnail into memory when the toast appears and
never re-reads the file, so the preview only has to outlive that load. Deriving
the cleanup delay from the expiry was false precision, and it turned -t into a
variable for no reason: -t is already the helper's expiry setting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Persist on-screen notification popups across shell restarts
Mirror every popup to its own file under
~/.local/state/omarchy/notifications/ for exactly as long as it is on
screen: written when the toast appears, deleted when it expires, is
dismissed, is acted upon, or is replaced via freedesktop replaces_id.
On startup the directory is read back and still-valid popups re-shown,
so toasts survive the restart omarchy-update performs — critical
alerts, which never expire, always make it across.
Restored popups keep ids from the previous server generation, so the
replaces_id cleanup tracks them separately instead of mistaking a
fresh notification's reused id for a replacement, and the startup
restore only discards a persisted file when a live row with a
different timestamp has superseded it. Files are read back with awk
so a torn write can't glue itself onto the next file and take a valid
popup down with it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Close the remaining cross-generation id collisions in popup persistence
Notification ids restart from 1 with every server process, so an id
alone never identifies a notification across a shell restart. The
first round of fixes guarded row removal, but review (and a live
repro) showed the same collision biting everywhere else an id was
used on its own:
- Dismissing or clicking a restored toast resolved liveRefs by id and
could dismiss, or fire the action of, an unrelated fresh
notification, and archive its pending row. Restored rows now never
resolve to a live object, and pending rows are matched by id plus
timestamp.
- parsePopupFiles deduped files by id, so a fresh notification reusing
a restored critical alert's id got that alert's file deleted as a
"stale duplicate" on the next restore. Files are never deduped now:
each one is a popup that was on screen, and the rare genuine
leftover from a crash re-shows once and cleans itself up.
- The restore only skips an entry when a live row matches both id and
timestamp (it is that entry); an id-only match shows both toasts
rather than guessing which one to drop.
- A same-millisecond replaces_id update shares its predecessor's
filename; the replacement's file is no longer deleted alongside the
replaced row.
- A restored popup's reset lifetime is persisted as an absolute
deadline, so a second restart judges it by the clock that actually
governs its display instead of dropping it while still on screen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Model rows stored the live Notification object in a ref role. When the
server destroyed the notification (sender close, DND untrack, dismiss)
the role became a dangling C++ pointer, and the next read segfaulted in
QQmlListModel::data — typically when replaying history over IPC. Track
live notifications in a JS map keyed by originalId instead, cleaned up
on close and untrack, where a stale reference degrades to a catchable
error instead of a crash.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>