Skip to content

feat(mobile): keep glanceable session counts fresh on foreground and Android background - #6112

Open
iscekic wants to merge 1 commit into
mainfrom
kwf/glanceable-updates-staleness-e460
Open

feat(mobile): keep glanceable session counts fresh on foreground and Android background#6112
iscekic wants to merge 1 commit into
mainfrom
kwf/glanceable-updates-staleness-e460

Conversation

@iscekic

@iscekic iscekic commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Changelog for users

  • Glanceable surfaces now show the current session counts instead of the counts from the last app open.
  • The Android ongoing notification and home widget update while the app is backgrounded, without opening the app.
  • Opening the app refreshes the Agents Active row and every glanceable surface in one pass.
  • A failed refresh or dropped network keeps the last counts on every surface instead of blanking.
  • A failed Agents-list refresh shows an inline "Couldn't refresh" with Retry while the kept rows stay in place.

Changelog for maintainers

  • Android Expo data-only wakes now send FCM high priority; iOS stays default because APNs background pushes use priority 5 and the Live Activity rides the direct APNs path. Start review at buildGlanceableExpoMessages and deliverGlanceableSnapshot.
  • The app-level mount subscribes to AppState change and schedules one 'foreground' refresh on transition to active. It rides the existing wakeup and adds no poll; cleanup removes the subscription with the sync.
  • 'foreground' joins the existing refresh-reason set, so superseded deliveries stay revision-fenced by the current fetch queue.
  • A retryable refresh failure with cached rows now routes through retryableRowsFailure to the reserved status line, not the load-failure block; a ref lock keeps the inline Retry idempotent.
  • LiveSessionFeedback allocates a min-h-5 slot for the refresh status whenever rows are shown, so a failure replaces empty space instead of pushing kept rows down. The count eyebrow is withheld while the list is unresolved or in error.
  • services/notifications/vitest.config.mts pins PUSH_SINK_MODE='' so unit tests no longer inherit a developer .dev.vars sink override.
  • .dev.vars.example documents KILO_WEB_API_BASE_URL for the local glanceable-snapshot route.
  • No schema, migration, or data-contract changes.

E2E proof

p1-recording.mp4
e2-clear.mp4
e2-bg-clear.mp4
p2-terminal.mp4
p2-empty-raw.mp4
p1-background-fresh2.mp4

E2E proof — log excerpts

[e1] needs:fault — retryable unhappy -> pass :: scripted-e1.log has the parked scene ('SCENE e1 OK', digest 'android.widget.TextView 1 LIVE'); e1-run.log: with the network dropped while backgrounded ('NETOFF ping=[connect: Network is unreachable ]') and a session set idle, the unused notification kept 'OFFLINE-CHANGE bg notif=[(1 Needs input)]' and the returned Agents digest kept 'android.widget.TextView 1 LIVE' plus the retained row 'android.widget.Button Greeting, needs input' (no blank); after 'NETON notif=[(1 Idle)]' and the next change to busy the surface read 'FG-T+1s notif=[(1 Working)]' — within ~1 s; e1d-run.log settles the changed retryable rows branch with the API faulted ('android.widget.TextView Couldn't refresh' and 'android
[e2] ux-check — ongoing notification clears and widget shows empty copy -> pass :: e2-run.log: backgrounded with one active session the stale count held ('BG stale notif=[(1 Idle)]', 'widget=[content-desc="1 Idle, Open agents"]'); after ending all seeded sessions ('remote-cli.sh stop') the background polls held that count (no local FCM/Expo injection on this host), and on the app's AppState->active return it read 'FG T+2s notif=[(No work in progress)]' then 'CLEARED at FG T+6s' (within the ~8 s window), and backgrounded again 'EMPTY notif=[] records=0 snap=[status":"empty","running":0,"needsInput":0,"idle":0] widget=[content-desc="No work in progress, Open agents"]' — no stale counts; backend-priority-thisrun.log proves 'builds
/home/igor_kilocode_ai/.local/share/kwf/sections/glanceable-updates-staleness-e460/e2e-mobile-app/e1-run.log
FINAL digest:
hierarchy: /tmp/kilo-hierarchy.yn96bW
android.widget.LinearLayout com.kilocode.kiloapp:id/action_bar_root tappable [0,0][1080,2400]
android.widget.FrameLayout android:id/content tappable [0,0][1080,2400]
android.widget.TextView 1 LIVE tappable [58,149][761,186]
android.view.View Agents tappable [58,190][761,296]
android.widget.Button See all tappable [787,203][933,242]
android.widget.TextView SEE ALL tappable [787,203][933,242]
android.widget.Button Filter sessions tappable [970,196][1022,248]
android.widget.EditText Search sessions tappable [163,340][984,449]
android.widget.Button Greeting, Idle, kwf/glanceable-updates-staleness-e460, CLOUD, and cost 1 cent, 1 hour ago tappable [0,549][1080,755]
android.widget.TextView CLOUD tappable [93,583][610,620]
android.widget.TextView $0.01 · 1 HOUR AGO tappable [654,583][957,620]
android.widget.TextView Greeting tappable [93,627][958,673]
android.widget.TextView kwf/glanceable-updates-staleness-e460 tappable [93,683][958,720]
android.widget.Button New session tappable [881,2006][1028,2153]
android.view.View Home, tab, 1 of 3 tappable [0,2195][360,2337]
android.widget.TextView HOME tappable [13,2281][347,2320]
android.view.View Agents, tab, 2 of 3 tappable [360,2195][720,2337]
android.widget.TextView AGENTS tappable [373,2281][707,2320]
android.view.View Profile, tab, 3 of 3 tappable [720,2195][1080,2337]
android.widget.TextView PROFILE tappable [733,2281][1067,2320]
/home/igor_kilocode_ai/.local/share/kwf/sections/glanceable-updates-staleness-e460/e2e-mobile-app/e1-updated.png 85655
== done 06:59:19 ==
/home/igor_kilocode_ai/.local/share/kwf/sections/glanceable-updates-staleness-e460/e2e-mobile-app/scripted-e1.log
SCENE e1 OK
android.widget.LinearLayout com.kilocode.kiloapp:id/action_bar_root tappable [0,0][1080,2400]
android.widget.FrameLayout android:id/content tappable [0,0][1080,2400]
android.widget.TextView 1 LIVE tappable [58,149][761,186]
android.view.View Agents tappable [58,190][761,296]
android.widget.Button See all tappable [787,203][933,242]
android.widget.TextView SEE ALL tappable [787,203][933,242]
android.widget.Button Filter sessions tappable [970,196][1022,248]
android.widget.EditText Search sessions tappable [163,340][984,449]
android.widget.Button Greeting, needs input, kwf/glanceable-updates-staleness-e460, and CLOUD tappable [0,549][1080,755]
android.widget.TextView CLOUD tappable [93,583][728,620]
android.widget.TextView NEEDS INPUT tappable [772,583][957,620]
android.widget.TextView Greeting tappable [93,627][958,673]
android.widget.TextView kwf/glanceable-updates-staleness-e460 tappable [93,683][958,720]
android.widget.Button New session tappable [881,2006][1028,2153]
android.view.View Home, tab, 1 of 3 tappable [0,2195][360,2337]
android.widget.TextView HOME tappable [13,2281][347,2320]
android.view.View Agents, 1 needs input, tab, 2 of 3 tappable [360,2195][720,2337]
android.widget.TextView 1 tappable [539,2200][589,2250]
android.widget.TextView AGENTS tappable [373,2281][707,2320]
android.view.View Profile, tab, 3 of 3 tappable [720,2195][1080,2337]
android.widget.TextView PROFILE tappable [733,2281][1067,2320]
/home/igor_kilocode_ai/.local/share/kwf/sections/glanceable-updates-staleness-e460/e2e-mobile-app/e1d-run.log
/home/igor_kilocode_ai/.local/share/kwf/sections/glanceable-updates-staleness-e460/e2e-mobile-app/e1d-unhappy.png 89875
-- fault up nextjs 07:06:04
fault.sh: dev:restart nextjs: Restarting nextjs (waiting for the old process to shut down)...
fault.sh: dev:restart nextjs: Restarted nextjs
fault.sh: nextjs up (port 7400, pids 2374132 , bound 0.0.0.0:7400 )
-- RECOVERED --
android.widget.TextView 1 LIVE tappable
android.widget.Button See all tappable
android.widget.TextView SEE ALL tappable
android.widget.Button Filter sessions tappable
android.widget.TextView Couldn't refresh tappable
android.widget.Button Retry tappable
android.widget.TextView RETRY tappable
android.widget.Button Greeting, needs input, kwf/glanceable-updates-staleness-e460, and CLOUD tappable
android.widget.TextView CLOUD tappable
android.widget.TextView NEEDS INPUT tappable
android.widget.TextView Greeting tappable
android.widget.TextView kwf/glanceable-updates-staleness-e460 tappable
android.widget.Button New session tappable
android.widget.TextView HOME tappable
android.widget.TextView 1 tappable
android.widget.TextView AGENTS tappable
android.widget.TextView PROFILE tappable
== done FOUND=1 07:06:20 ==
/home/igor_kilocode_ai/.local/share/kwf/sections/glanceable-updates-staleness-e460/e2e-mobile-app/e2-run.log
-- end all seeded sessions: remote-cli.sh stop 07:00:09
Stopped 'kilo-e2e-cli-glanceable-updates-staleness-e460'.
BG T+1s notif=[(1 Idle)] snap=[status":"happy","running":0,"needsInput":0,"idle":1]
BG T+2s notif=[(1 Idle)] snap=[status":"happy","running":0,"needsInput":0,"idle":1]
BG T+3s notif=[(1 Idle)] snap=[status":"happy","running":0,"needsInput":0,"idle":1]
BG T+4s notif=[(1 Idle)] snap=[status":"happy","running":0,"needsInput":0,"idle":1]
BG T+5s notif=[(1 Idle)] snap=[status":"happy","running":0,"needsInput":0,"idle":1]
BG T+6s notif=[(1 Idle)] snap=[status":"happy","running":0,"needsInput":0,"idle":1]
BG T+7s notif=[(1 Idle)] snap=[status":"happy","running":0,"needsInput":0,"idle":1]
BG T+8s notif=[(1 Idle)] snap=[status":"happy","running":0,"needsInput":0,"idle":1]
-- return to app (AppState -> active) 07:00:21
FG T+1s notif=[(1 Idle)] records=1 snap=[status":"empty","running":0,"needsInput":0,"idle":0]
FG T+2s notif=[(No work in progress)] records=1 snap=[status":"empty","running":0,"needsInput":0,"idle":0]
FG T+3s notif=[(No work in progress)] records=1 snap=[status":"empty","running":0,"needsInput":0,"idle":0]
FG T+4s notif=[(No work in progress)] records=1 snap=[status":"empty","running":0,"needsInput":0,"idle":0]
/home/igor_kilocode_ai/.local/share/kwf/sections/glanceable-updates-staleness-e460/e2e-mobile-app/backend-priority-thisrun.log
 ✓ src/lib/glanceable-delivery.test.ts > buildGlanceableExpoMessages > builds the iOS wake at default priority and the Android wake at high priority 0ms
 Test Files  1 passed (1)
      Tests  80 passed (80)
Owner request

Surface: the mobile app (apps/mobile), and the backend (services/notifications and the session-ingest triggers that feed it) only where it needs to change.

Improve the freshness of the glanceable state — the iOS Live Activity and widgets, and the Android ongoing notification and home widget — so it reflects the current session states with much lower staleness. Today the aggregate is often stale: it shows, for example, 5 in progress and 1 idle while the truth when the app is opened is 3 in progress and 3 idle. After this change a glanceable surface must show the current counts rather than the counts from the last time the app happened to be opened.

Definitions that govern every design decision and the PR description:

  • Scalable: the change must not incur additional battery cost for the user. Do not add polling, new background timers, shorter refresh intervals, or any new device wakeups. The set of pushes and wakeups the device already receives must not grow; freshness may only ride work the system already does.
  • Performant: the glanceable state must be as fresh as we can make it without breaking the battery rule. Never buy freshness at the cost of battery; when a fresher option would add a wakeup, take the battery-neutral option.

How freshness is delivered (this is the shape of the system; do not replace it with polling):

  • Background: the existing server-triggered aggregate snapshot. A real session-status change refreshes the snapshot once per user+org and a data-only push updates the native surfaces. Keep it event-driven, one snapshot per user+org per change, with superseded deliveries revision-fenced, so backend work and device wakeups scale with real changes only. The Android data-only wake must actually reach the device while the app is backgrounded so the ongoing notification and widget do not hold stale counts until the app is opened.
  • Foreground: one refresh of the active-sessions source on AppState -> active, so every glanceable surface (and the Agents Active row) is current the moment the app is opened, without blanking or jumping the existing content.
  • No new polls, no shorter refetch intervals, no extra background timers. Surfaces keep their last snapshot on failure or reconnect rather than blanking, and the app scales for the backend because each refresh stays one bounded snapshot per user+org.

Owner manual verification

The workflow skipped these checks. Owner verification is pending; these checks did not pass automatically.

  • owner-manual: Android data-only FCM wake while backgrounded (no local push inject;

…Android background

Surface: the mobile app (apps/mobile), and the backend (services/notifications and the session-ingest triggers that feed it) only where it needs to change.

Improve the freshness of the glanceable state — the iOS Live Activity and widgets, and the Android ongoing notification and home widget — so it reflects the current session states with much lower staleness. Today the aggregate is often stale: it shows, for example, 5 in progress and 1 idle while the truth when the app is opened is 3 in progress and 3 idle. After this change a glanceable surface must show the current counts rather than the counts from the last time the app happened to be opened.

Definitions that govern every design decision and the PR description:

- Scalable: the change must not incur additional battery cost for the user. Do not add polling, new background timers, shorter refresh intervals, or any new device wakeups. The set of pushes and wakeups the device already receives must not grow; freshness may only ride work the system already does.
- Performant: the glanceable state must be as fresh as we can make it without breaking the battery rule. Never buy freshness at the cost of battery; when a fresher option would add a wakeup, take the battery-neutral option.

How freshness is delivered (this is the shape of the system; do not replace it with polling):

- Background: the existing server-triggered aggregate snapshot. A real session-status change refreshes the snapshot once per user+org and a data-only p
@kilo-code-bot

kilo-code-bot Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Executive Summary

Reviewed the mobile foreground-refresh plumbing, the Agents-list refresh feedback and retry idempotence, and the Android FCM high priority change against the changed lines; the background/foreground refresh path stays coalesced and fenced, the AppState subscription is removed on cleanup, and no new memory leak or runtime bug was found. Documentation-only SEO and markdown-image rules do not apply to this diff.

Files Reviewed (11 files)
  • apps/mobile/src/components/agents/session-list-screen.mounted.test.tsx
  • apps/mobile/src/components/agents/session-list-screen.tsx
  • apps/mobile/src/components/home/agent-sessions-section.tsx
  • apps/mobile/src/lib/active-sessions-live-sync-mount.tsx
  • apps/mobile/src/lib/active-sessions-live-sync.foreground.test.ts
  • apps/mobile/src/lib/active-sessions-live-sync.ts
  • apps/mobile/src/lib/hooks/use-agent-sessions.live.mounted.test.tsx
  • services/notifications/.dev.vars.example
  • services/notifications/src/lib/glanceable-delivery.test.ts
  • services/notifications/src/lib/glanceable-delivery.ts
  • services/notifications/vitest.config.mts

Reviewed by deepseek-v4.1-flash · Input: 0 · Output: 0 · Cached: 0

Review guidance: REVIEW.md from base branch main

@iscekic iscekic added the human-ready The PR is ready for human review. label Sep 13, 2026
@iscekic
iscekic requested a review from eshurakov September 13, 2026 07:36
@iscekic iscekic self-assigned this Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

human-ready The PR is ready for human review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant