Skip to content

feat(mobile): notify by oversight category with deep links, quiet hours and a stuck threshold - #1653

Merged
janicduplessis merged 4 commits into
feat/1644-oversight-notificationsfrom
feat/1645-app-oversight-notifications
Sep 27, 2026
Merged

janicduplessis merged 4 commits into
feat/1644-oversight-notificationsfrom
feat/1645-app-oversight-notifications

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Stacked on #1648, which adds the shared rule module (apps/mobile/src/lib/oversight.ts, identical to the server's) and makes stim-server push by oversight category. The diff shown here is only the phone side.

The app still notified locally on raw attention events: a failed build, new log errors, low disk, offline, a stopped app, a slow build. Settings had one switch per raw event plus an "only workspaces an agent drives" filter. A tap could open only home, the machine sheet, a workspace or its errors. Every notification played a sound, and each one stacked under a new identifier.

Solution

Local notifications come from the shared rules. localNotifications runs oversee per machine. Each machine gets two state scopes, as before: link:<id> for the connection (offline / refused / unpaired, which only the phone sees) and status:<id> for everything the status carries. Local notifications cover foreground use and servers that don't push. For a machine that accepted push.register, the status scope still runs but with no categories, so it records state without notifying. That way the phone never repeats a push, and doesn't dump a backlog if pushing stops. Without push there are no PR lookups or control conflicts; git's mergedInto stands in for "merged".

Delivery. Each local notification is scheduled under a stable identifier (<mac>:<category>:<path>), so a later episode replaces the earlier notification instead of stacking. Work started is quiet: interruptionLevel: 'passive' and no sound on iOS, and a new low-importance "Work started" channel on Android. It uses threadIdentifier: started:<Mac>. The foreground handler now follows each notification, local or pushed: no banner or sound for passive ones, and sound only when the notification has one.

Deep links. Notification data gains the device, build and url targets the server sends.

  • A tap opens the device viewer, the build details sheet (which shows the diagnostics), the workspace, or the machine sheet.
  • A url opens in the browser only when it matches https://github.com/<owner>/<repo>/pull/<n>. Anything else falls back to the workspace, so a push can't make the phone open an arbitrary link.

Settings. Settings gets a switch per category, Stuck after (5 to 60 min, default 15), Quiet hours (off or three night ranges) and a rewritten footer. push.register now sends the categories, stuckMinutes and quietHours with the phone's IANA time zone. It re-registers when any of them change or the connection reopens. Stored preferences from before this change keep enabled and get every category.

Attention strip. The strip keeps showing failed builds, log errors, stopped apps and slow builds. HomeAttentionItem loses the fields that existed only for notifications (event, occurrence, reason, driven, count), and src/lib/notify.ts goes away.

Risk

  • Against an older stim-server (before feat(server): push what an overseeing human needs instead of raw attention events #1648), push.register with the new event names is refused as bad-request. The app then treats that Mac as not pushing, sends push.unregister so the old registration stops pushing the raw events, and notifies locally.
  • No new native module: expo-notifications already ships, so this is OTA-compatible with the current TestFlight build. The Android channels are created whenever the notifier starts, so a phone that turned notifications on before gets the quiet channel too.

Test plan

  • src/lib/notifications.test.ts covers:
    • a looping build notified once with its stable id and build target
    • removed events staying silent
    • work started being quiet, threaded, and opening the device viewer
    • offline after a minute of open app time
    • pushed machines staying silent while their disconnection still notifies
    • switched-off categories and quiet hours
    • the summary
    • migrating prefs from before this change
    • every route, including a non-GitHub url falling back to the workspace
  • Simulator run (iPhone 18 Pro, iOS 27.0), Stim-built app from this branch, with a scratch STIM_HOME. I drove it with agent-device against the mock server on port 7797, whose --overlay I changed step by step:
    • A single failed build and 7 log errors showed in the attention strip and did not notify.
    • The third identical failure notified once, with janic/wide-insets / Mock Mac / Same Swift error 3x at AppDelegate.swift:71. A fourth failure did not notify again.
    • With Stuck after at 5 minutes, one No agent activity for 5 min; iPhone 18 Pro 27.0 still up arrived at the threshold.
    • Stopping the workspace after a green build gave Agent stopped after a green iOS build.
    • Low disk gave 3.0 GB free, below Stim's floor.
    • Work started (warming, then driving) arrived without a banner, as intended.
    • Tapping the loop banner opened the iOS build details sheet with the AppDelegate.swift:99:9 diagnostic. Tapping the disk banner opened the machine sheet.
  • Real server: I paired the same app with a scratch stim-server from this stack on port 7911.
    • push.register stored all six categories, stuckMinutes: 5 and a real Expo token.
    • With the phone controlling the scratch workspace's simulator, a second paired client called control.begin { takeOver: true }. The phone showed "Control ended. Test iPad took over this device." and the server sent the control push to Expo without a ticket error.
    • On the simulator, killing my own agent-device runner was the only way to end "driven". App log chatter (about 200 records a minute) is why feat(server): push what an overseeing human needs instead of raw attention events #1648 adds activity.recent.

Fixes #1645

@janicduplessis
janicduplessis added this pull request to stack #1654 September 27, 2026 03:41

@janicduplessis janicduplessis left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fresh review of the phone half (diff vs feat/1644-oversight-notifications). tsc --noEmit, npm test (158 passing), npm run lint and npm run format:check pass in apps/mobile.

  1. bug apps/mobile/src/hooks/notifications.tsx:328-330 - taps are deduplicated by request.identifier alone (HANDLED_KEY). Before this PR every local notification had a fresh identifier; now they are stable on purpose (<mac>:looping-ios:<path>, summary), and pushes carry an APNs collapseId, which iOS uses as the request identifier. So after the user taps a loop notification, the next episode's loop notification for the same workspace (or the next summary) replaces it under the same identifier, and tapping it does nothing: storage.getString(HANDLED_KEY) === id returns early until some other notification is tapped. Fix: key the marker by identifier plus delivery time, e.g. const id = `${response.notification.request.identifier}:${response.notification.date}` .

  2. should-fix apps/mobile/src/hooks/notifications.tsx:308-312 - against a stim-server from before #1648, push.register with the new event names is refused (bad-request), and the app then marks the Mac as not pushing and notifies locally. The Mac's registry still holds the registration the phone made before the update (old events: build-failed, log-errors, disk, app-stopped, slow-build), and a refused register does not replace it. So that Mac keeps pushing the raw events this PR removes, and low disk arrives twice: once as the old disk push and once as the local machine notification. The PR's Risk section says this is the existing fallback path, but that path assumed the old registration was gone. Fix: when the refusal is bad-request, send push.unregister so the old server stops pushing. The other option is to retry with the legacy names, which the new server also accepts (LEGACY_PUSH_EVENTS).

  3. nit apps/mobile/src/screens/settings.tsx:145 / settings.ios.tsx:187 - Stuck after shows even when Agent looks stuck is off, where it has no effect. Show it only when prefs.categories.includes('stuck').

  4. nit apps/mobile/src/hooks/notifications.tsx:243 - timeZone is read on each render, so a phone that changes time zone re-registers only after some unrelated re-render (a connection change). This is harmless in practice. I'm mentioning it only because the PR says it "re-registers when any of those change".

Checked with no finding:

  • Per-machine link:/status: scopes. The link scope always runs with status: null, and the status scope with link: null.
  • A pushed machine's status scope runs with no categories, so lasting marks problems settled and event drops them. No backlog if pushing stops.
  • The summary's quiet flag.
  • Quiet hours via inQuietHours, with the 30 s tick picking up their end.
  • parsePrefs migration and ranges, which match the server schema (1-240).
  • The url allowlist: the regex anchors the host, and a non-matching url falls back to the workspace.
  • device/build routes and their params match src/app/mac/[id]/device.tsx and build.tsx.
  • The foreground handler. iOS serializes interruptionLevel. On Android, local sound: false serializes to null, and the LOW channel prevents heads-up.
  • App PushRegisterParams against packages/server/src/protocol.ts.
  • README and footer, against oversight.ts behavior.
  • Tests assert notifier output and routes at the right boundary, with no constant or pass-through tests. No test covers finding 1, which sits in the hook.

@janicduplessis

Copy link
Copy Markdown
Collaborator Author

Review follow-up:

  1. Fixed: the tap marker is keyed by identifier plus delivery date, so a later episode under the same identifier opens on tap.
  2. Fixed: when a server refuses push.register with bad-request (a stim-server from before feat(server): push what an overseeing human needs instead of raw attention events #1648), the app now sends push.unregister so that server stops pushing the old raw events, and notifies locally.
  3. Fixed: Stuck after shows only while stuck notifications are on.
  4. Left as is: the time zone goes with the next registration, which is sent on reconnect or any preference change; a time zone change alone does not re-register.

@janicduplessis janicduplessis left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fresh review of b79d8c8 and of the phone diff vs feat/1644-oversight-notifications. No actionable findings.

typecheck, format:check, lint and npm test (158 passing) pass in apps/mobile.

What I checked in the fix commit:

  • Tap marker: it is now <identifier>@<date>. A later notification that reuses an identifier (a stable local id, summary, or an APNs collapseId) has a new delivery date, so a tap on it opens its route. A re-render with the same response still has the same key and is still ignored.
  • bad-request unregister: the pre-#1648 registerPush (a24deff server.ts) refuses unknown events with bad-request, and its push.unregister handler takes {}, so the old registration is removed. The unregister runs only while current() holds. A newer registration sent in the meantime therefore can't be undone by a stale refusal, and on one socket the unregister is ordered before any later register. A new server that refuses a register for another reason also gets unregistered. That matches setPushed(false) and local notifying, so the phone doesn't get the same event twice.
  • Stuck after: now shown only while stuck is among the categories, on both settings screens.

The rest of hooks/notifications.tsx and lib/notifications.ts had no new issue. One note that needs no change: on the first launch after the update against an old server, the stored pushed: flag keeps the status scope silent until the refusal arrives. A problem already present in that window is marked settled and isn't notified locally. This happens once, and the flag is cleared on refusal.

@janicduplessis
janicduplessis marked this pull request as ready for review September 27, 2026 03:47
@janicduplessis
janicduplessis force-pushed the feat/1645-app-oversight-notifications branch from b79d8c8 to cfcc7f6 Compare September 27, 2026 03:53
@janicduplessis
janicduplessis force-pushed the feat/1645-app-oversight-notifications branch from cfcc7f6 to 3bcdf89 Compare September 27, 2026 04:03

@janicduplessis janicduplessis left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fresh review of 3bcdf89 (web in notification routing and the attention strip).

No actionable findings.

  • notificationRoute accepts platform: 'web' for device targets. app/mac/[id]/device.tsx from #1650 accepts web, so a web started or stuck notification opens the page in the device viewer. A build target with web falls through to the workspace, which is right because web has no builds.
  • NotificationData.platform and the device route params use DevicePlatform, which matches the server's widened PushTarget.
  • The attention strip's page-web item no longer carries the removed push-event fields, and still opens the workspace. The existing attention test for a failed web page covers it.
  • pnpm exec tsc --noEmit passes in apps/mobile, npm test -- src/lib passes (159 tests), and oversight-agreement.test.ts passes.

…rs and a stuck threshold

Local notifications come from the rules stim-server pushes with, per category, updated in place under a stable identifier, quiet for work started, and open the device viewer, build details, a pull request, the workspace or the machine sheet. Settings gets a switch per category, the stuck threshold and quiet hours. Fixes #1645.
Handle a tap on a later notification that reuses an identifier, unregister from a stim-server that refuses the new events so it stops pushing the old ones, and show the stuck threshold only while stuck notifications are on.
@janicduplessis
janicduplessis force-pushed the feat/1645-app-oversight-notifications branch from 3bcdf89 to 85798a4 Compare September 27, 2026 04:11
@janicduplessis
janicduplessis merged commit 772ea38 into main Sep 27, 2026
5 checks passed
@janicduplessis
janicduplessis deleted the feat/1645-app-oversight-notifications branch September 27, 2026 04:20
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.

Phone app: notification categories, deep links and settings for the redesigned rules

1 participant