
Foxl v0.2.23 rebuilds the notification stack. Every banner now has action buttons. Every click lands on the specific thing the notification was about. "Install Now" actually installs. And the whole pipeline is covered by end-to-end tests so it stays that way.
The problem
The notification system had a split personality. The update-ready toast had "Install Now" / "Later" buttons. Everything else — agent done, schedule completed, Slack message, feed alert — was just a banner that focused the app window and dropped you on the default page. Even worse, clicking "Install Now" on macOS did nothing. The button was there; it just didn't work.
Under the hood there were three races:
- Missing actions. The
showNotification IPC accepted an actions array, but the WebSocket handler that forwarded server events never attached one. So the Electron main process received an action-less notification and rendered it that way. - Missing meta. Even when the action array was wired, clicking "Open Agent" dropped you on
/agents — not /agents/{sessionId}. The server knew which agent finished, but that information was lost between the broadcast and the renderer's action handler. - quitAndInstall race. "Install Now" called
autoUpdater.quitAndInstall synchronously from the notification action callback. On macOS, that call races with the native notification dismiss animation and silently aborts the quit about half the time. The button was clicked, the IPC fired, the main process logged "applying update" — and then nothing happened.
Actions on every type
Each notification type now has a default action button set derived from its type. Agent events get "Open Agent". Schedules get "View". Chat and channel messages get "Reply". Feed alerts get "View". All of them get "Dismiss". The defaults live in defaultActionsForType in both the WebSocket handler (for the native OS toast) and the in-app notification store (for the bell popover), so the two surfaces stay consistent even when one isn't available — Windows and Linux don't render native action buttons, but the bell popover picks up the slack.
Meta flows end-to-end
The server now attaches a meta field to every notification emission. Subagent completions carry {agentId, sessionId, conversationId}. Schedule events carry {scheduleId, scheduleName}. Channel messages carry {conversationId, channel, channelId}. The WebSocket handler forwards meta to pilot.showNotification. Electron's main process stores it in closure, then includes it in the navigate and app-command IPCs when the user clicks. The renderer's navigate() already knew how to deep-link — it was just never given the ids.
The result: clicking a Slack message notification opens /chat/{conversationId}. Clicking "Agent Done" opens /agents/{sessionId}. Clicking a schedule opens /schedules#{scheduleId}, with the hash so the list page can scroll to the row. This works for the notification body click AND the action button click — they both route through the same meta-aware navigator.
Install Now, actually
The fix for the update-ready button is three lines. installUpdateAndRestart() now:
- Defers
autoUpdater.quitAndInstall(true, true) to the next tick via setImmediate, so the IPC reply unwinds and the notification dismiss completes before the native call. - Explicitly sets
autoInstallOnAppQuit = true right before the call, so even if quitAndInstall is a no-op, the next ordinary app quit still applies the update. - Catches exceptions from the native call and falls back to
app.quit(), relying on the autoInstallOnAppQuit belt to finish the install on shutdown.
None of these are clever. They're just the three things quitAndInstall needs to reliably apply an update from inside a notification callback. The test for this is manual — Playwright can't drive the macOS NSUserNotification banner — but it's in the release checklist now.
Agents page bug: "1 active" with an empty list
A smaller but visible bug got fixed in the same pass. The Agents page header showed "1 active" when a chat-driven agent was running, but the list below it was empty or only had yesterday's completed runs. The badge read from in-memory team state. The list read from the DB-backed agent_sessions table. Chat-driven streaming agents never wrote to that table, and the time filter (Today / 7 Days / 30 Days / All) could exclude long-running sessions that started before the window.
The fix merges live teamState.members into the session list as synthetic rows, and running sessions bypass the time filter so a 20-hour-long agent stays visible even when you have "Today" selected. They render in a pinned "Active" group at the top of the list.
Settings picker: single source of truth
The Settings page test picker was using a CustomEvent shortcut that bypassed the WebSocket round-trip entirely. It would pop an in-app notification with whatever mock payload the picker wanted, which meant it could easily drift from production — we caught this exact bug this session. The picker now hits POST /api/test/notification and the server broadcasts over WS just like real events. Clicking "Send test" exercises the exact same pipeline a real notification does.
We also pruned the Settings test scenarios. pet_expedition_return and pet_item_drop were in the picker but no code ever emitted them — dead UI. channel_message_whatsapp and channel_message_web were emitted by the server but missing from the picker — blind spots. Both are fixed. And the picker now resolves real deep-link ids from the server: when you test an agent notification, it fetches your most recent agent session and uses that session's id as meta.sessionId, so the click actually lands on a concrete page row.
Tests
34 notification-related tests, covering the full pipeline:
- notifications-e2e.spec.ts (server/WS, 30 tests): endpoint validation, WS delivery for every type, meta propagation (object / primitive rejection / missing), 11 guardrail tests that make sure every server-emitted type has a matching Settings scenario, subagent completion shape.
- notification-actions-ui-e2e.spec.ts (UI, 6 tests): Settings picker hits the real endpoint (not CustomEvent shortcut), bell popover renders action buttons, each action's
pushState observed via a history.pushState spy for race-free deep-link verification.
What to look for
| Event | Action | Lands on |
|---|
| Agent completed | Open Agent | /agents/{sessionId} |
| Schedule completed | View | /schedules#{scheduleId} |
| Channel message | Reply | /chat/{conversationId} |
| Feed (urgent) | View | /activities |
| Update ready | Install Now | relaunch into new version |
Upgrade with the usual Check for Updates menu item. The new notification flow takes effect the moment you click any banner.