Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0489c41
Make push badge data authoritative (kwf deliver-the-work-described-b-…
iscekic Sep 4, 2026
a651519
Sync the app badge from glanceable snapshots (kwf deliver-the-work-de…
iscekic Sep 4, 2026
edf03a8
UX: A delayed older push can replace the current badge and false (kwf…
iscekic Sep 4, 2026
5a02ba3
fix: fix the failed device scenarios (kwf deliver-the-work-described-…
iscekic Sep 4, 2026
56cd55e
fix: fix the failed device scenarios (kwf deliver-the-work-described-…
iscekic Sep 4, 2026
290f401
fix: fix the failed device scenarios (kwf deliver-the-work-described-…
iscekic Sep 4, 2026
91251d2
fix: fix the failed device scenarios (kwf deliver-the-work-described-…
iscekic Sep 4, 2026
201fbaf
fix: fix the failed device scenarios (kwf deliver-the-work-described-…
iscekic Sep 4, 2026
77500c2
fix: fix the failed device scenarios (kwf deliver-the-work-described-…
iscekic Sep 4, 2026
935d537
chore: deliver-the-work-described-b-fbf8 pre-verify snapshot
iscekic Sep 4, 2026
60841b8
fix: fix the failed device scenarios (kwf deliver-the-work-described-…
iscekic Sep 5, 2026
53f1bae
fix: fix the failed device scenarios (kwf deliver-the-work-described-…
iscekic Sep 5, 2026
c175a89
fix: fix the failed device scenarios (kwf deliver-the-work-described-…
iscekic Sep 5, 2026
c2ffa8c
feat(mobile): sync launcher badge with glanceable needs-input count
iscekic Sep 5, 2026
7114868
fix: fix the failed device scenarios (kwf deliver-the-work-described-…
iscekic Sep 5, 2026
802eafc
fix: fix the failed device scenarios (escalation: kilo/x-ai/grok-4.6)…
iscekic Sep 5, 2026
28eb2ef
chore: re-trigger CI after dropped synchronize event (kwf deliver-the…
iscekic Sep 6, 2026
f08efa0
chore: retry CI trigger (kwf deliver-the-work-described-b-fbf8/vr16)
iscekic Sep 6, 2026
c56ff2e
chore: re-trigger CI (kwf deliver-the-work-described-b-fbf8/vr16)
iscekic Sep 6, 2026
2bec1c9
chore: re-trigger CI (kwf deliver-the-work-described-b-fbf8/vr16)
iscekic Sep 6, 2026
b2f4eaa
Merge origin/main into kwf/deliver-the-work-described-b-fbf8
iscekic Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 6 additions & 21 deletions apps/mobile/src/components/kilo-chat/hooks/mark-read-operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,13 @@ export async function markReadConversation({
return result;
}

type ApplyBadgeClearResultInput<T> = {
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<T>;
};

export function filterClearedBadgeBucket(
Expand All @@ -45,26 +42,14 @@ export function filterClearedBadgeBucket(
return badges?.filter(row => row.badgeBucket !== badgeClear.badgeBucket);
}

export function applyBadgeClearResult<T>({
export function applyBadgeClearResult({
badgeClear,
startBadgeFreshnessEpoch,
currentBadgeFreshnessEpoch,
userId,
updateBadgeRows,
setBadgeCount,
}: ApplyBadgeClearResultInput<T>): 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));
}
9 changes: 1 addition & 8 deletions apps/mobile/src/components/kilo-chat/hooks/use-mark-read.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
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';
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;
Expand Down Expand Up @@ -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<BadgeCountRow[]>(queryKey, updater);
},
setBadgeCount: Notifications.setBadgeCountAsync,
});
},
onSettled: () => {
Expand Down
97 changes: 4 additions & 93 deletions apps/mobile/src/components/kilo-chat/mark-read-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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<boolean>>(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<boolean>>(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<boolean>>(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<boolean>>(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<boolean>>(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();
});
});
2 changes: 2 additions & 0 deletions apps/mobile/src/lib/active-sessions-live-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 0 additions & 10 deletions apps/mobile/src/lib/badge-freshness.ts

This file was deleted.

26 changes: 0 additions & 26 deletions apps/mobile/src/lib/badge-hydration.ts

This file was deleted.

8 changes: 7 additions & 1 deletion apps/mobile/src/lib/glanceable/publisher.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -99,7 +100,7 @@ describe('GlanceablePublisher', () => {
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 });
Expand All @@ -110,6 +111,11 @@ describe('GlanceablePublisher', () => {
vi.advanceTimersByTime(1000);
expect(count(calls, 'startOrUpdate')).toBe(2);
expect(lastSnapshot(calls, 'startOrUpdate').running).toBe(3);
// The badge reads `needsInput`, so a change to it skips the coalesce wait.
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();
});

Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/lib/glanceable/publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
54 changes: 54 additions & 0 deletions apps/mobile/src/lib/hooks/use-current-user-id.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
4 changes: 2 additions & 2 deletions apps/mobile/src/lib/hooks/use-current-user-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand All @@ -17,7 +17,7 @@ export function useCurrentUserId(options: UseCurrentUserIdOptions = {}) {
userId: data?.id,
email: data?.email,
isLoading,
isError,
isError: isError || (isLoading && isFetched),
refetch: () => {
void refetch();
},
Expand Down
2 changes: 0 additions & 2 deletions apps/mobile/src/lib/hooks/use-unread-counts-invalidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -30,7 +29,6 @@ export function useUnreadCountsInvalidation() {
}

const invalidate = () => {
advanceBadgeFreshnessEpoch();
void queryClient.invalidateQueries({
queryKey: ['badges', userId],
});
Expand Down
Loading