Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
ea2ce37
feat(mobile): deliver glanceable snapshot updates
iscekic Aug 27, 2026
eba0897
fix(glanceable): send renderable Live Activity content-state
iscekic Aug 27, 2026
2815c7a
fix(glanceable): apply remote snapshot revision and org scope
iscekic Aug 27, 2026
1fcf89c
fix(glanceable): apply snapshot in the background
iscekic Aug 27, 2026
dbc302e
fix(glanceable): await activity-token unregister on logout
iscekic Aug 27, 2026
a4acd19
fix(glanceable): recover iOS Live Activity and spoken labels
iscekic Aug 27, 2026
39d422e
fix(glanceable): register Android ongoing and restore widget state
iscekic Aug 27, 2026
cdb5144
Merge branch 'audit-w8b-live-activities-3cf8-s3' into audit-w8b-live-…
iscekic Aug 27, 2026
4f68438
fix(glanceable): unregister tokens on switch and end remote-empty
iscekic Aug 27, 2026
b32b12e
Merge branch 'audit-w8b-live-activities-3cf8-s3' into audit-w8b-live-…
iscekic Aug 27, 2026
328e4c7
chore(db): renumber activity-token migration after main merge
iscekic Aug 27, 2026
3367927
chore(deps): reconcile lockfile after main merge
iscekic Aug 27, 2026
f1d9acc
fix(glanceable): resolve lint errors after lint config update
iscekic Aug 27, 2026
d576b7e
Merge branch 'audit-w8b-live-activities-3cf8-s3' into audit-w8b-live-…
iscekic Aug 27, 2026
b4cfb29
Merge branch 'audit-w8b-live-activities-3cf8-s3' into audit-w8b-live-…
iscekic Aug 27, 2026
09bd401
Merge branch 'audit-w8b-live-activities-3cf8-s3' into audit-w8b-live-…
iscekic Aug 28, 2026
92f7574
fix(glanceable): settle token races and APNs start stacking
iscekic Aug 28, 2026
caf4017
docs(glanceable): correct unregisterTokens contract comment
iscekic Aug 28, 2026
520eb88
chore(stack): merge Android widget repairs into delivery
iscekic Aug 28, 2026
fe6f60a
fix(mobile): fence activity teardown and isolate unread tests
iscekic Aug 28, 2026
05664ca
fix(mobile): preserve cleanup and order scope token mutations
iscekic Aug 28, 2026
5853a07
test(mobile): isolate native imports in the mounted Agents suite
iscekic Aug 28, 2026
74cdebf
Merge branch 'audit-w8b-live-activities-3cf8-s3' into audit-w8b-live-…
iscekic Aug 28, 2026
fa8897d
feat(i18n): translate active agents update notifications
iscekic Aug 28, 2026
29dce5d
Merge branch 'audit-w8b-live-activities-3cf8-s3' into audit-w8b-live-…
iscekic Aug 28, 2026
609fc68
fix(mobile): fence delayed glanceable publication and recovery
iscekic Aug 28, 2026
e53b44a
Merge branch 'audit-w8b-live-activities-3cf8-s3' into audit-w8b-live-…
iscekic Aug 28, 2026
874dc91
Merge branch 'audit-w8b-live-activities-3cf8-s3' into audit-w8b-live-…
iscekic Aug 28, 2026
a07b005
fix(i18n): add ActivityKit prompt keys with their consumer
iscekic Aug 28, 2026
47f4653
chore(stack): merge Android count presentation into delivery
iscekic Aug 28, 2026
1330ac0
chore(stack): merge Android native deadlines into delivery
iscekic Aug 28, 2026
2864988
fix(mobile): recover ActivityKit capability while agents are idle
iscekic Aug 28, 2026
453669e
fix(mobile): retain scope delivery and register rotated tokens
iscekic Aug 28, 2026
7428272
fix(glanceable): order refreshes and persist eligible intervals
iscekic Aug 28, 2026
fbd4616
fix(glanceable): refresh counts after cloud run reports
iscekic Aug 28, 2026
b53d3fc
fix(glanceable): retire terminal APNs targets before fresh work
iscekic Aug 28, 2026
2e4a85f
fix(glanceable): hand iOS terminal dismissal to ActivityKit
iscekic Sep 2, 2026
e9bd7b6
Merge branch 'audit-w8b-live-activities-3cf8-s3' into audit-w8b-live-…
iscekic Sep 2, 2026
33674ef
feat(mobile): add an Active Agents on/off setting
iscekic Sep 2, 2026
6f9b484
feat(notifications): land the APNs Live Activity credentials
iscekic Sep 2, 2026
d28d7a8
chore(dev): remove the dev:mobile:open script
iscekic Sep 2, 2026
d94ad7b
feat(mobile): redesign the glanceable iOS surfaces around three states
iscekic Sep 2, 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
12 changes: 12 additions & 0 deletions ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,18 @@ When `VERCEL_TARGET_ENV` is absent in local development or a script process, tra

## Services

### Notifications Worker

- `APNS_TEAM_ID` - Apple Developer team ID for the token-based APNs key used to send Live Activity pushes. Set in `services/notifications/wrangler.jsonc` under `vars`. [SERVER]
- `APNS_KEY_ID` - APNs key identifier (`kid`) for the Live Activity push key. Set beside `APNS_TEAM_ID`. [SERVER]
- `APNS_PRIVATE_KEY` - PKCS#8 ES256 `.p8` private key contents for APNs provider-token signing. Stored as one line: the PEM decoder strips every whitespace character, so the newlines are not needed. Store the key in the Secrets Store first, then add its `secrets_store_secrets` binding; a binding for a missing secret fails the deploy. `[SECRET]`
- `APNS_TOPIC` - iOS app bundle id (`com.kilocode.kiloapp`); Live Activity pushes use `<topic>.push-type.liveactivity`. Already set in `vars`. [SERVER]
- `KILO_WEB_API_BASE_URL` - Base origin of the web app, used to reach the internal `glanceable-agents-snapshot` route; `https://app.kilo.ai` in production. [SERVER]

Until all four values reach the worker it logs `APNs Live Activity credentials missing` and skips Live Activity pushes. Every other glanceable delivery, including the Expo aggregate push, keeps working.

The key is team-scoped for all topics and valid in both the sandbox and production APNs environments. A backup of the `.p8` lives in the 1Password "Eng / Product" vault as "Apple AuthKey KRYMZL626P (.p8)"; Apple never serves it a second time.

### KiloClaw Controller

- `KILOCODE_API_KEY` - API key used by the KiloClaw controller for internal gateway identity. `[SECRET]`
Expand Down
6 changes: 5 additions & 1 deletion apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,10 @@ const config: ExpoConfig = {
{
icon: './assets/images/android-notification-icon.png',
color: '#FAF74F',
// iOS requires `remote-notification` in UIBackgroundModes for the
// headless background task (`registerTaskAsync`) to deliver a data-only
// `active_agents_glanceable` push while the app is not in the foreground.
enableBackgroundRemoteNotifications: true,
},
],
'expo-web-browser',
Expand Down Expand Up @@ -268,7 +272,7 @@ const config: ExpoConfig = {
{
name: 'ActiveAgentsWidget',
displayName: 'Active Agents',
description: 'Counts of running, needs-input, and reconnecting agents',
description: 'Agents that need input, are working, or are idle',
contentMarginsDisabled: false,
supportedFamilies: [
'systemSmall',
Expand Down
Binary file added apps/mobile/assets/images/logo-widget.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Icon
import android.net.Uri
import android.os.Build
import expo.modules.kotlin.modules.Module
Expand All @@ -16,7 +17,8 @@ import expo.modules.kotlin.modules.ModuleDefinition
*
* The JS side owns the translated copy and the revision guard; this module owns
* the fixed notification id, the dedicated `active-agents` channel (default
* importance, silent, no heads-up), and the API 36.1+ promotion gate.
* importance, silent, no heads-up), the API 36.1+ promotion gate, and the
* content intent plus named action that open the Agents tab via a deep link.
*/
class ActiveAgentsLiveUpdateModule : Module() {
override fun definition() = ModuleDefinition {
Expand All @@ -26,12 +28,12 @@ class ActiveAgentsLiveUpdateModule : Module() {
isPromotionCapable()
}

Function("start") { title: String, text: String, compactText: String?, promotion: Boolean ->
post(title, text, compactText, promotion, 0)
Function("start") { title: String, text: String, openAgentsLabel: String, compactText: String?, promotion: Boolean ->
post(title, text, openAgentsLabel, compactText, promotion, 0)
}

Function("update") { title: String, text: String, compactText: String?, promotion: Boolean, timeoutMs: Double ->
post(title, text, compactText, promotion, timeoutMs.toLong())
Function("update") { title: String, text: String, openAgentsLabel: String, compactText: String?, promotion: Boolean, timeoutMs: Double ->
post(title, text, openAgentsLabel, compactText, promotion, timeoutMs.toLong())
}

Function("end") {
Expand Down Expand Up @@ -102,16 +104,24 @@ class ActiveAgentsLiveUpdateModule : Module() {
)
}

private fun post(title: String, text: String, compactText: String?, promotion: Boolean, timeoutMs: Long) {
private fun post(title: String, text: String, openAgentsLabel: String, compactText: String?, promotion: Boolean, timeoutMs: Long) {
val contentIntent = openAgentsPendingIntent()
val builder = newBuilder(title)
.setSmallIcon(smallIconId())
.setContentTitle(title)
.setContentText(text)
.setContentIntent(openAgentsPendingIntent())
.setContentIntent(contentIntent)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setSound(null)
.setCategory(Notification.CATEGORY_STATUS)
.addAction(
Notification.Action.Builder(
Icon.createWithResource(context, smallIconId()),
openAgentsLabel,
contentIntent
).build()
)

// API 36.1+ Live Update: promote only when the device reports the capability.
// setRequestPromotedOngoing does not exist; use the documented flag setter.
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"expo-sqlite": "~57.0.1",
"expo-status-bar": "57.0.1",
"expo-store-review": "~57.0.2",
"expo-task-manager": "57.0.12",
"expo-tracking-transparency": "~57.0.1",
"expo-web-browser": "~57.0.2",
"expo-widgets": "57.0.11",
Expand Down
207 changes: 206 additions & 1 deletion apps/mobile/src/app/(app)/(tabs)/(2_agents)/index.mounted.test.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,94 @@
/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom); its React 19 deprecation notice points to the DOM-based Testing Library, which cannot render this app's non-DOM tree, and @testing-library/react-native cannot be transformed by the current vitest pipeline (react-native ships Flow). See src/test/render-with-providers.tsx. */
/* eslint-disable max-lines -- mounted route outcomes and Settings recovery share the native boundary harness. */
import { createElement } from 'react';
import TestRenderer, { act } from 'react-test-renderer';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import {
buildGlanceableSnapshot,
type GlanceableAgentsSnapshot,
} from '@kilocode/app-shared/glanceable-agents-snapshot';
import AgentSessionList, { buildGitHubInstallOutcomeAlert } from './index';
import {
getGitHubInstallReturnOutcome,
setGitHubInstallReturnOutcome,
} from '@/lib/github-install-return';
import {
_resetGlanceablePersistForTests,
_setLastGlanceableSnapshotForTests,
} from '@/lib/glanceable/persist';
import {
type GlanceableSink,
registerGlanceableSink,
unregisterGlanceableSink,
} from '@/lib/glanceable/sink-registry';
import { ACTIVE_USER_ID_KEY } from '@/lib/storage-keys';

const alertMock = vi.hoisted(() => vi.fn());
const platformMock = vi.hoisted(() => ({ OS: 'ios' }));
const mintInstallStateMock = vi.hoisted(() => vi.fn());
const openAuthSessionMock = vi.hoisted(() => vi.fn());
const openBrowserMock = vi.hoisted(() => vi.fn());
const focusedRoute = vi.hoisted(() => ({ focused: true }));
const appStateListeners = vi.hoisted(() => new Set<(state: string) => void>());
const activityKit = vi.hoisted(() => ({ denied: false, available: false, settingsOpen: false }));

vi.mock('react-native', () => ({
Alert: { alert: alertMock },
Platform: platformMock,
AppState: {
addEventListener: (_event: string, listener: (state: string) => void) => {
appStateListeners.add(listener);
return {
remove: () => {
appStateListeners.delete(listener);
},
};
},
},
Linking: {
openSettings: () => {
activityKit.settingsOpen = true;
},
},
}));

vi.mock('expo-router', async () => {
const { useEffect } = await import('react');
return {
useFocusEffect: (effect: Parameters<typeof useEffect>[0]) => {
const focused = focusedRoute.focused;
useEffect(() => (focused ? effect() : undefined), [effect, focused]);
},
};
});

// `failIdentityReadOnce` fails the identity read alone. The master-switch read
// runs first, and its own failure keeps the surfaces on rather than skipping
// recovery, so a first-call rejection would not exercise the identity path.
const storage = vi.hoisted(() => ({ failIdentityReadOnce: false }));
vi.mock('expo-secure-store', () => ({
getItemAsync: vi.fn((key: string) => {
if (key !== ACTIVE_USER_ID_KEY) {
return null;
}
if (storage.failIdentityReadOnce) {
storage.failIdentityReadOnce = false;
throw new Error('storage unavailable');
}
return 'u1';
}),
}));

vi.mock('@/glanceable-ios/ios-sink', () => ({
getActivityKitDenied: () => activityKit.denied,
clearActivityKitDeniedIfAvailable: () => {
if (!activityKit.denied || !activityKit.available) {
return false;
}
activityKit.denied = false;
return true;
},
}));

vi.mock('expo-web-browser', () => ({
Expand Down Expand Up @@ -297,3 +368,137 @@ describe('Agents tab return-outcome rendering', () => {
});
});
});

describe('Agents ActivityKit Settings recovery', () => {
const surface: { activity: GlanceableAgentsSnapshot | null } = { activity: null };
const sink: GlanceableSink = {
publish: () => undefined,
endImmediate() {
surface.activity = null;
},
startOrUpdate(snapshot) {
surface.activity = snapshot;
},
};
const snapshot = buildGlanceableSnapshot({
sessions: [{ status: 'busy' }, { status: 'question' }],
userId: 'u1',
organizationId: null,
now: 1_750_000_000_000,
});

beforeEach(() => {
alertMock.mockClear();
platformMock.OS = 'ios';
focusedRoute.focused = true;
activityKit.denied = false;
activityKit.available = false;
activityKit.settingsOpen = false;
surface.activity = null;
appStateListeners.clear();
setGitHubInstallReturnOutcome(null);
_resetGlanceablePersistForTests();
_setLastGlanceableSnapshotForTests(snapshot);
registerGlanceableSink(sink);
});

afterEach(() => {
unregisterGlanceableSink(sink);
storage.failIdentityReadOnce = false;
});

function changeAppState(state: string) {
act(() => {
for (const listener of appStateListeners) {
listener(state);
}
});
}

it('recovers on direct Settings return without refocusing or repeating the alert', async () => {
activityKit.denied = true;
const renderer = mountRoute();
await flushMicrotasks();
expect(surface.activity).toBeNull();
expect(lastAlertButtons()).toEqual([
{ text: 'Cancel', style: 'cancel' },
{ text: 'Open Settings', onPress: expect.any(Function) },
]);

act(() => {
lastAlertButtons()
?.find(button => button.text === 'Open Settings')
?.onPress?.();
});
expect(activityKit.settingsOpen).toBe(true);
changeAppState('background');
changeAppState('active');
await flushMicrotasks();
expect(surface.activity).toBeNull();
expect(alertMock.mock.calls).toHaveLength(1);

changeAppState('background');
activityKit.available = true;
changeAppState('inactive');
await flushMicrotasks();
expect(surface.activity).toBeNull();
changeAppState('active');
await flushMicrotasks();

expect(surface.activity).toEqual(snapshot);
expect(alertMock.mock.calls).toHaveLength(1);
act(() => {
renderer.unmount();
});
});

it('retries the same snapshot after a failed Settings-return identity read', async () => {
activityKit.denied = true;
const renderer = mountRoute();
await flushMicrotasks();
alertMock.mockClear();

changeAppState('background');
activityKit.available = true;
storage.failIdentityReadOnce = true;
changeAppState('active');
await flushMicrotasks();
expect(surface.activity).toBeNull();

changeAppState('background');
changeAppState('active');
await flushMicrotasks();

expect(surface.activity).toEqual(snapshot);
expect(alertMock.mock.calls).toHaveLength(0);
act(() => {
renderer.unmount();
});
});

it.each(['blur', 'unmount'])('stops foreground recovery after route %s', async transition => {
const renderer = mountRoute();
if (transition === 'blur') {
focusedRoute.focused = false;
act(() => {
renderer.update(createElement(AgentSessionList));
});
} else {
act(() => {
renderer.unmount();
});
}
activityKit.denied = true;
activityKit.available = true;
changeAppState('active');
await flushMicrotasks();

expect(surface.activity).toBeNull();
expect(appStateListeners.size).toBe(0);
if (transition === 'blur') {
act(() => {
renderer.unmount();
});
}
});
});
24 changes: 23 additions & 1 deletion apps/mobile/src/app/(app)/(tabs)/(2_agents)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useCallback, useEffect } from 'react';
import * as WebBrowser from 'expo-web-browser';
import { Alert, Platform } from 'react-native';
import { useFocusEffect } from 'expo-router';
import { Alert, AppState, Platform } from 'react-native';

import { i18n } from '@/i18n';
import { AgentSessionListScreen } from '@/components/agents/session-list-screen';
Expand All @@ -11,6 +12,10 @@ import {
type GitHubInstallReturnOutcome,
subscribeToGitHubInstallReturnOutcome,
} from '@/lib/github-install-return';
import {
recoverGlanceableActivityKit,
showActivityKitDisabledAlertOnce,
} from '@/lib/glanceable/activity-kit-prompt';
import { trpcClient } from '@/lib/trpc';

export type GitHubInstallOutcomeAlertButton = {
Expand Down Expand Up @@ -135,5 +140,22 @@ export default function AgentSessionList() {
return subscribeToGitHubInstallReturnOutcome(consumeReturnOutcome);
}, [consumeReturnOutcome]);

// Only tab focus can show the one-time alert. Settings can return without
// changing route focus, so also retry recovery when the app becomes active.
useFocusEffect(
useCallback(() => {
void showActivityKitDisabledAlertOnce();
void recoverGlanceableActivityKit();
const subscription = AppState.addEventListener('change', state => {
if (state === 'active') {
void recoverGlanceableActivityKit();
}
});
return () => {
subscription.remove();
};
}, [])
);

return <AgentSessionListScreen />;
}
Loading
Loading