From 0489c41a77c5f814bce29ddc4afc6322a4ae3901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 4 Sep 2026 10:53:23 +0200 Subject: [PATCH 01/20] Make push badge data authoritative (kwf deliver-the-work-described-b-fbf8/s1) --- .../src/__tests__/dispatch-push.test.ts | 41 +++++------ .../src/dos/NotificationChannelDO.ts | 29 +++----- .../src/lib/glanceable-delivery.test.ts | 68 +++++++++++++------ .../src/lib/glanceable-delivery.ts | 1 + 4 files changed, 80 insertions(+), 59 deletions(-) diff --git a/services/notifications/src/__tests__/dispatch-push.test.ts b/services/notifications/src/__tests__/dispatch-push.test.ts index b1bcf81842..fcc39c9367 100644 --- a/services/notifications/src/__tests__/dispatch-push.test.ts +++ b/services/notifications/src/__tests__/dispatch-push.test.ts @@ -85,6 +85,12 @@ function getDO(name = 'user-1') { return env.NOTIFICATION_CHANNEL_DO.get(id); } +afterEach(() => { + for (const [messages] of vi.mocked(sendPushNotifications).mock.calls) { + for (const message of messages) expect(message).not.toHaveProperty('badge'); + } +}); + describe('NotificationChannelDO.dispatchPush', () => { beforeEach(() => { vi.clearAllMocks(); @@ -241,8 +247,6 @@ describe('NotificationChannelDO.dispatchPush', () => { expect(result.kind).toBe('delivered'); expect(sendPushNotifications).toHaveBeenCalledOnce(); - const [[messages]] = vi.mocked(sendPushNotifications).mock.calls; - expect(messages[0].badge).toBe(1); // Bucket persisted to DO storage. const stored = await runInDurableObject(stub, async (_inst, state) => ({ @@ -276,8 +280,6 @@ describe('NotificationChannelDO.dispatchPush', () => { expect(result.outcome.kind).toBe('delivered'); expect(result.totalReads).toBe(1); - const [[messages]] = vi.mocked(sendPushNotifications).mock.calls; - expect(messages[0].badge).toBe(1); }); it('returns failed and avoids delivered idempotency for non-stale Expo ticket errors', async () => { @@ -445,7 +447,6 @@ describe('NotificationChannelDO.dispatchPush', () => { expect(sendPushNotifications).toHaveBeenCalledOnce(); const [[messages]] = vi.mocked(sendPushNotifications).mock.calls; expect(messages.map(message => message.to)).toEqual(['tok-accepted', 'tok-rate-limited']); - expect(messages.map(message => message.badge)).toEqual([1, 1]); expect(receiptSpy).toHaveBeenCalledWith( { ticketTokenPairs: [{ ticketId: 'ticket-accepted', token: 'tok-accepted' }] }, { delaySeconds: 900 } @@ -489,10 +490,6 @@ describe('NotificationChannelDO.dispatchPush', () => { }); expect(second).toEqual({ kind: 'delivered', tokenCount: 1 }); expect(sendPushNotifications).toHaveBeenCalledTimes(2); - const [firstMessages] = vi.mocked(sendPushNotifications).mock.calls[0]; - const [secondMessages] = vi.mocked(sendPushNotifications).mock.calls[1]; - expect(firstMessages[0].badge).toBe(1); - expect(secondMessages[0].badge).toBe(1); const stored = await runInDurableObject(stub, async (_inst, state) => state.storage.get<{ stage: string; ts: number }>('idem:k-retry-ticket-error') @@ -500,7 +497,7 @@ describe('NotificationChannelDO.dispatchPush', () => { expect(stored).toMatchObject({ stage: 'delivered' }); }); - it('accumulates bucket counts across deliveries and exposes total via badge', async () => { + it('accumulates bucket counts across deliveries', async () => { installDbMock({ tokens: [{ user_id: 'u', token: 'tok1' }] }); vi.spyOn(env.EVENT_SERVICE, 'isUserInContext').mockResolvedValue(false); const stub = getDO('user-accumulate'); @@ -514,11 +511,6 @@ describe('NotificationChannelDO.dispatchPush', () => { }) ); - const calls = vi.mocked(sendPushNotifications).mock.calls; - expect(calls[0]?.[0][0].badge).toBe(1); - expect(calls[1]?.[0][0].badge).toBe(2); - expect(calls[2]?.[0][0].badge).toBe(3); - const buckets = await runInDurableObject(stub, async (_inst, state) => { const entries = await state.storage.list({ prefix: 'bucket:' }); return Array.from(entries.entries()); @@ -678,8 +670,20 @@ describe('NotificationChannelDO.dispatchPush', () => { ); expect(afterFail).toBe(1); - const second = await stub.dispatchPush(input); - expect(second.kind).toBe('delivered'); + const retry = await runInDurableObject(stub, async (instance, state) => { + const originalGet = state.storage.get.bind(state.storage); + let totalReads = 0; + state.storage.get = ((key: string | string[]) => { + if (Array.isArray(key)) return originalGet(key); + if (key === 'total') totalReads++; + return originalGet(key); + }) as typeof state.storage.get; + + const outcome = await instance.dispatchPush(input); + return { outcome, totalReads }; + }); + expect(retry.outcome.kind).toBe('delivered'); + expect(retry.totalReads).toBe(0); // Bucket must not be incremented twice across the retry — the first // attempt's `pending` marker gates the second increment out. @@ -687,9 +691,6 @@ describe('NotificationChannelDO.dispatchPush', () => { state.storage.get('bucket:conv1') ); expect(afterRetry).toBe(1); - - const [[messages]] = vi.mocked(sendPushNotifications).mock.calls; - expect(messages[0].badge).toBe(1); }); it('schedules cleanup when writing the pending marker (failed send)', async () => { diff --git a/services/notifications/src/dos/NotificationChannelDO.ts b/services/notifications/src/dos/NotificationChannelDO.ts index b1efc094d7..8ac957b1a9 100644 --- a/services/notifications/src/dos/NotificationChannelDO.ts +++ b/services/notifications/src/dos/NotificationChannelDO.ts @@ -159,22 +159,15 @@ export class NotificationChannelDO extends DurableObject { // 4. Badge math. On a retry the badge was already incremented during // the prior attempt; re-applying the delta would double-count. - // The total is recomputed in either case (other writers may have - // advanced it). - let badgeTotal: number | undefined; - if (input.badge) { - if (!isRetry) { - // Mark `pending` BEFORE the increment so any later failure path - // is gated on the marker and a retry skips the increment. - const ts = Date.now(); - await this.ctx.storage.put(idemKey, { stage: 'pending', ts }); - // Also schedule cleanup at this point — if Expo keeps failing and - // no future push ever lands, `pending` would otherwise leak. - await this.ensureCleanupAlarm(ts); - badgeTotal = await this.incrementBucket(input.badge.badgeBucket, input.badge.delta); - } else { - badgeTotal = await this.getTotal(); - } + if (input.badge && !isRetry) { + // Mark `pending` BEFORE the increment so any later failure path + // is gated on the marker and a retry skips the increment. + const ts = Date.now(); + await this.ctx.storage.put(idemKey, { stage: 'pending', ts }); + // Also schedule cleanup at this point — if Expo keeps failing and + // no future push ever lands, `pending` would otherwise leak. + await this.ensureCleanupAlarm(ts); + await this.incrementBucket(input.badge.badgeBucket, input.badge.delta); } // 5. Tokens. Missing Expo tokens only means no OS push can be sent; the @@ -288,7 +281,6 @@ export class NotificationChannelDO extends DurableObject { // version at registration) get a channelId; older clients fall back // to the default channel. iOS ignores channelId either way. ...(app_version != null && { channelId }), - ...(badgeTotal !== undefined && { badge: badgeTotal }), sound: input.push.sound ?? undefined, priority: input.push.priority ?? 'default', } satisfies ExpoPushMessage; @@ -550,7 +542,7 @@ export class NotificationChannelDO extends DurableObject { // Read-modify-write of a bucket counter. The DO is single-threaded, so // this is race-free without explicit locking. - private async incrementBucket(bucket: string, delta: number): Promise { + private async incrementBucket(bucket: string, delta: number): Promise { const key = `${BUCKET_PREFIX}${bucket}`; const total = await this.getTotal(); const current = (await this.ctx.storage.get(key)) ?? 0; @@ -563,7 +555,6 @@ export class NotificationChannelDO extends DurableObject { const nextTotal = Math.max(0, total + delta); await this.ctx.storage.put(TOTAL_KEY, nextTotal); - return nextTotal; } // Aggregate badge count. Existing DOs without the aggregate fall back to one diff --git a/services/notifications/src/lib/glanceable-delivery.test.ts b/services/notifications/src/lib/glanceable-delivery.test.ts index 223f61a411..ba417bff48 100644 --- a/services/notifications/src/lib/glanceable-delivery.test.ts +++ b/services/notifications/src/lib/glanceable-delivery.test.ts @@ -1661,28 +1661,32 @@ describe('toGlanceableContentState', () => { }); describe('buildGlanceableExpoMessages', () => { - it('emits one data-only, tag-collapsed message per Expo token', () => { - const messages = buildGlanceableExpoMessages( - [ - { token: 'ExponentPushToken[aaa]', locale: null }, - { token: 'ExponentPushToken[bbb]', locale: 'es' }, - ], - snapshot - ); + it.each([0, 1, 3])( + 'emits badge %i in one data-only, tag-collapsed message per Expo token', + needsInput => { + const messages = buildGlanceableExpoMessages( + [ + { token: 'ExponentPushToken[aaa]', locale: null }, + { token: 'ExponentPushToken[bbb]', locale: 'es' }, + ], + { ...snapshot, needsInput } + ); - expect(messages).toHaveLength(2); - for (const message of messages) { - expect(message.data).toEqual(snapshot); - expect(message._contentAvailable).toBe(true); - expect(message.title).toBeUndefined(); - expect(message.body).toBeUndefined(); - expect(message.sound).toBeNull(); - expect(message.priority).toBe('default'); - expect(message.channelId).toBe('active-agents'); - expect(message.tag).toBe('deadbeef'); + expect(messages).toHaveLength(2); + for (const message of messages) { + expect(message.data).toEqual({ ...snapshot, needsInput }); + expect(message.badge).toBe(needsInput); + expect(message._contentAvailable).toBe(true); + expect(message.title).toBeUndefined(); + expect(message.body).toBeUndefined(); + expect(message.sound).toBeNull(); + expect(message.priority).toBe('default'); + expect(message.channelId).toBe('active-agents'); + expect(message.tag).toBe('deadbeef'); + } + expect(messages.map(m => m.to)).toEqual(['ExponentPushToken[aaa]', 'ExponentPushToken[bbb]']); } - expect(messages.map(m => m.to)).toEqual(['ExponentPushToken[aaa]', 'ExponentPushToken[bbb]']); - }); + ); }); describe('deliverGlanceableSnapshot', () => { @@ -1698,6 +1702,30 @@ describe('deliverGlanceableSnapshot', () => { expect(calls.expoSends).toHaveLength(0); }); + it('keeps the badge unchanged after transport failure and sends the latest count on retry', async () => { + const latestSnapshot = { ...snapshot, needsInput: 4 }; + const delivered: ExpoPushMessage[] = []; + const { deps } = fakeDeps({ + buildSnapshot: vi.fn().mockResolvedValueOnce(snapshot).mockResolvedValueOnce(latestSnapshot), + listIosExpoTokens: vi.fn(async () => [{ token: 'ExponentPushToken[aaa]', locale: null }]), + sendExpoPush: vi + .fn() + .mockRejectedValueOnce(new Error('transport down')) + .mockImplementationOnce(async messages => { + delivered.push(...messages); + }), + }); + + await expect( + deliverGlanceableSnapshot({ userId: 'u1', organizationId: null }, deps) + ).rejects.toThrow('transport down'); + expect(delivered).toHaveLength(0); + + await deliverGlanceableSnapshot({ userId: 'u1', organizationId: null }, deps); + expect(delivered.map(message => message.badge)).toEqual([4]); + expect(vi.mocked(deps.sendExpoPush).mock.calls[1][0][0].badge).toBe(4); + }); + it('sends update only to the activity tokens when both kinds are registered', async () => { const iosTokens: IosActivityToken[] = [ { token: 'ptt-token', kind: 'ios_push_to_start' }, diff --git a/services/notifications/src/lib/glanceable-delivery.ts b/services/notifications/src/lib/glanceable-delivery.ts index 13dc8c97bc..c0791b41ce 100644 --- a/services/notifications/src/lib/glanceable-delivery.ts +++ b/services/notifications/src/lib/glanceable-delivery.ts @@ -79,6 +79,7 @@ export function buildGlanceableExpoMessages( ({ to: token, data: snapshot, + badge: snapshot.needsInput, // Data-only wake: `_contentAvailable` makes the OS deliver the message to // the background task while the app is backgrounded/killed, and omitting // title/body keeps it from becoming a visible FCM notification that skips From a651519f0a7ea94c71e6ada0f6a6d4f472b4b6ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 4 Sep 2026 11:07:10 +0200 Subject: [PATCH 02/20] Sync the app badge from glanceable snapshots (kwf deliver-the-work-described-b-fbf8/s2) --- .../kilo-chat/hooks/mark-read-operation.ts | 27 +--- .../kilo-chat/hooks/use-mark-read.ts | 9 +- .../kilo-chat/mark-read-state.test.ts | 97 +----------- apps/mobile/src/lib/badge-freshness.ts | 10 -- apps/mobile/src/lib/badge-hydration.ts | 26 ---- .../hooks/use-unread-counts-invalidation.ts | 2 - .../mobile/src/lib/hooks/use-unread-counts.ts | 10 -- apps/mobile/src/lib/notifications.test.ts | 142 +++++++++++++++++- apps/mobile/src/lib/notifications.ts | 37 ++++- 9 files changed, 186 insertions(+), 174 deletions(-) delete mode 100644 apps/mobile/src/lib/badge-freshness.ts delete mode 100644 apps/mobile/src/lib/badge-hydration.ts diff --git a/apps/mobile/src/components/kilo-chat/hooks/mark-read-operation.ts b/apps/mobile/src/components/kilo-chat/hooks/mark-read-operation.ts index 8467bd3369..d9abb6344c 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/mark-read-operation.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/mark-read-operation.ts @@ -22,16 +22,13 @@ export async function markReadConversation({ return result; } -type ApplyBadgeClearResultInput = { +type ApplyBadgeClearResultInput = { badgeClear: MarkConversationReadResponse['badgeClear']; - startBadgeFreshnessEpoch: number; - currentBadgeFreshnessEpoch: number; userId: string | null; updateBadgeRows: ( queryKey: readonly ['badges', string], updater: (badges: BadgeCountRow[] | undefined) => BadgeCountRow[] | undefined ) => void; - setBadgeCount: (badgeCount: number) => Promise; }; export function filterClearedBadgeBucket( @@ -45,26 +42,14 @@ export function filterClearedBadgeBucket( return badges?.filter(row => row.badgeBucket !== badgeClear.badgeBucket); } -export function applyBadgeClearResult({ +export function applyBadgeClearResult({ badgeClear, - startBadgeFreshnessEpoch, - currentBadgeFreshnessEpoch, userId, updateBadgeRows, - setBadgeCount, -}: ApplyBadgeClearResultInput): boolean { - if (badgeClear === null) { - return false; - } - - if (userId !== null) { - updateBadgeRows(['badges', userId], badges => filterClearedBadgeBucket(badges, badgeClear)); - } - - if (currentBadgeFreshnessEpoch !== startBadgeFreshnessEpoch) { - return false; +}: ApplyBadgeClearResultInput): void { + if (badgeClear === null || userId === null) { + return; } - void setBadgeCount(badgeClear.badgeCount); - return true; + updateBadgeRows(['badges', userId], badges => filterClearedBadgeBucket(badges, badgeClear)); } diff --git a/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts index efb7ae1e2e..7c9a482a72 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts @@ -1,7 +1,6 @@ import { useCallback } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import * as Sentry from '@sentry/react-native'; -import * as Notifications from 'expo-notifications'; import { type KiloChatClient, type MarkConversationReadResponse } from '@kilocode/kilo-chat'; import { type BadgeCountRow } from '@kilocode/notifications'; @@ -9,7 +8,6 @@ import { useMarkConversationRead } from '@kilocode/kilo-chat-hooks'; import { useCurrentUserId } from './use-current-user-id'; import { applyBadgeClearResult, markReadConversation } from './mark-read-operation'; -import { advanceBadgeFreshnessEpoch, readBadgeFreshnessEpoch } from '@/lib/badge-freshness'; type MarkReadInput = { sandboxId: string; @@ -48,18 +46,13 @@ export function useMarkRead(client: KiloChatClient) { extra: { hasUser: userId !== null }, }); }, - onMutate: () => ({ startBadgeFreshnessEpoch: advanceBadgeFreshnessEpoch() }), - onSuccess: (result, _variables, context) => { - const currentBadgeFreshnessEpoch = readBadgeFreshnessEpoch(); + onSuccess: result => { applyBadgeClearResult({ badgeClear: result.badgeClear, - startBadgeFreshnessEpoch: context.startBadgeFreshnessEpoch, - currentBadgeFreshnessEpoch, userId, updateBadgeRows: (queryKey, updater) => { queryClient.setQueryData(queryKey, updater); }, - setBadgeCount: Notifications.setBadgeCountAsync, }); }, onSettled: () => { diff --git a/apps/mobile/src/components/kilo-chat/mark-read-state.test.ts b/apps/mobile/src/components/kilo-chat/mark-read-state.test.ts index 9b2805a8da..1afce4055a 100644 --- a/apps/mobile/src/components/kilo-chat/mark-read-state.test.ts +++ b/apps/mobile/src/components/kilo-chat/mark-read-state.test.ts @@ -13,7 +13,6 @@ import { filterClearedBadgeBucket, markReadConversation, } from './hooks/mark-read-operation'; -import { reconcileHydratedBadgeCount, totalBadgeCount } from '@/lib/badge-hydration'; type UpdateBadgeRows = ( queryKey: readonly ['badges', string], @@ -105,116 +104,28 @@ describe('markReadConversation', () => { ).toEqual([{ badgeBucket: 'bucket-1', badgeCount: 2 }]); }); - it('does not update badge cache or OS badge count when badgeClear is null', () => { + it('does not update the badge cache when badgeClear is null', () => { const updateBadgeRows = createUpdateBadgeRowsMock(); - const setBadgeCount = vi.fn<(badgeCount: number) => Promise>(async () => { - const result = await Promise.resolve(true); - return result; - }); - const applied = applyBadgeClearResult({ + applyBadgeClearResult({ badgeClear: null, - startBadgeFreshnessEpoch: 0, - currentBadgeFreshnessEpoch: 0, userId: 'user-1', updateBadgeRows, - setBadgeCount, }); - expect(applied).toBe(false); expect(updateBadgeRows).not.toHaveBeenCalled(); - expect(setBadgeCount).not.toHaveBeenCalled(); }); - it('updates badge cache and OS badge count when badgeClear includes a count with unchanged freshness', () => { + it('updates the badge cache when badgeClear contains a cleared row', () => { const updateBadgeRows = createUpdateBadgeRowsMock(); - const setBadgeCount = vi.fn<(badgeCount: number) => Promise>(async () => { - const result = await Promise.resolve(true); - return result; - }); - const applied = applyBadgeClearResult({ + applyBadgeClearResult({ badgeClear: { badgeBucket: 'server-bucket', badgeCount: 3 }, - startBadgeFreshnessEpoch: 4, - currentBadgeFreshnessEpoch: 4, userId: 'user-1', updateBadgeRows, - setBadgeCount, }); - expect(applied).toBe(true); expect(updateBadgeRows).toHaveBeenCalledOnce(); expect(updateBadgeRows).toHaveBeenCalledWith(['badges', 'user-1'], expect.any(Function)); - expect(setBadgeCount).toHaveBeenCalledWith(3); - }); - - it('keeps badge cache updates but skips stale OS badge counts when freshness advanced', () => { - const updateBadgeRows = createUpdateBadgeRowsMock(); - const setBadgeCount = vi.fn<(badgeCount: number) => Promise>(async () => { - const result = await Promise.resolve(true); - return result; - }); - - const applied = applyBadgeClearResult({ - badgeClear: { badgeBucket: 'server-bucket', badgeCount: 0 }, - startBadgeFreshnessEpoch: 8, - currentBadgeFreshnessEpoch: 9, - userId: 'user-1', - updateBadgeRows, - setBadgeCount, - }); - - expect(applied).toBe(false); - expect(updateBadgeRows).toHaveBeenCalledOnce(); - expect(updateBadgeRows).toHaveBeenCalledWith(['badges', 'user-1'], expect.any(Function)); - expect(setBadgeCount).not.toHaveBeenCalled(); - }); -}); - -describe('badge hydration reconciliation', () => { - it('totals all hydrated badge buckets for the native OS badge', () => { - expect( - totalBadgeCount([ - { badgeBucket: 'kiloclaw:sandbox-1', badgeCount: 2 }, - { badgeBucket: 'kiloclaw:sandbox-1:conversation-1', badgeCount: 3 }, - ]) - ).toBe(5); - }); - - it('updates the native OS badge when hydration is still fresh', () => { - const setBadgeCount = vi.fn<(badgeCount: number) => Promise>(async () => { - const result = await Promise.resolve(true); - return result; - }); - - const applied = reconcileHydratedBadgeCount({ - badgeRows: [ - { badgeBucket: 'kiloclaw:sandbox-1', badgeCount: 2 }, - { badgeBucket: 'kiloclaw:sandbox-1:conversation-1', badgeCount: 3 }, - ], - startBadgeFreshnessEpoch: 10, - currentBadgeFreshnessEpoch: 10, - setBadgeCount, - }); - - expect(applied).toBe(true); - expect(setBadgeCount).toHaveBeenCalledWith(5); - }); - - it('does not overwrite a newer native OS badge update from stale hydration', () => { - const setBadgeCount = vi.fn<(badgeCount: number) => Promise>(async () => { - const result = await Promise.resolve(true); - return result; - }); - - const applied = reconcileHydratedBadgeCount({ - badgeRows: [{ badgeBucket: 'kiloclaw:sandbox-1', badgeCount: 4 }], - startBadgeFreshnessEpoch: 10, - currentBadgeFreshnessEpoch: 11, - setBadgeCount, - }); - - expect(applied).toBe(false); - expect(setBadgeCount).not.toHaveBeenCalled(); }); }); diff --git a/apps/mobile/src/lib/badge-freshness.ts b/apps/mobile/src/lib/badge-freshness.ts deleted file mode 100644 index 66327bd29b..0000000000 --- a/apps/mobile/src/lib/badge-freshness.ts +++ /dev/null @@ -1,10 +0,0 @@ -let badgeFreshnessEpoch = 0; - -export function readBadgeFreshnessEpoch(): number { - return badgeFreshnessEpoch; -} - -export function advanceBadgeFreshnessEpoch(): number { - badgeFreshnessEpoch += 1; - return badgeFreshnessEpoch; -} diff --git a/apps/mobile/src/lib/badge-hydration.ts b/apps/mobile/src/lib/badge-hydration.ts deleted file mode 100644 index 74cc9db6d5..0000000000 --- a/apps/mobile/src/lib/badge-hydration.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { type BadgeCountRow } from '@kilocode/notifications'; - -type ReconcileHydratedBadgeCountInput = { - badgeRows: BadgeCountRow[]; - startBadgeFreshnessEpoch: number; - currentBadgeFreshnessEpoch: number; - setBadgeCount: (badgeCount: number) => Promise; -}; - -export function totalBadgeCount(badgeRows: BadgeCountRow[]): number { - return badgeRows.reduce((total, row) => total + row.badgeCount, 0); -} - -export function reconcileHydratedBadgeCount({ - badgeRows, - startBadgeFreshnessEpoch, - currentBadgeFreshnessEpoch, - setBadgeCount, -}: ReconcileHydratedBadgeCountInput): boolean { - if (currentBadgeFreshnessEpoch !== startBadgeFreshnessEpoch) { - return false; - } - - void setBadgeCount(totalBadgeCount(badgeRows)); - return true; -} diff --git a/apps/mobile/src/lib/hooks/use-unread-counts-invalidation.ts b/apps/mobile/src/lib/hooks/use-unread-counts-invalidation.ts index 6570edcbb7..449bd86c32 100644 --- a/apps/mobile/src/lib/hooks/use-unread-counts-invalidation.ts +++ b/apps/mobile/src/lib/hooks/use-unread-counts-invalidation.ts @@ -4,7 +4,6 @@ import { useEffect } from 'react'; import { AppState } from 'react-native'; import { useCurrentUserId } from '@/components/kilo-chat/hooks/use-current-user-id'; -import { advanceBadgeFreshnessEpoch } from '@/lib/badge-freshness'; import { parseNotificationData } from '@/lib/notifications'; /** @@ -30,7 +29,6 @@ export function useUnreadCountsInvalidation() { } const invalidate = () => { - advanceBadgeFreshnessEpoch(); void queryClient.invalidateQueries({ queryKey: ['badges', userId], }); diff --git a/apps/mobile/src/lib/hooks/use-unread-counts.ts b/apps/mobile/src/lib/hooks/use-unread-counts.ts index 0a3fc7b092..40cbe83571 100644 --- a/apps/mobile/src/lib/hooks/use-unread-counts.ts +++ b/apps/mobile/src/lib/hooks/use-unread-counts.ts @@ -1,5 +1,4 @@ import { useQuery } from '@tanstack/react-query'; -import * as Notifications from 'expo-notifications'; import { useMemo } from 'react'; import { @@ -10,8 +9,6 @@ import { import { useCurrentUserId } from '@/components/kilo-chat/hooks/use-current-user-id'; import { useKiloChatTokenGetter } from '@/components/kilo-chat/hooks/use-kilo-chat-token'; -import { readBadgeFreshnessEpoch } from '@/lib/badge-freshness'; -import { reconcileHydratedBadgeCount } from '@/lib/badge-hydration'; import { NOTIFICATIONS_URL } from '@/lib/config'; /** @@ -34,7 +31,6 @@ export function useUnreadCounts() { enabled: userId !== null, staleTime: 30_000, queryFn: async () => { - const startBadgeFreshnessEpoch = readBadgeFreshnessEpoch(); const token = await getToken(); const response = await fetch(`${NOTIFICATIONS_URL}/v1/badges`, { headers: { Authorization: `Bearer ${token}` }, @@ -43,12 +39,6 @@ export function useUnreadCounts() { throw new Error(`Failed to fetch badges: ${response.status}`); } const body = listBadgesResponseSchema.parse(await response.json()); - reconcileHydratedBadgeCount({ - badgeRows: body.buckets, - startBadgeFreshnessEpoch, - currentBadgeFreshnessEpoch: readBadgeFreshnessEpoch(), - setBadgeCount: Notifications.setBadgeCountAsync, - }); return body.buckets; }, }); diff --git a/apps/mobile/src/lib/notifications.test.ts b/apps/mobile/src/lib/notifications.test.ts index ed2c83d4ba..3cbb19a143 100644 --- a/apps/mobile/src/lib/notifications.test.ts +++ b/apps/mobile/src/lib/notifications.test.ts @@ -34,7 +34,9 @@ type ResponseListener = (response: Response) => void; const mocks = vi.hoisted(() => ({ platform: { OS: 'android' as string }, + setBadgeCountAsync: vi.fn(), setNotificationChannelAsync: vi.fn(), + setNotificationHandler: vi.fn(), getPermissionsAsync: vi.fn(), requestPermissionsAsync: vi.fn(), getExpoPushTokenAsync: vi.fn(), @@ -59,11 +61,12 @@ vi.mock('react-native', () => ({ })); vi.mock('expo-notifications', () => ({ + setBadgeCountAsync: mocks.setBadgeCountAsync, setNotificationChannelAsync: mocks.setNotificationChannelAsync, getPermissionsAsync: mocks.getPermissionsAsync, requestPermissionsAsync: mocks.requestPermissionsAsync, getExpoPushTokenAsync: mocks.getExpoPushTokenAsync, - setNotificationHandler: vi.fn(), + setNotificationHandler: mocks.setNotificationHandler, addNotificationResponseReceivedListener: (listener: ResponseListener) => { mocks.listeners.add(listener); return { remove: () => mocks.listeners.delete(listener) }; @@ -167,7 +170,12 @@ async function loadNotifications() { deleteItemAsync: vi.fn().mockResolvedValue(undefined), getItemAsync: vi.fn().mockResolvedValue(null), }); - return { ...(await import('./notifications')), pending }; + const [notifications, registry, persist] = await Promise.all([ + import('./notifications'), + import('@/lib/glanceable/sink-registry'), + import('@/lib/glanceable/persist'), + ]); + return { ...notifications, pending, persist, registry }; } function deferred(): { promise: Promise; resolve: () => void } { @@ -192,6 +200,7 @@ async function flushMicrotasks(): Promise { beforeEach(() => { vi.clearAllMocks(); mocks.platform.OS = 'android'; + mocks.setBadgeCountAsync.mockResolvedValue(true); mocks.setNotificationChannelAsync.mockResolvedValue(undefined); mocks.getPermissionsAsync.mockResolvedValue({ status: 'denied' }); mocks.requestPermissionsAsync.mockResolvedValue({ status: 'denied' }); @@ -513,6 +522,133 @@ const secureStoreMock = { }), }; +describe('glanceable app badge sink', () => { + async function loadBadgeSink() { + const loaded = await loadNotifications(); + loaded._setGlanceableSinksLoaderForTests(() => undefined); + loaded.setupNotificationBackgroundHandler(); + const [sink] = loaded.registry.getGlanceableSinks(); + if (!sink) { + throw new Error('The app badge sink was not registered'); + } + return { loaded, sink }; + } + + it('sets the needs-input count for a local happy snapshot', async () => { + const { sink } = await loadBadgeSink(); + + sink.publish(glanceableSnapshot({ needsInput: 3 })); + await flushMicrotasks(); + + expect(mocks.setBadgeCountAsync).toHaveBeenCalledWith(3); + }); + + it('keeps the stale count until a successful retry publishes a replacement', async () => { + const { sink } = await loadBadgeSink(); + + sink.publish(glanceableSnapshot({ needsInput: 3 })); + sink.publish(glanceableSnapshot({ status: 'stale', needsInput: 3 })); + sink.publish(glanceableSnapshot({ revision: 2, needsInput: 1 })); + await flushMicrotasks(); + + expect(mocks.setBadgeCountAsync.mock.calls).toEqual([[3], [3], [1]]); + }); + + it.each([ + ['busy-only', { status: 'happy', running: 2, needsInput: 0 }], + ['empty', { status: 'empty', running: 0, needsInput: 0, needsInputSince: null }], + ['waiting', { status: 'waiting', running: 0, needsInput: 0, needsInputSince: null }], + ['signed-out', { status: 'signed_out', running: 0, needsInput: 0, needsInputSince: null }], + ['privacy', { status: 'privacy', running: 0, needsInput: 0, needsInputSince: null }], + ] as const)('clears the badge for a %s snapshot', async (_label, overrides) => { + const { sink } = await loadBadgeSink(); + + sink.publish(glanceableSnapshot(overrides)); + await flushMicrotasks(); + + expect(mocks.setBadgeCountAsync).toHaveBeenCalledWith(0); + }); + + it('serializes native writes so the newest count finishes last', async () => { + const gate = deferred(); + mocks.setBadgeCountAsync + .mockImplementationOnce(async () => { + await gate.promise; + return true; + }) + .mockResolvedValue(true); + const { sink } = await loadBadgeSink(); + + sink.publish(glanceableSnapshot({ needsInput: 2 })); + sink.publish(glanceableSnapshot({ revision: 2, needsInput: 7 })); + expect(mocks.setBadgeCountAsync.mock.calls).toEqual([[2]]); + + gate.resolve(); + await flushMicrotasks(); + expect(mocks.setBadgeCountAsync.mock.calls).toEqual([[2], [7]]); + }); + + it('captures a failed write and continues with the next count', async () => { + const error = new Error('badge write failed'); + mocks.setBadgeCountAsync.mockRejectedValueOnce(error).mockResolvedValue(true); + const { sink } = await loadBadgeSink(); + + sink.publish(glanceableSnapshot({ needsInput: 2 })); + sink.publish(glanceableSnapshot({ revision: 2, needsInput: 5 })); + await flushMicrotasks(); + + expect(mocks.setBadgeCountAsync.mock.calls).toEqual([[2], [5]]); + expect(mocks.captureException).toHaveBeenCalledWith(error, { + tags: { + 'error.subsystem': 'notifications', + 'error.operation': 'set_glanceable_badge', + }, + }); + }); + + it('owns foreground glanceable counts and disables ordinary push badges', async () => { + const { loaded } = await loadBadgeSink(); + loaded.persist._setLastGlanceableSnapshotForTests(glanceableSnapshot()); + mockSecureStoreKeys(); + loaded.setupNotificationHandler(); + const registration = mocks.setNotificationHandler.mock.calls[0]?.[0] as { + handleNotification: (notification: { + request: { content: { data: unknown } }; + }) => Promise<{ shouldSetBadge: boolean }>; + }; + + const ordinary = await registration.handleNotification({ + request: { + content: { + data: { + type: 'chat.message', + sandboxId: 'sandbox-1', + conversationId: 'conversation-1', + messageId: 'message-1', + }, + }, + }, + }); + expect(ordinary.shouldSetBadge).toBe(false); + expect(mocks.setBadgeCountAsync).not.toHaveBeenCalled(); + + const glanceable = await registration.handleNotification({ + request: { + content: { + data: activeGlanceablePush({ + updatedAt: '2026-01-02T00:00:00.000Z', + needsInput: 4, + }), + }, + }, + }); + await flushMicrotasks(); + + expect(glanceable.shouldSetBadge).toBe(false); + expect(mocks.setBadgeCountAsync).toHaveBeenCalledWith(4); + }); +}); + describe('applyGlanceablePushData', () => { beforeEach(() => { _resetGlanceablePersistForTests(); @@ -947,6 +1083,7 @@ describe('setupNotificationBackgroundHandler', () => { scopeKey: SCOPE_KEY, updatedAt: '2026-01-02T00:00:00.000Z', organizationBound: true, + needsInput: 6, }) ), }, @@ -965,6 +1102,7 @@ describe('setupNotificationBackgroundHandler', () => { userId: 'u1', organizationId: 'org-9', }); + expect(mocks.setBadgeCountAsync).toHaveBeenCalledWith(6); unregisterGlanceableSink(sink); }); diff --git a/apps/mobile/src/lib/notifications.ts b/apps/mobile/src/lib/notifications.ts index 8abc51d8b6..3d04ae0aed 100644 --- a/apps/mobile/src/lib/notifications.ts +++ b/apps/mobile/src/lib/notifications.ts @@ -33,7 +33,12 @@ import { persistGlanceableSink, restorePersistedGlanceable, } from '@/lib/glanceable/persist'; -import { getGlanceableSinks, registerGlanceableSink } from '@/lib/glanceable/sink-registry'; +import { + getGlanceableSinks, + type GlanceableSink, + registerGlanceableSink, +} from '@/lib/glanceable/sink-registry'; +import { chainSave } from '@/lib/hooks/save-chain'; import { ACTIVE_USER_ID_KEY, ORGANIZATION_STORAGE_KEY } from '@/lib/storage-keys'; import { i18n } from '@/i18n'; import { setPendingDeepLink } from './deep-link-launch'; @@ -56,6 +61,33 @@ function getProjectId(): string { // is registered once and must always read the latest value without stale closures. let activeChatLocation: { sandboxId: string; conversationId: string } | null = null; +async function setAppBadge(count: number): Promise { + try { + await chainSave('glanceable-app-badge', async () => { + await Notifications.setBadgeCountAsync(count); + }); + } catch (error) { + Sentry.captureException(error, { + tags: { + 'error.subsystem': 'notifications', + 'error.operation': 'set_glanceable_badge', + }, + }); + } +} + +const appBadgeSink: GlanceableSink = { + publish(snapshot) { + void setAppBadge(snapshot.needsInput); + }, + endImmediate() { + // A terminal snapshot already published zero. + }, + startOrUpdate() { + // The publish operation owns every badge write. + }, +}; + export function setActiveChatLocation( location: { sandboxId: string; conversationId: string } | null ) { @@ -216,7 +248,7 @@ async function getActiveUserId(): Promise { const shown = { shouldPlaySound: true, - shouldSetBadge: true, + shouldSetBadge: false, shouldShowBanner: true, shouldShowList: true, } satisfies Notifications.NotificationBehavior; @@ -274,6 +306,7 @@ export function _setGlanceableSinksLoaderForTests(loader: (() => void) | null): * sinks must be registered here before `applyGlanceablePushData` runs. */ function ensureGlanceableSinksLoaded(): void { + registerGlanceableSink(appBadgeSink); if (glanceableSinksLoaderForTests) { glanceableSinksLoaderForTests(); return; From edf03a87c8728f7f091b323e996bb69076ec764f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 4 Sep 2026 11:22:53 +0200 Subject: [PATCH 03/20] UX: A delayed older push can replace the current badge and false (kwf deliver-the-work-described-b-fbf8/ux2) --- services/notifications/src/lib/glanceable-delivery.test.ts | 3 ++- services/notifications/src/lib/glanceable-delivery.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/services/notifications/src/lib/glanceable-delivery.test.ts b/services/notifications/src/lib/glanceable-delivery.test.ts index ba417bff48..36f728eaef 100644 --- a/services/notifications/src/lib/glanceable-delivery.test.ts +++ b/services/notifications/src/lib/glanceable-delivery.test.ts @@ -1662,7 +1662,7 @@ describe('toGlanceableContentState', () => { describe('buildGlanceableExpoMessages', () => { it.each([0, 1, 3])( - 'emits badge %i in one data-only, tag-collapsed message per Expo token', + 'emits badge %i in one data-only, collapsed message per Expo token', needsInput => { const messages = buildGlanceableExpoMessages( [ @@ -1683,6 +1683,7 @@ describe('buildGlanceableExpoMessages', () => { expect(message.priority).toBe('default'); expect(message.channelId).toBe('active-agents'); expect(message.tag).toBe('deadbeef'); + expect(message.collapseId).toBe('deadbeef'); } expect(messages.map(m => m.to)).toEqual(['ExponentPushToken[aaa]', 'ExponentPushToken[bbb]']); } diff --git a/services/notifications/src/lib/glanceable-delivery.ts b/services/notifications/src/lib/glanceable-delivery.ts index c0791b41ce..40da18e38b 100644 --- a/services/notifications/src/lib/glanceable-delivery.ts +++ b/services/notifications/src/lib/glanceable-delivery.ts @@ -92,6 +92,7 @@ export function buildGlanceableExpoMessages( // Android collapse key = the opaque scope key, so every aggregate update // for one user+org collapses into the same ongoing notification. tag: snapshot.scopeKey, + collapseId: snapshot.scopeKey, }) satisfies ExpoPushMessage ); } From 5a02ba3985255cd06b3c085bec36eb3bc9ca4565 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 4 Sep 2026 15:53:24 +0200 Subject: [PATCH 04/20] fix: fix the failed device scenarios (kwf deliver-the-work-described-b-fbf8/vr4) --- .../src/lib/hooks/use-current-user-id.test.ts | 54 +++++++++++++++++++ .../src/lib/hooks/use-current-user-id.ts | 4 +- 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/src/lib/hooks/use-current-user-id.test.ts diff --git a/apps/mobile/src/lib/hooks/use-current-user-id.test.ts b/apps/mobile/src/lib/hooks/use-current-user-id.test.ts new file mode 100644 index 0000000000..b828fae1d7 --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-current-user-id.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useCurrentUserId } from './use-current-user-id'; + +const query = vi.hoisted(() => ({ + data: undefined as { id: string; email: string } | undefined, + isLoading: false, + isError: false, + isFetched: false, + refetch: vi.fn(), +})); + +vi.mock('@tanstack/react-query', () => ({ useQuery: () => query })); +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ user: { getMe: { queryOptions: () => ({}) } } }), +})); + +describe('useCurrentUserId', () => { + beforeEach(() => { + Object.assign(query, { + data: undefined, + isLoading: false, + isError: false, + isFetched: false, + }); + }); + + it('keeps the error state visible while a failed request retries', () => { + Object.assign(query, { isLoading: true, isFetched: true }); + + expect(useCurrentUserId().isError).toBe(true); + }); + + it('does not turn the initial loading state into an error', () => { + Object.assign(query, { isLoading: true }); + + expect(useCurrentUserId().isError).toBe(false); + }); + + it('keeps a settled request error visible', () => { + Object.assign(query, { isError: true, isFetched: true }); + + expect(useCurrentUserId().isError).toBe(true); + }); + + it('clears the error state after a successful retry', () => { + Object.assign(query, { + data: { id: 'user-1', email: 'user@example.com' }, + isFetched: true, + }); + + expect(useCurrentUserId().isError).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/hooks/use-current-user-id.ts b/apps/mobile/src/lib/hooks/use-current-user-id.ts index 70bcac3341..8e8472704a 100644 --- a/apps/mobile/src/lib/hooks/use-current-user-id.ts +++ b/apps/mobile/src/lib/hooks/use-current-user-id.ts @@ -8,7 +8,7 @@ type UseCurrentUserIdOptions = { export function useCurrentUserId(options: UseCurrentUserIdOptions = {}) { const trpc = useTRPC(); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, isFetched, refetch } = useQuery({ ...trpc.user.getMe.queryOptions(), enabled: options.enabled ?? true, }); @@ -17,7 +17,7 @@ export function useCurrentUserId(options: UseCurrentUserIdOptions = {}) { userId: data?.id, email: data?.email, isLoading, - isError, + isError: isError || (isLoading && isFetched), refetch: () => { void refetch(); }, From 56cd55e9791e8901fd57f0eb73e7d40db1fa7e06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 4 Sep 2026 21:39:09 +0200 Subject: [PATCH 05/20] fix: fix the failed device scenarios (kwf deliver-the-work-described-b-fbf8/vr5) --- apps/mobile/src/lib/notifications.test.ts | 56 +++++++++++++++++++++++ apps/mobile/src/lib/notifications.ts | 9 +++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/lib/notifications.test.ts b/apps/mobile/src/lib/notifications.test.ts index 3cbb19a143..4e24d2d2ce 100644 --- a/apps/mobile/src/lib/notifications.test.ts +++ b/apps/mobile/src/lib/notifications.test.ts @@ -1107,6 +1107,62 @@ describe('setupNotificationBackgroundHandler', () => { unregisterGlanceableSink(sink); }); + it('keeps the background task alive until a zero-count badge write finishes', async () => { + const persisted = glanceableSnapshot({ + scopeKey: SCOPE_KEY, + revision: 1, + updatedAt: '2026-01-01T00:00:00.000Z', + needsInput: 1, + }); + secureStore.set('glanceable-snapshot', JSON.stringify(persisted)); + secureStore.set('glanceable-scope-key', SCOPE_KEY); + _setGlanceableSinksLoaderForTests(() => undefined); + const badgeWrite = deferred(); + mocks.setBadgeCountAsync.mockImplementation(async () => { + await badgeWrite.promise; + return true; + }); + + setupNotificationBackgroundHandler(); + const executor = executorFor(mocks.defineTask); + let completed = false; + const apply = async () => { + const result = await executor({ + data: { + notification: null, + data: { + dataString: JSON.stringify( + activeGlanceablePush({ + scopeKey: SCOPE_KEY, + updatedAt: '2026-01-02T00:00:00.000Z', + status: 'empty', + running: 0, + needsInput: 0, + idle: 0, + needsInputSince: null, + }) + ), + }, + }, + error: null, + executionInfo: { + eventId: 'e-clear', + taskName: 'active-agents-glanceable-background-task', + }, + }); + completed = true; + return result; + }; + const applying = apply(); + await flushMicrotasks(); + + expect(mocks.setBadgeCountAsync).toHaveBeenCalledWith(0); + expect(completed).toBe(false); + + badgeWrite.resolve(); + expect(await applying).toBe(0); + }); + it('ignores a headless payload that is not a glanceable push', async () => { _setGlanceableSinksLoaderForTests(() => undefined); diff --git a/apps/mobile/src/lib/notifications.ts b/apps/mobile/src/lib/notifications.ts index 3d04ae0aed..82e45da16d 100644 --- a/apps/mobile/src/lib/notifications.ts +++ b/apps/mobile/src/lib/notifications.ts @@ -76,9 +76,16 @@ async function setAppBadge(count: number): Promise { } } +let appBadgeWrite: Promise | null = null; + const appBadgeSink: GlanceableSink = { publish(snapshot) { - void setAppBadge(snapshot.needsInput); + appBadgeWrite = setAppBadge(snapshot.needsInput); + }, + async waitForNativeTerminal() { + if (appBadgeWrite) { + await appBadgeWrite; + } }, endImmediate() { // A terminal snapshot already published zero. From 290f4019fff1356aa7e7c973b18bcf00c46d7730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 4 Sep 2026 22:14:02 +0200 Subject: [PATCH 06/20] fix: fix the failed device scenarios (kwf deliver-the-work-described-b-fbf8/vr6) --- apps/mobile/src/lib/notifications.test.ts | 11 ++++++++++- apps/mobile/src/lib/notifications.ts | 3 +++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/lib/notifications.test.ts b/apps/mobile/src/lib/notifications.test.ts index 4e24d2d2ce..b4c07b042a 100644 --- a/apps/mobile/src/lib/notifications.test.ts +++ b/apps/mobile/src/lib/notifications.test.ts @@ -632,7 +632,12 @@ describe('glanceable app badge sink', () => { expect(ordinary.shouldSetBadge).toBe(false); expect(mocks.setBadgeCountAsync).not.toHaveBeenCalled(); - const glanceable = await registration.handleNotification({ + const badgeWrite = deferred(); + mocks.setBadgeCountAsync.mockImplementation(async () => { + await badgeWrite.promise; + return true; + }); + const handling = registration.handleNotification({ request: { content: { data: activeGlanceablePush({ @@ -643,9 +648,13 @@ describe('glanceable app badge sink', () => { }, }); await flushMicrotasks(); + const completedBeforeWrite = await Promise.race([handling, Promise.resolve(null)]); + badgeWrite.resolve(); + const glanceable = await handling; expect(glanceable.shouldSetBadge).toBe(false); expect(mocks.setBadgeCountAsync).toHaveBeenCalledWith(4); + expect(completedBeforeWrite).toBeNull(); }); }); diff --git a/apps/mobile/src/lib/notifications.ts b/apps/mobile/src/lib/notifications.ts index 82e45da16d..67a25bab14 100644 --- a/apps/mobile/src/lib/notifications.ts +++ b/apps/mobile/src/lib/notifications.ts @@ -218,6 +218,9 @@ export async function applyGlanceablePushData( // Keep the existing fallback for other sinks; widgets retain their timeline. scheduleGlanceableTerminalEnd(); } + if (appBadgeWrite) { + await appBadgeWrite; + } // Do not finish a background task before ActivityKit accepts the native end. // All publication happens before this await, so it cannot restore an old scope. if (!eligible) { From 91251d2e4376fe1cfb283d6889b47355b08a0f37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 4 Sep 2026 22:41:04 +0200 Subject: [PATCH 07/20] fix: fix the failed device scenarios (kwf deliver-the-work-described-b-fbf8/vr7) --- apps/mobile/src/lib/notifications.test.ts | 22 +++++++++++++++++++--- apps/mobile/src/lib/notifications.ts | 9 +++++++-- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/lib/notifications.test.ts b/apps/mobile/src/lib/notifications.test.ts index b4c07b042a..c186ca9952 100644 --- a/apps/mobile/src/lib/notifications.test.ts +++ b/apps/mobile/src/lib/notifications.test.ts @@ -606,9 +606,9 @@ describe('glanceable app badge sink', () => { }); }); - it('owns foreground glanceable counts and disables ordinary push badges', async () => { + it('applies foreground glanceable counts and disables ordinary push badges', async () => { const { loaded } = await loadBadgeSink(); - loaded.persist._setLastGlanceableSnapshotForTests(glanceableSnapshot()); + loaded.persist._setLastGlanceableSnapshotForTests(glanceableSnapshot({ needsInput: 2 })); mockSecureStoreKeys(); loaded.setupNotificationHandler(); const registration = mocks.setNotificationHandler.mock.calls[0]?.[0] as { @@ -632,6 +632,22 @@ describe('glanceable app badge sink', () => { expect(ordinary.shouldSetBadge).toBe(false); expect(mocks.setBadgeCountAsync).not.toHaveBeenCalled(); + const stale = await registration.handleNotification({ + request: { + content: { + data: activeGlanceablePush({ + updatedAt: '2025-12-31T00:00:00.000Z', + needsInput: 9, + }), + }, + }, + }); + expect(stale.shouldSetBadge).toBe(false); + expect(mocks.setBadgeCountAsync).not.toHaveBeenCalled(); + await flushMicrotasks(); + expect(mocks.setBadgeCountAsync).toHaveBeenCalledWith(2); + mocks.setBadgeCountAsync.mockClear(); + const badgeWrite = deferred(); mocks.setBadgeCountAsync.mockImplementation(async () => { await badgeWrite.promise; @@ -652,7 +668,7 @@ describe('glanceable app badge sink', () => { badgeWrite.resolve(); const glanceable = await handling; - expect(glanceable.shouldSetBadge).toBe(false); + expect(glanceable.shouldSetBadge).toBe(true); expect(mocks.setBadgeCountAsync).toHaveBeenCalledWith(4); expect(completedBeforeWrite).toBeNull(); }); diff --git a/apps/mobile/src/lib/notifications.ts b/apps/mobile/src/lib/notifications.ts index 67a25bab14..a10c27ee30 100644 --- a/apps/mobile/src/lib/notifications.ts +++ b/apps/mobile/src/lib/notifications.ts @@ -279,8 +279,13 @@ export function setupNotificationHandler() { // The aggregate glanceable push is a data carrier for the ongoing // notification/widgets, never a visible banner: the local ongoing owns // the display. Apply it to the sinks regardless of the discard outcome. - await applyGlanceablePushData(data); - return suppressed; + const applied = await applyGlanceablePushData(data); + if (!applied) { + setTimeout(() => { + appBadgeWrite = setAppBadge(getLastGlanceableSnapshot()?.needsInput ?? 0); + }, 0); + } + return { ...suppressed, shouldSetBadge: applied }; } if ( From 201fbaf79c700331c8992c9d5ae69449e1ef4819 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 4 Sep 2026 23:04:55 +0200 Subject: [PATCH 08/20] fix: fix the failed device scenarios (kwf deliver-the-work-described-b-fbf8/vr8) --- apps/mobile/src/lib/notifications.test.ts | 4 ++-- apps/mobile/src/lib/notifications.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/lib/notifications.test.ts b/apps/mobile/src/lib/notifications.test.ts index c186ca9952..e6843f20ff 100644 --- a/apps/mobile/src/lib/notifications.test.ts +++ b/apps/mobile/src/lib/notifications.test.ts @@ -606,7 +606,7 @@ describe('glanceable app badge sink', () => { }); }); - it('applies foreground glanceable counts and disables ordinary push badges', async () => { + it('applies foreground glanceable counts and allows visible push badges', async () => { const { loaded } = await loadBadgeSink(); loaded.persist._setLastGlanceableSnapshotForTests(glanceableSnapshot({ needsInput: 2 })); mockSecureStoreKeys(); @@ -629,7 +629,7 @@ describe('glanceable app badge sink', () => { }, }, }); - expect(ordinary.shouldSetBadge).toBe(false); + expect(ordinary.shouldSetBadge).toBe(true); expect(mocks.setBadgeCountAsync).not.toHaveBeenCalled(); const stale = await registration.handleNotification({ diff --git a/apps/mobile/src/lib/notifications.ts b/apps/mobile/src/lib/notifications.ts index a10c27ee30..3f3f61daf1 100644 --- a/apps/mobile/src/lib/notifications.ts +++ b/apps/mobile/src/lib/notifications.ts @@ -258,7 +258,7 @@ async function getActiveUserId(): Promise { const shown = { shouldPlaySound: true, - shouldSetBadge: false, + shouldSetBadge: true, shouldShowBanner: true, shouldShowList: true, } satisfies Notifications.NotificationBehavior; From 77500c2180e37a28a5bc713e9badd64e76aee3c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 4 Sep 2026 23:56:49 +0200 Subject: [PATCH 09/20] fix: fix the failed device scenarios (kwf deliver-the-work-described-b-fbf8/vr9) --- apps/mobile/src/lib/glanceable/publisher.test.ts | 15 +++++---------- apps/mobile/src/lib/glanceable/publisher.ts | 4 ++++ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/lib/glanceable/publisher.test.ts b/apps/mobile/src/lib/glanceable/publisher.test.ts index 0845573591..6dd08bfe97 100644 --- a/apps/mobile/src/lib/glanceable/publisher.test.ts +++ b/apps/mobile/src/lib/glanceable/publisher.test.ts @@ -90,16 +90,7 @@ describe('GlanceablePublisher', () => { expect(snapshot.status).toBe('happy'); }); - it('starts the activity immediately on the first eligible emit', () => { - vi.useFakeTimers(); - const { sink, calls } = makeSink(); - const publisher = new GlanceablePublisher({ sinks: [sink], now: () => NOW }); - publisher.handleSessions([{ status: 'busy' }], PUB_CTX); - expect(count(calls, 'startOrUpdate')).toBe(1); - publisher.dispose(); - }); - - it('coalesces later happy updates and emits only the latest', () => { + it('coalesces later happy updates but publishes needs-input changes immediately', () => { vi.useFakeTimers(); const { sink, calls } = makeSink(); const publisher = new GlanceablePublisher({ sinks: [sink], now: () => NOW, coalesceMs: 1000 }); @@ -110,6 +101,10 @@ describe('GlanceablePublisher', () => { vi.advanceTimersByTime(1000); expect(count(calls, 'startOrUpdate')).toBe(2); expect(lastSnapshot(calls, 'startOrUpdate').running).toBe(3); + publisher.handleSessions([{ status: 'permission' }], PUB_CTX); + expect(lastSnapshot(calls, 'startOrUpdate').needsInput).toBe(1); + publisher.handleSessions([{ status: 'busy' }], PUB_CTX); + expect(lastSnapshot(calls, 'startOrUpdate').needsInput).toBe(0); publisher.dispose(); }); diff --git a/apps/mobile/src/lib/glanceable/publisher.ts b/apps/mobile/src/lib/glanceable/publisher.ts index 189a65f287..9bae4a8188 100644 --- a/apps/mobile/src/lib/glanceable/publisher.ts +++ b/apps/mobile/src/lib/glanceable/publisher.ts @@ -132,6 +132,10 @@ export class GlanceablePublisher { // First eligible emit starts the activity immediately, no coalesce wait. this.emit(snapshot, ctx); this.activityStarted = true; + } else if (snapshot.needsInput !== this.current?.needsInput) { + // Badge changes are actionable and must reach the launcher immediately. + this.cancelCoalesce(); + this.emit(snapshot, ctx); } else { this.scheduleCoalesced(snapshot, ctx); } From 935d5370c6729fa4e1cb1c1ff088dadb5db9c935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 5 Sep 2026 01:35:37 +0200 Subject: [PATCH 10/20] chore: deliver-the-work-described-b-fbf8 pre-verify snapshot --- .kwf-keep-stack | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .kwf-keep-stack diff --git a/.kwf-keep-stack b/.kwf-keep-stack new file mode 100644 index 0000000000..e69de29bb2 From 60841b8ce2b1fe01e0c552eae0fec2db89738557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 5 Sep 2026 02:21:46 +0200 Subject: [PATCH 11/20] fix: fix the failed device scenarios (kwf deliver-the-work-described-b-fbf8/vr10) --- .../src/lib/glanceable-delivery.test.ts | 51 +++++++++++++++---- .../src/lib/glanceable-delivery.ts | 38 ++++++++++++-- 2 files changed, 74 insertions(+), 15 deletions(-) diff --git a/services/notifications/src/lib/glanceable-delivery.test.ts b/services/notifications/src/lib/glanceable-delivery.test.ts index 36f728eaef..b560ef6d6e 100644 --- a/services/notifications/src/lib/glanceable-delivery.test.ts +++ b/services/notifications/src/lib/glanceable-delivery.test.ts @@ -202,7 +202,7 @@ describe('NotificationsService.refreshGlanceableSessions', () => { }); vi.mocked(getWorkerDb).mockReturnValue(db as never); vi.mocked(sendPushNotifications).mockImplementation(async incoming => { - messages.push(...incoming); + messages.push(...incoming.filter(message => message.data !== undefined)); return { ticketTokenPairs: [], staleTokens: [], ticketErrors: [] }; }); vi.stubGlobal('fetch', async (url: string, init: RequestInit) => { @@ -1425,7 +1425,10 @@ describe('NotificationsService.refreshGlanceableSessions', () => { ); expect( messages.every( - message => message._contentAvailable && message.sound === null && !message.body + message => + message._contentAvailable && + (message.sound === null || message.sound === undefined) && + !message.body ) ).toBe(true); }); @@ -1661,15 +1664,43 @@ describe('toGlanceableContentState', () => { }); describe('buildGlanceableExpoMessages', () => { + it.each([0, 1, 2])('separates iOS badge %i from the background wake', needsInput => { + const nextSnapshot = { ...snapshot, needsInput }; + const messages = buildGlanceableExpoMessages( + [{ token: 'ExponentPushToken[aaa]', locale: null }], + nextSnapshot, + 'ios' + ); + + expect(messages).toEqual([ + expect.objectContaining({ + to: 'ExponentPushToken[aaa]', + badge: needsInput, + priority: 'high', + }), + expect.objectContaining({ + to: 'ExponentPushToken[aaa]', + data: nextSnapshot, + _contentAvailable: true, + priority: 'normal', + }), + ]); + expect(messages[0]).not.toHaveProperty('data'); + expect(messages[0]).not.toHaveProperty('_contentAvailable'); + expect(messages[1]).not.toHaveProperty('badge'); + expect(messages[0].collapseId).not.toBe(messages[1].collapseId); + }); + it.each([0, 1, 3])( - 'emits badge %i in one data-only, collapsed message per Expo token', + 'emits badge %i in one data-only, collapsed Android message per Expo token', needsInput => { const messages = buildGlanceableExpoMessages( [ { token: 'ExponentPushToken[aaa]', locale: null }, { token: 'ExponentPushToken[bbb]', locale: 'es' }, ], - { ...snapshot, needsInput } + { ...snapshot, needsInput }, + 'android' ); expect(messages).toHaveLength(2); @@ -1723,7 +1754,7 @@ describe('deliverGlanceableSnapshot', () => { expect(delivered).toHaveLength(0); await deliverGlanceableSnapshot({ userId: 'u1', organizationId: null }, deps); - expect(delivered.map(message => message.badge)).toEqual([4]); + expect(delivered.map(message => message.badge)).toEqual([4, undefined]); expect(vi.mocked(deps.sendExpoPush).mock.calls[1][0][0].badge).toBe(4); }); @@ -1869,7 +1900,7 @@ describe('deliverGlanceableSnapshot', () => { }); }); - it('sends the data-only iOS Expo push regardless of the android_ongoing token', async () => { + it('sends separate iOS badge and background pushes regardless of the Android token', async () => { const { deps, calls } = fakeDeps({ hasAndroidOngoingToken: vi.fn(async () => false), listIosExpoTokens: vi.fn(async () => [{ token: 'ExponentPushToken[ios]', locale: null }]), @@ -1879,10 +1910,10 @@ describe('deliverGlanceableSnapshot', () => { expect(deps.listIosExpoTokens).toHaveBeenCalledWith('u1', null); expect(calls.expoSends).toHaveLength(1); - expect(calls.expoSends[0]).toHaveLength(1); + expect(calls.expoSends[0]).toHaveLength(2); expect(calls.expoSends[0][0].to).toBe('ExponentPushToken[ios]'); - expect(calls.expoSends[0][0]._contentAvailable).toBe(true); - expect(calls.expoSends[0][0].title).toBeUndefined(); - expect(calls.expoSends[0][0].body).toBeUndefined(); + expect(calls.expoSends[0][0].badge).toBe(1); + expect(calls.expoSends[0][1]._contentAvailable).toBe(true); + expect(calls.expoSends[0][1].data).toEqual(snapshot); }); }); diff --git a/services/notifications/src/lib/glanceable-delivery.ts b/services/notifications/src/lib/glanceable-delivery.ts index 40da18e38b..caf56e09c0 100644 --- a/services/notifications/src/lib/glanceable-delivery.ts +++ b/services/notifications/src/lib/glanceable-delivery.ts @@ -72,8 +72,31 @@ export function toGlanceableContentState( export function buildGlanceableExpoMessages( tokens: readonly ExpoPushToken[], - snapshot: ActiveAgentsGlanceable + snapshot: ActiveAgentsGlanceable, + platform: 'ios' | 'android' ): ExpoPushMessage[] { + if (platform === 'ios') { + // APNs badge updates are user interactions, so keep them out of the background push. + return tokens.flatMap( + ({ token }) => + [ + { + to: token, + badge: snapshot.needsInput, + priority: 'high', + collapseId: `${snapshot.scopeKey}:badge`, + }, + { + to: token, + data: snapshot, + _contentAvailable: true, + priority: 'normal', + collapseId: `${snapshot.scopeKey}:data`, + }, + ] satisfies ExpoPushMessage[] + ); + } + return tokens.map( ({ token }) => ({ @@ -179,18 +202,23 @@ export async function deliverGlanceableSnapshot( ); } - // iOS Expo tokens always need the data-only wake: it drives the widget - // timeline through the background task while the app is not foregrounded. + // iOS Expo tokens need a badge update plus a data-only wake for the widget timeline. if (deps.isCurrent && !(await deps.isCurrent())) return; if (iosExpoTokens.length > 0) { - await deps.sendExpoPush(buildGlanceableExpoMessages(iosExpoTokens, snapshot), deps.isCurrent); + await deps.sendExpoPush( + buildGlanceableExpoMessages(iosExpoTokens, snapshot, 'ios'), + deps.isCurrent + ); } if (await deps.hasAndroidOngoingToken(params.userId, params.organizationId)) { const expoTokens = await deps.listAndroidExpoTokens(params.userId, params.organizationId); if (deps.isCurrent && !(await deps.isCurrent())) return; if (expoTokens.length > 0) { - await deps.sendExpoPush(buildGlanceableExpoMessages(expoTokens, snapshot), deps.isCurrent); + await deps.sendExpoPush( + buildGlanceableExpoMessages(expoTokens, snapshot, 'android'), + deps.isCurrent + ); } } } From 53f1bae78befd6c1fa2a29425bac3451dcf132cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 5 Sep 2026 03:07:21 +0200 Subject: [PATCH 12/20] fix: fix the failed device scenarios (kwf deliver-the-work-described-b-fbf8/vr11) --- .../src/lib/active-sessions-live-sync.ts | 2 + apps/mobile/src/lib/notifications.test.ts | 5 ++ apps/mobile/src/lib/notifications.ts | 4 ++ .../src/lib/glanceable-delivery.test.ts | 25 ++++------ .../src/lib/glanceable-delivery.ts | 48 +++++-------------- 5 files changed, 33 insertions(+), 51 deletions(-) diff --git a/apps/mobile/src/lib/active-sessions-live-sync.ts b/apps/mobile/src/lib/active-sessions-live-sync.ts index 79c86453f9..52e29c67f4 100644 --- a/apps/mobile/src/lib/active-sessions-live-sync.ts +++ b/apps/mobile/src/lib/active-sessions-live-sync.ts @@ -333,6 +333,8 @@ export class ActiveSessionsLiveSync { /** One app-level mount owns the registry and socket lease. */ let attachedSync: ActiveSessionsLiveSync | null = null; +export const refreshActiveSessionsFromPush = (): void => attachedSync?.scheduleRefresh('manual'); + /** Returns false if no current owner handles this exact key. */ export async function refreshActiveSessionsNow( queryKey: QueryKey diff --git a/apps/mobile/src/lib/notifications.test.ts b/apps/mobile/src/lib/notifications.test.ts index e6843f20ff..3fb41385fd 100644 --- a/apps/mobile/src/lib/notifications.test.ts +++ b/apps/mobile/src/lib/notifications.test.ts @@ -51,6 +51,7 @@ const mocks = vi.hoisted(() => ({ startTokenListeners: new Set<(event: { activityPushToStartToken: string }) => void>(), registerActivityToken: vi.fn(), unregisterActivityToken: vi.fn(), + refreshActiveSessionsFromPush: vi.fn(), defineTask: vi.fn(), registerTaskAsync: vi.fn(), captureEvent: vi.fn(), @@ -142,6 +143,9 @@ vi.mock('@/lib/trpc', () => ({ }, })); vi.mock('@/lib/query-client', () => ({ queryClient: {} })); +vi.mock('@/lib/active-sessions-live-sync', () => ({ + refreshActiveSessionsFromPush: mocks.refreshActiveSessionsFromPush, +})); vi.mock('@/lib/persist/read-cache', () => ({ readCachedUserId: () => null })); vi.mock('@kilocode/notifications', async importOriginal => ({ @@ -670,6 +674,7 @@ describe('glanceable app badge sink', () => { expect(glanceable.shouldSetBadge).toBe(true); expect(mocks.setBadgeCountAsync).toHaveBeenCalledWith(4); + expect(mocks.refreshActiveSessionsFromPush).toHaveBeenCalledOnce(); expect(completedBeforeWrite).toBeNull(); }); }); diff --git a/apps/mobile/src/lib/notifications.ts b/apps/mobile/src/lib/notifications.ts index 3f3f61daf1..5994f3acf2 100644 --- a/apps/mobile/src/lib/notifications.ts +++ b/apps/mobile/src/lib/notifications.ts @@ -25,6 +25,7 @@ import { } from '@kilocode/app-shared/glanceable-agents-snapshot'; import { captureEvent } from '@/lib/analytics/posthog'; +import { refreshActiveSessionsFromPush } from '@/lib/active-sessions-live-sync'; import { currentAuthEpoch } from '@/lib/auth/auth-epoch'; import { getTerminalBlankEpoch } from '@/lib/glanceable/cleanup'; import { @@ -280,6 +281,9 @@ export function setupNotificationHandler() { // notification/widgets, never a visible banner: the local ongoing owns // the display. Apply it to the sinks regardless of the discard outcome. const applied = await applyGlanceablePushData(data); + if (applied) { + refreshActiveSessionsFromPush(); + } if (!applied) { setTimeout(() => { appBadgeWrite = setAppBadge(getLastGlanceableSnapshot()?.needsInput ?? 0); diff --git a/services/notifications/src/lib/glanceable-delivery.test.ts b/services/notifications/src/lib/glanceable-delivery.test.ts index b560ef6d6e..e0d4d71261 100644 --- a/services/notifications/src/lib/glanceable-delivery.test.ts +++ b/services/notifications/src/lib/glanceable-delivery.test.ts @@ -202,7 +202,7 @@ describe('NotificationsService.refreshGlanceableSessions', () => { }); vi.mocked(getWorkerDb).mockReturnValue(db as never); vi.mocked(sendPushNotifications).mockImplementation(async incoming => { - messages.push(...incoming.filter(message => message.data !== undefined)); + messages.push(...incoming); return { ticketTokenPairs: [], staleTokens: [], ticketErrors: [] }; }); vi.stubGlobal('fetch', async (url: string, init: RequestInit) => { @@ -1664,7 +1664,7 @@ describe('toGlanceableContentState', () => { }); describe('buildGlanceableExpoMessages', () => { - it.each([0, 1, 2])('separates iOS badge %i from the background wake', needsInput => { + it.each([0, 1, 2])('keeps iOS badge %i with its background snapshot', needsInput => { const nextSnapshot = { ...snapshot, needsInput }; const messages = buildGlanceableExpoMessages( [{ token: 'ExponentPushToken[aaa]', locale: null }], @@ -1673,22 +1673,15 @@ describe('buildGlanceableExpoMessages', () => { ); expect(messages).toEqual([ - expect.objectContaining({ - to: 'ExponentPushToken[aaa]', - badge: needsInput, - priority: 'high', - }), expect.objectContaining({ to: 'ExponentPushToken[aaa]', data: nextSnapshot, + badge: needsInput, _contentAvailable: true, priority: 'normal', }), ]); - expect(messages[0]).not.toHaveProperty('data'); - expect(messages[0]).not.toHaveProperty('_contentAvailable'); - expect(messages[1]).not.toHaveProperty('badge'); - expect(messages[0].collapseId).not.toBe(messages[1].collapseId); + expect(messages[0].collapseId).toBe(nextSnapshot.scopeKey); }); it.each([0, 1, 3])( @@ -1754,7 +1747,7 @@ describe('deliverGlanceableSnapshot', () => { expect(delivered).toHaveLength(0); await deliverGlanceableSnapshot({ userId: 'u1', organizationId: null }, deps); - expect(delivered.map(message => message.badge)).toEqual([4, undefined]); + expect(delivered.map(message => message.badge)).toEqual([4]); expect(vi.mocked(deps.sendExpoPush).mock.calls[1][0][0].badge).toBe(4); }); @@ -1900,7 +1893,7 @@ describe('deliverGlanceableSnapshot', () => { }); }); - it('sends separate iOS badge and background pushes regardless of the Android token', async () => { + it('sends one iOS badge and background push regardless of the Android token', async () => { const { deps, calls } = fakeDeps({ hasAndroidOngoingToken: vi.fn(async () => false), listIosExpoTokens: vi.fn(async () => [{ token: 'ExponentPushToken[ios]', locale: null }]), @@ -1910,10 +1903,10 @@ describe('deliverGlanceableSnapshot', () => { expect(deps.listIosExpoTokens).toHaveBeenCalledWith('u1', null); expect(calls.expoSends).toHaveLength(1); - expect(calls.expoSends[0]).toHaveLength(2); + expect(calls.expoSends[0]).toHaveLength(1); expect(calls.expoSends[0][0].to).toBe('ExponentPushToken[ios]'); expect(calls.expoSends[0][0].badge).toBe(1); - expect(calls.expoSends[0][1]._contentAvailable).toBe(true); - expect(calls.expoSends[0][1].data).toEqual(snapshot); + expect(calls.expoSends[0][0]._contentAvailable).toBe(true); + expect(calls.expoSends[0][0].data).toEqual(snapshot); }); }); diff --git a/services/notifications/src/lib/glanceable-delivery.ts b/services/notifications/src/lib/glanceable-delivery.ts index caf56e09c0..e85f0be7e2 100644 --- a/services/notifications/src/lib/glanceable-delivery.ts +++ b/services/notifications/src/lib/glanceable-delivery.ts @@ -75,46 +75,24 @@ export function buildGlanceableExpoMessages( snapshot: ActiveAgentsGlanceable, platform: 'ios' | 'android' ): ExpoPushMessage[] { - if (platform === 'ios') { - // APNs badge updates are user interactions, so keep them out of the background push. - return tokens.flatMap( - ({ token }) => - [ - { - to: token, - badge: snapshot.needsInput, - priority: 'high', - collapseId: `${snapshot.scopeKey}:badge`, - }, - { - to: token, - data: snapshot, - _contentAvailable: true, - priority: 'normal', - collapseId: `${snapshot.scopeKey}:data`, - }, - ] satisfies ExpoPushMessage[] - ); - } - return tokens.map( ({ token }) => ({ to: token, data: snapshot, badge: snapshot.needsInput, - // Data-only wake: `_contentAvailable` makes the OS deliver the message to - // the background task while the app is backgrounded/killed, and omitting - // title/body keeps it from becoming a visible FCM notification that skips - // the task. The ongoing notification and widget content come from the local - // `applyGlanceablePushData` path, so the push never rings or interrupts. + // `_contentAvailable` wakes the background task. The badge stays on this + // same ordered snapshot, while no title, body, or sound interrupts the user. _contentAvailable: true, - sound: null, - priority: 'default', - channelId: 'active-agents', - // Android collapse key = the opaque scope key, so every aggregate update - // for one user+org collapses into the same ongoing notification. - tag: snapshot.scopeKey, + ...(platform === 'ios' + ? { priority: 'normal' as const } + : { + sound: null, + priority: 'default' as const, + channelId: 'active-agents', + tag: snapshot.scopeKey, + }), + // One opaque scope key keeps each platform's badge and snapshot ordered together. collapseId: snapshot.scopeKey, }) satisfies ExpoPushMessage ); @@ -174,7 +152,7 @@ export async function deliverGlanceableSnapshot( // Read the iOS Expo rows first: they carry the only per-user locale on this // path, and APNs requires a localized alert on a push-to-start. The - // data-only wake below reuses the same rows, so this costs no extra query. + // background update below reuses the same rows, so this costs no extra query. const iosExpoTokens = await deps.listIosExpoTokens(params.userId, params.organizationId); const locale = iosExpoTokens.find(row => row.locale !== null)?.locale ?? null; @@ -202,7 +180,7 @@ export async function deliverGlanceableSnapshot( ); } - // iOS Expo tokens need a badge update plus a data-only wake for the widget timeline. + // The iOS badge and data share one update so neither can arrive or collapse alone. if (deps.isCurrent && !(await deps.isCurrent())) return; if (iosExpoTokens.length > 0) { await deps.sendExpoPush( From c175a89f4cf21466ea2640f28be6f0f35635267f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 5 Sep 2026 04:05:20 +0200 Subject: [PATCH 13/20] fix: fix the failed device scenarios (kwf deliver-the-work-described-b-fbf8/vr12) --- services/notifications/src/lib/glanceable-delivery.test.ts | 4 ++-- services/notifications/src/lib/glanceable-delivery.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/services/notifications/src/lib/glanceable-delivery.test.ts b/services/notifications/src/lib/glanceable-delivery.test.ts index e0d4d71261..b89cb76eb1 100644 --- a/services/notifications/src/lib/glanceable-delivery.test.ts +++ b/services/notifications/src/lib/glanceable-delivery.test.ts @@ -1664,7 +1664,7 @@ describe('toGlanceableContentState', () => { }); describe('buildGlanceableExpoMessages', () => { - it.each([0, 1, 2])('keeps iOS badge %i with its background snapshot', needsInput => { + it.each([0, 1, 2])('sends iOS badge %i without a low-priority delay', needsInput => { const nextSnapshot = { ...snapshot, needsInput }; const messages = buildGlanceableExpoMessages( [{ token: 'ExponentPushToken[aaa]', locale: null }], @@ -1678,7 +1678,7 @@ describe('buildGlanceableExpoMessages', () => { data: nextSnapshot, badge: needsInput, _contentAvailable: true, - priority: 'normal', + priority: 'high', }), ]); expect(messages[0].collapseId).toBe(nextSnapshot.scopeKey); diff --git a/services/notifications/src/lib/glanceable-delivery.ts b/services/notifications/src/lib/glanceable-delivery.ts index e85f0be7e2..327a56b2b0 100644 --- a/services/notifications/src/lib/glanceable-delivery.ts +++ b/services/notifications/src/lib/glanceable-delivery.ts @@ -85,7 +85,7 @@ export function buildGlanceableExpoMessages( // same ordered snapshot, while no title, body, or sound interrupts the user. _contentAvailable: true, ...(platform === 'ios' - ? { priority: 'normal' as const } + ? { priority: 'high' as const } : { sound: null, priority: 'default' as const, From c2ffa8cd3c43b3670b0e5bf7e5498b523356373c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 5 Sep 2026 13:05:37 +0200 Subject: [PATCH 14/20] feat(mobile): sync launcher badge with glanceable needs-input count --- .kwf-keep-stack | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .kwf-keep-stack diff --git a/.kwf-keep-stack b/.kwf-keep-stack deleted file mode 100644 index e69de29bb2..0000000000 From 7114868aae37796d00fa927e7af989b3398b05e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 5 Sep 2026 20:28:01 +0200 Subject: [PATCH 15/20] fix: fix the failed device scenarios (kwf deliver-the-work-described-b-fbf8/vr15) --- apps/mobile/src/lib/notifications.test.ts | 93 +++++++++++++++++++++++ apps/mobile/src/lib/notifications.ts | 27 ++++++- 2 files changed, 117 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/lib/notifications.test.ts b/apps/mobile/src/lib/notifications.test.ts index 3fb41385fd..3493334f44 100644 --- a/apps/mobile/src/lib/notifications.test.ts +++ b/apps/mobile/src/lib/notifications.test.ts @@ -558,6 +558,29 @@ describe('glanceable app badge sink', () => { expect(mocks.setBadgeCountAsync.mock.calls).toEqual([[3], [3], [1]]); }); + it('leaves a push-set launcher badge alone across re-publications of the same count', async () => { + const { sink } = await loadBadgeSink(); + + // e7 baseline: a needs-input session published count 1 to the launcher. + sink.publish(glanceableSnapshot({ needsInput: 1 })); + await flushMicrotasks(); + expect(mocks.setBadgeCountAsync.mock.calls).toEqual([[1]]); + + // The OS then moves the launcher badge itself: a visible foreground push + // with badge 2 is applied through the shouldSetBadge: true path. Every + // tray refresh re-publishes the unchanged snapshot afterwards; none of + // those re-publications may undo the badge the push carried. + sink.publish(glanceableSnapshot({ revision: 2, needsInput: 1 })); + sink.publish(glanceableSnapshot({ revision: 3, status: 'stale', needsInput: 1 })); + await flushMicrotasks(); + expect(mocks.setBadgeCountAsync.mock.calls).toEqual([[1]]); + + // A real count change still reaches the launcher. + sink.publish(glanceableSnapshot({ revision: 4, needsInput: 2 })); + await flushMicrotasks(); + expect(mocks.setBadgeCountAsync.mock.calls).toEqual([[1], [2]]); + }); + it.each([ ['busy-only', { status: 'happy', running: 2, needsInput: 0 }], ['empty', { status: 'empty', running: 0, needsInput: 0, needsInputSince: null }], @@ -677,6 +700,76 @@ describe('glanceable app badge sink', () => { expect(mocks.refreshActiveSessionsFromPush).toHaveBeenCalledOnce(); expect(completedBeforeWrite).toBeNull(); }); + + it('does not re-assert the local count when a later glanceable push is discarded', async () => { + const { loaded } = await loadBadgeSink(); + loaded.persist._setLastGlanceableSnapshotForTests(glanceableSnapshot({ needsInput: 2 })); + mockSecureStoreKeys(); + loaded.setupNotificationHandler(); + const registration = mocks.setNotificationHandler.mock.calls[0]?.[0] as { + handleNotification: (notification: { + request: { content: { data: unknown } }; + }) => Promise<{ shouldSetBadge: boolean }>; + }; + + // A fresh glanceable push confirms count 2 on the launcher through the sink. + await registration.handleNotification({ + request: { + content: { + data: activeGlanceablePush({ + updatedAt: '2026-01-02T00:00:00.000Z', + needsInput: 2, + }), + }, + }, + }); + await flushMicrotasks(); + expect(mocks.setBadgeCountAsync.mock.calls).toEqual([[2]]); + + // A visible push then moves the launcher itself (badge 3, shouldSetBadge). + // A stale glanceable push is discarded: it carries no new count, so the + // fallback must NOT re-write the unchanged local truth — that would undo + // the badge the visible push carried (the e7 clobber). + await registration.handleNotification({ + request: { + content: { + data: { + type: 'chat.message', + sandboxId: 'sandbox-1', + conversationId: 'conversation-1', + messageId: 'message-1', + }, + }, + }, + }); + const stale = await registration.handleNotification({ + request: { + content: { + data: activeGlanceablePush({ + updatedAt: '2025-12-31T00:00:00.000Z', + needsInput: 9, + }), + }, + }, + }); + expect(stale.shouldSetBadge).toBe(false); + await flushMicrotasks(); + expect(mocks.setBadgeCountAsync.mock.calls).toEqual([[2]]); + + // A real count change still reaches the launcher after the discard. + await registration.handleNotification({ + request: { + content: { + data: activeGlanceablePush({ + updatedAt: '2026-01-03T00:00:00.000Z', + needsInput: 3, + }), + }, + }, + }); + await flushMicrotasks(); + expect(mocks.setBadgeCountAsync.mock.calls).toEqual([[2], [3]]); + }); }); describe('applyGlanceablePushData', () => { diff --git a/apps/mobile/src/lib/notifications.ts b/apps/mobile/src/lib/notifications.ts index 5994f3acf2..4f44bb8c98 100644 --- a/apps/mobile/src/lib/notifications.ts +++ b/apps/mobile/src/lib/notifications.ts @@ -62,11 +62,19 @@ function getProjectId(): string { // is registered once and must always read the latest value without stale closures. let activeChatLocation: { sandboxId: string; conversationId: string } | null = null; +let appBadgeWrite: Promise | null = null; + +// The count the last successful native write put on the launcher badge. The +// badge is shared with iOS itself: a visible push that carries its own badge +// moves the icon through the shouldSetBadge path without the app writing. +let badgeWrittenCount: number | null = null; + async function setAppBadge(count: number): Promise { try { await chainSave('glanceable-app-badge', async () => { await Notifications.setBadgeCountAsync(count); }); + badgeWrittenCount = count; } catch (error) { Sentry.captureException(error, { tags: { @@ -77,11 +85,20 @@ async function setAppBadge(count: number): Promise { } } -let appBadgeWrite: Promise | null = null; +function syncAppBadge(count: number): void { + // Only a change of the needs-input count owns a badge write. Re-asserting + // an unchanged count would undo a badge iOS applied from a visible push + // payload (e7: foreground push badge 2 was clobbered back to needsInput 1 + // before the app was terminated). + if (count === badgeWrittenCount) { + return; + } + appBadgeWrite = setAppBadge(count); +} const appBadgeSink: GlanceableSink = { publish(snapshot) { - appBadgeWrite = setAppBadge(snapshot.needsInput); + syncAppBadge(snapshot.needsInput); }, async waitForNativeTerminal() { if (appBadgeWrite) { @@ -286,7 +303,11 @@ export function setupNotificationHandler() { } if (!applied) { setTimeout(() => { - appBadgeWrite = setAppBadge(getLastGlanceableSnapshot()?.needsInput ?? 0); + // A discarded push carries no new count, so the local truth is + // unchanged. Re-asserting it here would undo a badge iOS applied + // from a later visible push (the same e7 clobber as an unchanged + // re-publication), so it goes through the guarded write too. + syncAppBadge(getLastGlanceableSnapshot()?.needsInput ?? 0); }, 0); } return { ...suppressed, shouldSetBadge: applied }; From 802eafc222b79edd064b5a6bd56dfd6c92b38fe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 6 Sep 2026 00:31:21 +0200 Subject: [PATCH 16/20] fix: fix the failed device scenarios (escalation: kilo/x-ai/grok-4.6) (kwf deliver-the-work-described-b-fbf8/vr16) --- .../active-agents-live-activity.test.ts | 203 ++++++++++++++++++ .../active-agents-live-activity.tsx | 24 ++- .../src/glanceable-ios/ios-sink.test.ts | 46 ++++ apps/mobile/src/glanceable-ios/view-props.ts | 6 +- .../mobile/src/lib/glanceable/presentation.ts | 5 +- .../src/lib/glanceable/publisher.test.ts | 29 ++- .../src/glanceable-agents-snapshot.test.ts | 38 ++++ .../src/glanceable-agents-snapshot.ts | 15 +- .../src/lib/glanceable-delivery.test.ts | 64 ++++-- .../src/lib/glanceable-delivery.ts | 10 +- .../src/lib/glanceable-refresh.ts | 2 +- 11 files changed, 403 insertions(+), 39 deletions(-) create mode 100644 apps/mobile/src/glanceable-ios/active-agents-live-activity.test.ts diff --git a/apps/mobile/src/glanceable-ios/active-agents-live-activity.test.ts b/apps/mobile/src/glanceable-ios/active-agents-live-activity.test.ts new file mode 100644 index 0000000000..da478d5bac --- /dev/null +++ b/apps/mobile/src/glanceable-ios/active-agents-live-activity.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { Text } from '@expo/ui/swift-ui'; +import { type GlanceableLiveActivityContentState } from '@kilocode/notifications'; + +import { glanceableLayoutCopy } from './layout-copy'; +// Constructing the exported handle registers the layout, which the mocked +// `createLiveActivity` below captures. vitest hoists the mocks above imports. +import './active-agents-live-activity'; + +// The `'widget'` layout is stringified by Babel and re-evaluated inside the +// widget process, where the copy and logo placeholders are already replaced. +// No widget transform runs under vitest, so the module holds the real function +// and the placeholders stay literals. This suite renders that exact function: +// the swift-ui tree and react-native are stubbed, `createLiveActivity` captures +// the layout it was registered with, and `JSON.parse` is stubbed to answer the +// copy placeholder the way the baked source literal does on device. +vi.mock('@expo/ui/swift-ui', () => ({ + Text: () => null, + VStack: () => null, + HStack: () => null, + Spacer: () => null, + Image: () => null, +})); +vi.mock('@expo/ui/swift-ui/modifiers', () => ({ + accessibilityElement: (...args: unknown[]) => ({ mod: 'accessibilityElement', args }), + accessibilityLabel: (...args: unknown[]) => ({ mod: 'accessibilityLabel', args }), + allowsTightening: (...args: unknown[]) => ({ mod: 'allowsTightening', args }), + cornerRadius: (...args: unknown[]) => ({ mod: 'cornerRadius', args }), + environment: (...args: unknown[]) => ({ mod: 'environment', args }), + font: (...args: unknown[]) => ({ mod: 'font', args }), + foregroundStyle: (...args: unknown[]) => ({ mod: 'foregroundStyle', args }), + frame: (...args: unknown[]) => ({ mod: 'frame', args }), + layoutPriority: (...args: unknown[]) => ({ mod: 'layoutPriority', args }), + lineLimit: (...args: unknown[]) => ({ mod: 'lineLimit', args }), + minimumScaleFactor: (...args: unknown[]) => ({ mod: 'minimumScaleFactor', args }), + monospacedDigit: (...args: unknown[]) => ({ mod: 'monospacedDigit', args }), + padding: (...args: unknown[]) => ({ mod: 'padding', args }), + resizable: (...args: unknown[]) => ({ mod: 'resizable', args }), +})); +vi.mock('react-native', () => ({ + PlatformColor: (name: string) => name, + Image: { resolveAssetSource: () => ({ uri: '' }) }, +})); + +const captured = vi.hoisted(() => ({ layout: null as unknown })); +vi.mock('expo-widgets', () => ({ + after: (date: Date) => ({ after: date }), + widgetsDirectory: 'file:///app-group/ExpoWidgets/', + createLiveActivity: (_name: string, layout: unknown) => { + captured.layout = layout; + return { start: () => null, getInstances: () => [] }; + }, +})); + +type Rendered = Record; + +/** + * Render the registered layout for one pushed content state, baking the copy + * the same way `withGlanceableCopy` patches the stringified source on device. + */ +function render(content: Partial): Rendered { + const copy = JSON.stringify(glanceableLayoutCopy()); + const realParse = JSON.parse; + vi.stubGlobal('JSON', { + parse: (text: string) => + text === '__KILO_GLANCEABLE_COPY__' ? realParse(copy) : realParse(text), + }); + try { + const layout = captured.layout as ( + props: Partial + ) => Rendered; + return layout(content); + } finally { + vi.unstubAllGlobals(); + } +} + +/** Every string a `Text` node draws anywhere in the surface tree. */ +function drawnText(node: unknown, out: string[] = []): string[] { + if (Array.isArray(node)) { + for (const item of node) { + drawnText(item, out); + } + return out; + } + if (node === null || typeof node !== 'object') { + return out; + } + const element = node as { type?: unknown; props?: { children?: unknown } }; + if (element.type === Text && typeof element.props?.children === 'string') { + out.push(element.props.children); + } + for (const value of Object.values(node as Record)) { + drawnText(value, out); + } + return out; +} + +/** The spoken label the surface attaches to its accessibility element. */ +function spokenLabel(node: unknown): string { + let label = ''; + const visit = (value: unknown): void => { + if (Array.isArray(value)) { + for (const item of value) { + visit(item); + } + return; + } + if (value === null || typeof value !== 'object') { + return; + } + const marker = value as { mod?: string; args?: unknown[] }; + if (marker.mod === 'accessibilityLabel' && typeof marker.args?.[0] === 'string') { + label = marker.args[0]; + } + for (const child of Object.values(value as Record)) { + visit(child); + } + }; + visit(node); + return label; +} + +describe('ActiveAgentsLiveActivity layout', () => { + it('clears the island number when an idle-only snapshot resolves to empty', () => { + // e23: the question was answered elsewhere and the session landed in + // `idle`. The terminal content state keeps the idle count, but the island + // must not show "1" beside a cleared badge — every surface draws the + // empty status line instead, exactly like the widget props do. + const rendered = render({ + status: 'empty', + running: 0, + needsInput: 0, + idle: 1, + needsInputSince: null, + }); + + expect(drawnText(rendered.compactTrailing).join('')).toBe(''); + expect(drawnText(rendered.minimal).join('')).toBe(''); + expect(drawnText(rendered.banner)).toEqual(['No work in progress']); + expect(drawnText(rendered.expandedBottom)).toEqual(['No work in progress']); + expect(spokenLabel(rendered.banner)).toBe('No work in progress, Open agents'); + }); + + it('shows the ranked number on the island while work is eligible', () => { + // The idle row keeps drawing beside real work, and the compact island + // shows the top-ranked non-zero count — needs-input outranks idle. + const rendered = render({ + status: 'happy', + running: 0, + needsInput: 1, + idle: 1, + needsInputSince: null, + }); + + expect(drawnText(rendered.compactTrailing)).toEqual(['1']); + const banner = drawnText(rendered.banner); + expect(banner).toContain('Needs input'); + expect(banner).toContain('Idle'); + expect(spokenLabel(rendered.banner)).toBe('1 Needs input, 1 Idle, Open agents'); + }); + + it('keeps the zero rows on the banner so the grid never reflows', () => { + const rendered = render({ + status: 'happy', + running: 2, + needsInput: 0, + idle: 0, + needsInputSince: null, + }); + + expect(drawnText(rendered.compactTrailing)).toEqual(['2']); + expect(drawnText(rendered.banner)).toEqual(['0', 'Needs input', '2', 'Working', '0', 'Idle']); + expect(spokenLabel(rendered.banner)).toBe('2 Working, Open agents'); + }); + + it('draws the stale status word beside the frozen counts', () => { + const rendered = render({ + status: 'stale', + running: 1, + needsInput: 0, + idle: 0, + needsInputSince: null, + }); + + expect(drawnText(rendered.compactTrailing)).toEqual(['1']); + expect(spokenLabel(rendered.banner)).toBe("Can't update now, 1 Working, Open agents"); + }); + + it('draws the waiting line with no number', () => { + const rendered = render({ + status: 'waiting', + running: 0, + needsInput: 0, + idle: 0, + needsInputSince: null, + }); + + expect(drawnText(rendered.compactTrailing).join('')).toBe(''); + expect(drawnText(rendered.banner)).toEqual(['Updating agents']); + }); +}); diff --git a/apps/mobile/src/glanceable-ios/active-agents-live-activity.tsx b/apps/mobile/src/glanceable-ios/active-agents-live-activity.tsx index 568f46a729..a2b88de45e 100644 --- a/apps/mobile/src/glanceable-ios/active-agents-live-activity.tsx +++ b/apps/mobile/src/glanceable-ios/active-agents-live-activity.tsx @@ -72,6 +72,13 @@ const layout: LiveActivityComponent = props => { const status = props.status ?? 'empty'; const statusLine = status === 'happy' ? null : COPY[status]; + // Counts draw only while the snapshot carries work. Idle is not work: a + // session resolved elsewhere lands in `idle` and the snapshot carries the + // `empty` status, and the surface must clear with the badge and the Agents + // list instead of drawing the number the user just resolved. This mirrors + // `showCounts` in the app-side builders; the pushed content state cannot + // carry derived fields, so the layout derives them here. + const showCounts = status === 'happy' || status === 'stale'; // Rank order: what the user must act on, then what is making progress, then // what is only connected. The Dynamic Island shows one number, so this @@ -102,10 +109,11 @@ const layout: LiveActivityComponent = props => { // `as const` keeps each `icon` an SF Symbol literal, which the Image prop // type requires. ] as const; - // A zero row still draws, so the rows never reflow as work changes state. - // `primary` skips the zeros: one number on the Dynamic Island must be a - // number worth showing. - const primary = countLines.find(line => line.count > 0) ?? null; + // A zero row still draws, so the rows never reflow as work changes state — + // but only while counts draw at all. `primary` skips the zeros: one number + // on the Dynamic Island must be a number worth showing, and an idle-only + // `empty` snapshot has none. + const primary = showCounts ? (countLines.find(line => line.count > 0) ?? null) : null; const hasCounts = primary !== null; const primaryCount = count(primary === null ? 0 : primary.count); // Only the needs-input row carries a duration, and only the oldest wait: a @@ -115,10 +123,14 @@ const layout: LiveActivityComponent = props => { // Spoken label: status word, numeric counts, then Open agents. The whole // surface deep-links to the agents list, so "Open agents" stays in the - // spoken label even though no line draws it. + // spoken label even though no line draws it. Zeros and a resolved idle row + // are layout anchors, not news: the spoken label keeps only the counts that + // draw as work, the way `glanceableSpokenLabel` does in the app. const spokenParts = [ ...(statusLine !== null ? [statusLine] : []), - ...countLines.map(line => `${line.count} ${line.label}`), + ...(showCounts + ? countLines.filter(line => line.count > 0).map(line => `${line.count} ${line.label}`) + : []), COPY.openAgents, ]; const accessibility = spokenParts.join(', '); diff --git a/apps/mobile/src/glanceable-ios/ios-sink.test.ts b/apps/mobile/src/glanceable-ios/ios-sink.test.ts index d323472836..128cb56d6e 100644 --- a/apps/mobile/src/glanceable-ios/ios-sink.test.ts +++ b/apps/mobile/src/glanceable-ios/ios-sink.test.ts @@ -567,6 +567,25 @@ describe('iosSink end', () => { publisher.dispose(); }); + it('ends the activity when a needs-input session resolved to idle without being opened', async () => { + // e23: the question is answered elsewhere and the session lands in `idle`. + // The badge and the Agents list clear on that transition, so the island + // must resolve too — it must not update on to show the same count the + // user just resolved. The idle count survives in the terminal content. + vi.useFakeTimers(); + vi.setSystemTime(NOW); + iosSink.startOrUpdate(snapshotFor([{ status: 'question' }]), CTX); + iosSink.publish(snapshotFor([{ status: 'idle' }], 1)); + await iosSink.waitForNativeTerminal?.(); + + expect(mockState.updated).toHaveLength(0); + expect(mockState.started[0]).toMatchObject({ + ended: true, + dismissAt: NOW + 8000, + props: { status: 'empty', running: 0, needsInput: 0, idle: 1 }, + }); + }); + it('keeps the full native terminal window after a delayed update', async () => { vi.useFakeTimers(); vi.setSystemTime(NOW); @@ -791,6 +810,12 @@ describe('iosSink widget publish', () => { boolean, ][] = [ ['empty', [], 'No work in progress', 0, false], + // e23: the last needs-input session resolved to `idle` without being + // opened. The idle count survives on the snapshot, but an idle-only + // snapshot carries `empty` and must not draw a primary number — the + // compact surfaces would otherwise keep showing the count the user + // just resolved beside the cleared badge. + ['empty', [{ status: 'idle' }], 'No work in progress', 0, false], // Stale draws rows, and all three draw whenever rows draw, so the // surface never reflows as work moves between states. ['stale', [{ status: 'busy' }], "Can't update now", 3, true], @@ -933,6 +958,27 @@ describe('buildGlanceableViewProps', () => { ]); }); + it('drops the compact primary fields when an idle-only snapshot carries empty', () => { + // e23: idle is not work. The snapshot keeps the idle count so ranked rows + // hold their grid while real work lives, but with `empty` no surface draws + // rows — and the compact fields must agree, or accessoryCircular/inline + // keep a "1" the badge and the Agents list already cleared. + const props = buildGlanceableViewProps( + snapshotFor([{ status: 'idle' }, { status: 'idle' }], 1), + {}, + key => key + ); + expect(props.statusLine).toBe('glanceable.empty'); + expect(props.countLines).toEqual([]); + expect(props.primaryLabel).toBeNull(); + expect(props.primaryKind).toBeNull(); + expect(props.primaryCount).toBe(0); + expect(props.accessibilityLabel).toBe('glanceable.empty, glanceable.openAgents'); + + // An idle-only snapshot must not survive the UserDefaults-safe form either. + expect(toWidgetProps(props)).not.toHaveProperty('primaryKind'); + }); + it('carries no title, organization name, or raw id into the widget JSON', () => { // A waiting row with its own status timestamp, so the assertion below // covers the one field that carries a time into the widget payload. diff --git a/apps/mobile/src/glanceable-ios/view-props.ts b/apps/mobile/src/glanceable-ios/view-props.ts index aee90d52c3..9dd8d02035 100644 --- a/apps/mobile/src/glanceable-ios/view-props.ts +++ b/apps/mobile/src/glanceable-ios/view-props.ts @@ -48,11 +48,15 @@ export function buildGlanceableViewProps( translate: (key: string) => string ): GlanceableViewProps { const statusKey = glanceableStatusCopyKey(snapshot, flags); - const primary = primaryGlanceableCount(snapshot); // Only these two statuses draw rows; the rest draw their status line, so the // locked frames carry no count payload at all. const status = resolveGlanceableStatus(snapshot, flags); const showCounts = status === 'happy' || status === 'stale'; + // The compact fields follow the same gate as the rows (see Android's + // `buildAndroidWidgetProps`): an idle-only snapshot carries `empty`, and a + // circular or inline accessory that still drew its idle number would keep + // showing the count the badge and the Agents list already cleared. + const primary = showCounts ? primaryGlanceableCount(snapshot) : null; return { statusLine: statusKey === null ? null : translate(statusKey), diff --git a/apps/mobile/src/lib/glanceable/presentation.ts b/apps/mobile/src/lib/glanceable/presentation.ts index ad8b60003d..5e49fdc7ae 100644 --- a/apps/mobile/src/lib/glanceable/presentation.ts +++ b/apps/mobile/src/lib/glanceable/presentation.ts @@ -45,8 +45,9 @@ const COUNT_ORDER: readonly { key: GlanceableCountKey; kind: GlanceableCountKind * * A zero row still draws: dropping it would move every remaining row as work * changes state, and a surface the user only glances at must not reflow. The - * surfaces show these rows only while some work exists — a snapshot with three - * zeros carries the `empty` status and draws its status line instead. + * surfaces show these rows only while some work exists — a snapshot without a + * running or needs-input session (idle alone included) carries the `empty` + * status and draws its status line instead. */ export function glanceableCountLines(snapshot: GlanceableAgentsSnapshot): GlanceableCountLine[] { return COUNT_ORDER.map(({ key, kind }) => ({ key, kind, count: snapshot[kind] })); diff --git a/apps/mobile/src/lib/glanceable/publisher.test.ts b/apps/mobile/src/lib/glanceable/publisher.test.ts index 6dd08bfe97..6f22bc029e 100644 --- a/apps/mobile/src/lib/glanceable/publisher.test.ts +++ b/apps/mobile/src/lib/glanceable/publisher.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- one cohesive publisher state-machine suite sharing the fake-sink harness */ import { afterEach, describe, expect, it, vi } from 'vitest'; import { @@ -117,7 +118,7 @@ describe('GlanceablePublisher', () => { expect(count(calls, 'startOrUpdate')).toBe(started); }); - it('starts for idle-only sessions but not when no session is connected', () => { + it('never starts for idle-only sessions or for an empty fleet', () => { vi.useFakeTimers(); const { sink, calls } = makeSink(); const publisher = new GlanceablePublisher({ sinks: [sink], now: () => NOW }); @@ -126,9 +127,31 @@ describe('GlanceablePublisher', () => { expect(lastSnapshot(calls, 'publish').status).toBe('empty'); vi.advanceTimersByTime(8000); expect(count(calls, 'endImmediate')).toBe(0); - // An idle agent is still connected, so the notch shows it ranked last. + // e23: an idle agent stays connected but is not work. A fleet where every + // session went idle without being opened must not start or keep a surface: + // the island must not go on showing the count the user just resolved. publisher.handleSessions([{ status: 'idle' }, { status: 'idle' }], PUB_CTX); - expect(lastSnapshot(calls, 'startOrUpdate')).toMatchObject({ status: 'happy', idle: 2 }); + expect(count(calls, 'startOrUpdate')).toBe(0); + expect(lastSnapshot(calls, 'publish')).toMatchObject({ status: 'empty', idle: 2 }); + publisher.dispose(); + }); + + it('ends a started activity when every session went idle without being opened', () => { + vi.useFakeTimers(); + const { sink, calls } = makeSink(); + const publisher = new GlanceablePublisher({ sinks: [sink], now: () => NOW }); + publisher.handleSessions([{ status: 'question' }], PUB_CTX); + expect(lastSnapshot(calls, 'startOrUpdate')).toMatchObject({ status: 'happy', needsInput: 1 }); + // The question is answered elsewhere: the session lands in `idle`. + publisher.handleSessions([{ status: 'idle' }], PUB_CTX); + expect(lastSnapshot(calls, 'publish')).toMatchObject({ + status: 'empty', + needsInput: 0, + idle: 1, + }); + // The terminal end fires after the brief empty window, resolving the island. + vi.advanceTimersByTime(8000); + expect(count(calls, 'endImmediate')).toBe(1); publisher.dispose(); }); diff --git a/packages/app-shared/src/glanceable-agents-snapshot.test.ts b/packages/app-shared/src/glanceable-agents-snapshot.test.ts index 413bcddf13..659dd5b51d 100644 --- a/packages/app-shared/src/glanceable-agents-snapshot.test.ts +++ b/packages/app-shared/src/glanceable-agents-snapshot.test.ts @@ -210,6 +210,44 @@ describe('isEligibleGlanceableWork and revision discard', () => { expect(isEligibleGlanceableWork(busy)).toBe(true); }); + it('resolves the surfaces when every session went idle without being opened', () => { + // e23: a question session answered elsewhere lands in `idle`. The badge + // and the Agents list clear on that transition, so the Live Activity must + // resolve too: an idle-only fleet keeps no surface alive, and the island + // must not go on showing the count the user just resolved. + const question = buildGlanceableSnapshot({ + sessions: [{ status: 'question' }], + userId: 'u1', + organizationId: null, + now: NOW, + }); + const idled = buildGlanceableSnapshot({ + sessions: [{ status: 'idle' }], + userId: 'u1', + organizationId: null, + now: NOW + 60_000, + previousRevision: question.revision, + }); + expect(isEligibleGlanceableWork(question)).toBe(true); + expect(isEligibleGlanceableWork(idled)).toBe(false); + expect(idled.status).toBe('empty'); + // The idle count survives on the snapshot: ranked rows still read it + // while other work keeps a surface alive. + expect(idled.idle).toBe(1); + }); + + it('keeps a surface alive while any session works even if others idle', () => { + const mixed = buildGlanceableSnapshot({ + sessions: [{ status: 'busy' }, { status: 'idle' }, { status: 'idle' }], + userId: 'u1', + organizationId: null, + now: NOW, + }); + expect(isEligibleGlanceableWork(mixed)).toBe(true); + expect(mixed.status).toBe('happy'); + expect(mixed.idle).toBe(2); + }); + it('discards a lower revision and an older updatedAt at equal revision', () => { const current = buildGlanceableSnapshot({ sessions: [{ status: 'busy' }], diff --git a/packages/app-shared/src/glanceable-agents-snapshot.ts b/packages/app-shared/src/glanceable-agents-snapshot.ts index f115bf9463..d5eaa92c99 100644 --- a/packages/app-shared/src/glanceable-agents-snapshot.ts +++ b/packages/app-shared/src/glanceable-agents-snapshot.ts @@ -192,9 +192,11 @@ export function buildGlanceableSnapshot( input: BuildGlanceableSnapshotInput ): GlanceableAgentsSnapshot { const counts = countGlanceableSessions(input.sessions); - // Idle counts: a connected agent doing nothing is still something the user - // wants on the Lock Screen, and the Dynamic Island ranks it last. - const eligible = counts.running + counts.needsInput + counts.idle > 0; + // Idle is not work: when the last needs-input session resolves without being + // opened it lands in `idle`, and every glanceable surface must clear with the + // badge and the Agents list. Idle counts stay on the snapshot so ranked rows + // keep showing them while working or waiting sessions hold a surface alive. + const eligible = counts.running + counts.needsInput > 0; const now = input.now; const updatedAt = new Date(now).toISOString(); @@ -214,9 +216,12 @@ export function buildGlanceableSnapshot( }; } -/** True when any agent is connected, whether working, waiting, or idle. */ +/** + * True when any agent is working or waits on the user. Idle agents stay on the + * snapshot's counts but never keep a glanceable surface alive. + */ export function isEligibleGlanceableWork(snapshot: GlanceableAgentsSnapshot): boolean { - return snapshot.running + snapshot.needsInput + snapshot.idle > 0; + return snapshot.running + snapshot.needsInput > 0; } /** diff --git a/services/notifications/src/lib/glanceable-delivery.test.ts b/services/notifications/src/lib/glanceable-delivery.test.ts index b89cb76eb1..a76c4516dd 100644 --- a/services/notifications/src/lib/glanceable-delivery.test.ts +++ b/services/notifications/src/lib/glanceable-delivery.test.ts @@ -401,7 +401,7 @@ describe('NotificationsService.refreshGlanceableSessions', () => { }); await service.refreshGlanceableSessions(personalRefresh); vi.mocked(Date.now).mockReturnValue(Date.parse('2026-08-27T10:10:00.000Z')); - current = freshSnapshot({ running: 0, idle: 1 }); + current = freshSnapshot({ running: 1 }); await createService().refreshGlanceableSessions(personalRefresh); vi.mocked(Date.now).mockReturnValue(Date.parse('2026-08-27T10:20:00.000Z')); current = freshSnapshot({ running: 0, needsInput: 1 }); @@ -414,7 +414,7 @@ describe('NotificationsService.refreshGlanceableSessions', () => { { running: 2, needsInputSince: '2026-08-27T10:00:00.000Z', revision: 1 }, // The wait is read from the rows on every build, so each delivery carries // its own snapshot's value instead of one latched at the first emit. - { idle: 1, needsInputSince: '2026-08-27T10:10:00.000Z', revision: 2 }, + { running: 1, needsInputSince: '2026-08-27T10:10:00.000Z', revision: 2 }, { needsInput: 1, needsInputSince: '2026-08-27T10:20:00.000Z', revision: 3 }, ]); }); @@ -447,7 +447,7 @@ describe('NotificationsService.refreshGlanceableSessions', () => { release.resolve(); await oldIdle; vi.mocked(Date.now).mockReturnValue(Date.parse('2026-08-27T10:20:00.000Z')); - current = freshSnapshot({ running: 0, idle: 1 }); + current = freshSnapshot({ running: 1 }); await createService().refreshGlanceableSessions(personalRefresh); expect( messages @@ -457,7 +457,7 @@ describe('NotificationsService.refreshGlanceableSessions', () => { { status: 'happy', needsInputSince: '2026-08-27T10:00:00.000Z' }, { status: 'empty', needsInputSince: null }, { status: 'happy', needsInputSince: '2026-08-27T10:10:00.000Z' }, - { idle: 1, needsInputSince: '2026-08-27T10:20:00.000Z' }, + { running: 1, needsInputSince: '2026-08-27T10:20:00.000Z' }, ]); }); @@ -517,7 +517,7 @@ describe('NotificationsService.refreshGlanceableSessions', () => { await createService().refreshGlanceableSessions(personalRefresh); unavailable = false; vi.mocked(Date.now).mockReturnValue(Date.parse('2026-08-27T10:10:00.000Z')); - current = freshSnapshot({ running: 0, idle: 1 }); + current = freshSnapshot({ running: 1 }); vi.mocked(sendPushNotifications).mockRejectedValueOnce(new Error('Expo unavailable')); await createService().refreshGlanceableSessions(personalRefresh); await createService().refreshGlanceableSessions(personalRefresh); @@ -527,7 +527,7 @@ describe('NotificationsService.refreshGlanceableSessions', () => { .map(message => message.data) ).toMatchObject([ { running: 2, needsInputSince: '2026-08-27T10:00:00.000Z' }, - { idle: 1, needsInputSince: '2026-08-27T10:10:00.000Z' }, + { running: 1, needsInputSince: '2026-08-27T10:10:00.000Z' }, ]); }); @@ -538,7 +538,7 @@ describe('NotificationsService.refreshGlanceableSessions', () => { current = freshSnapshot({ status: 'stale', running: 0, needsInputSince: null }); await createService().refreshGlanceableSessions(personalRefresh); vi.mocked(Date.now).mockReturnValue(Date.parse('2026-08-27T10:10:00.000Z')); - current = freshSnapshot({ running: 0, idle: 1 }); + current = freshSnapshot({ running: 1 }); await createService().refreshGlanceableSessions(personalRefresh); expect( messages @@ -546,7 +546,7 @@ describe('NotificationsService.refreshGlanceableSessions', () => { .map(message => message.data) ).toMatchObject([ { running: 2, needsInputSince: '2026-08-27T10:00:00.000Z' }, - { idle: 1, needsInputSince: '2026-08-27T10:10:00.000Z' }, + { running: 1, needsInputSince: '2026-08-27T10:10:00.000Z' }, ]); }); @@ -588,14 +588,14 @@ describe('NotificationsService.refreshGlanceableSessions', () => { }); vi.mocked(Date.now).mockReturnValue(Date.parse('2026-08-27T10:00:01.000Z')); - current = freshSnapshot({ running: 0, idle: 1 }); + current = freshSnapshot({ running: 1 }); await createService().refreshGlanceableSessions(personalRefresh); expect(apns.map(({ token, aps }) => [token, aps.event])).toEqual([ ['old-activity', 'end'], ['scope-token', 'start'], ]); expect(JSON.parse(apns[1].aps['content-state'].props)).toMatchObject({ - idle: 1, + running: 1, needsInputSince: '2026-08-27T10:00:01.000Z', }); expect([...activityRows.keys()]).toEqual(['scope-token']); @@ -741,13 +741,13 @@ describe('NotificationsService.refreshGlanceableSessions', () => { } expect(activityRows.get('old-activity')).toEqual(renewedRow); vi.mocked(Date.now).mockReturnValue(Date.parse('2026-08-27T10:00:02.000Z')); - current = freshSnapshot({ running: 0, idle: 1 }); + current = freshSnapshot({ running: 1 }); await createService().refreshGlanceableSessions(personalRefresh); expect(liveActivityProps()).toMatchObject([ { - running: 0, + running: 1, needsInput: 0, - idle: 1, + idle: 0, // Forwarded from this refresh's snapshot, not latched at the earlier one. needsInputSince: '2026-08-27T10:00:02.000Z', }, @@ -945,10 +945,10 @@ describe('NotificationsService.refreshGlanceableSessions', () => { }); } vi.mocked(Date.now).mockReturnValue(Date.parse('2026-08-27T10:00:02.000Z')); - current = freshSnapshot({ running: 0, idle: 1 }); + current = freshSnapshot({ running: 1 }); await createService().refreshGlanceableSessions(personalRefresh); expect(liveActivityProps()).toMatchObject([ - { running: 0, needsInput: 0, idle: 1, needsInputSince: '2026-08-27T10:00:02.000Z' }, + { running: 1, needsInput: 0, idle: 0, needsInputSince: '2026-08-27T10:00:02.000Z' }, ]); expect(apns.map(({ token, aps }) => [token, aps.event])).toEqual([ ['old-activity', 'end'], @@ -1091,9 +1091,9 @@ describe('NotificationsService.refreshGlanceableSessions', () => { }); } } - current = freshSnapshot({ running: 0, idle: 1 }); + current = freshSnapshot({ running: 1 }); await createService().refreshGlanceableSessions(personalRefresh); - expect(liveActivityProps()).toMatchObject([{ running: 0, needsInput: 0, idle: 1 }]); + expect(liveActivityProps()).toMatchObject([{ running: 1, needsInput: 0, idle: 0 }]); expect(apns.map(({ token, aps }) => [token, aps.event])).toEqual([ ['old-activity', 'end'], ['old-activity', 'end'], @@ -1809,6 +1809,36 @@ describe('deliverGlanceableSnapshot', () => { expect(calls.expoSends).toHaveLength(0); }); + it('ends the activity instead of updating it when every session went idle', async () => { + // e23: a question session answered outside the app lands in `idle`. The + // badge push carries 0, so the Live Activity must resolve too — an idle + // fleet must not keep the island alive showing the count just resolved. + const idledSnapshot: ActiveAgentsGlanceable = { + ...snapshot, + status: 'empty', + running: 0, + needsInput: 0, + idle: 1, + needsInputSince: null, + }; + const { deps, calls } = fakeDeps({ + buildSnapshot: vi.fn(async () => idledSnapshot), + listIosActivityTokens: vi.fn(async () => [ + { token: 'ptt-token', kind: 'ios_push_to_start' as const, id: 'row-0', updated_at: 'x' }, + { token: 'activity-token', kind: 'ios_activity' as const, id: 'row-1', updated_at: 'x' }, + ]), + }); + + await deliverGlanceableSnapshot({ userId: 'u1', organizationId: null }, deps); + + expect(calls.iosSends).toHaveLength(1); + const [tokens] = calls.iosSends[0] as [ + { token: string; event: string }[], + GlanceableApnsContentState, + ]; + expect(tokens).toEqual([{ token: 'activity-token', event: 'end' }]); + }); + it('skips Android when no android_ongoing activity token exists', async () => { const { deps, calls } = fakeDeps({ hasAndroidOngoingToken: vi.fn(async () => false), diff --git a/services/notifications/src/lib/glanceable-delivery.ts b/services/notifications/src/lib/glanceable-delivery.ts index 327a56b2b0..356de0985f 100644 --- a/services/notifications/src/lib/glanceable-delivery.ts +++ b/services/notifications/src/lib/glanceable-delivery.ts @@ -35,9 +35,11 @@ export type IosActivityToken = { token: string; kind: 'ios_activity' | 'ios_push export type ExpoPushToken = { token: string; locale: string | null }; /** - * Update eligible activities or end zero-count activities. Never start empty work. - * A push-to-start token is used only when no activity target remains, avoiding - * duplicate activities while allowing fresh work after terminal target retirement. + * Update eligible activities or end activities whose work has resolved (no + * running or needs-input session; idle alone resolves too). Never start empty + * work. A push-to-start token is used only when no activity target remains, + * avoiding duplicate activities while allowing fresh work after terminal + * target retirement. */ export function apnsSendsForTokens( tokens: readonly IosActivityToken[], @@ -158,7 +160,7 @@ export async function deliverGlanceableSnapshot( const iosTokens = await deps.listIosActivityTokens(params.userId, params.organizationId); if (deps.isCurrent && !(await deps.isCurrent())) return; - const eligible = snapshot.running + snapshot.needsInput + snapshot.idle > 0; + const eligible = snapshot.running + snapshot.needsInput > 0; const iosSends = apnsSendsForTokens(iosTokens, eligible); if (iosSends.length > 0) { await deps.sendIosLiveActivity( diff --git a/services/notifications/src/lib/glanceable-refresh.ts b/services/notifications/src/lib/glanceable-refresh.ts index 848a90f6ad..1588cd9ba3 100644 --- a/services/notifications/src/lib/glanceable-refresh.ts +++ b/services/notifications/src/lib/glanceable-refresh.ts @@ -68,7 +68,7 @@ export async function refreshGlanceableSnapshot( }); if (committed === null) return; - const eligible = committed.running + committed.needsInput + committed.idle > 0; + const eligible = committed.running + committed.needsInput > 0; await deliverGlanceableSnapshot(scope, { ...deps, buildSnapshot: async () => committed, From 28eb2ef9400dc626640cb71e61b27aefc7f449d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 6 Sep 2026 13:08:58 +0200 Subject: [PATCH 17/20] chore: re-trigger CI after dropped synchronize event (kwf deliver-the-work-described-b-fbf8/vr16) From f08efa0d012b198ef0f0f86f2a0694f97179399f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 6 Sep 2026 13:36:33 +0200 Subject: [PATCH 18/20] chore: retry CI trigger (kwf deliver-the-work-described-b-fbf8/vr16) From c56ff2e66c26eebf4aee8e5249f1fbe0e4cdaeef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 6 Sep 2026 15:21:30 +0200 Subject: [PATCH 19/20] chore: re-trigger CI (kwf deliver-the-work-described-b-fbf8/vr16) From 2bec1c9a5766b5bbc3f197084d5e1ee4bd22b595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 6 Sep 2026 18:05:11 +0200 Subject: [PATCH 20/20] chore: re-trigger CI (kwf deliver-the-work-described-b-fbf8/vr16)