Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
a1c46cc
feat(mobile): add iOS widgets native config and module
iscekic Aug 27, 2026
d8d6f5b
chore(deps): dedupe @expo/ui native module
iscekic Aug 27, 2026
b49e6b3
feat(mobile): add iOS Live Activity and widgets
iscekic Aug 27, 2026
6c75153
Merge branch 'audit-w8b-live-activities-3cf8' into audit-w8b-live-act…
iscekic Aug 27, 2026
7aada2b
Merge branch 'audit-w8b-live-activities-3cf8' into audit-w8b-live-act…
iscekic Aug 27, 2026
1a757a1
Merge branch 'audit-w8b-live-activities-3cf8' into audit-w8b-live-act…
iscekic Aug 27, 2026
119f2a2
fix(glanceable): use deep link and end leftover Live Activity
iscekic Aug 27, 2026
5043f7a
fix(mobile): allow the staged Android widget dependency
iscekic Aug 28, 2026
a6209af
Merge branch 'audit-w8b-live-activities-3cf8' into audit-w8b-live-act…
iscekic Aug 28, 2026
2a47259
fix(mobile): preserve terminal iOS widget timelines
iscekic Aug 28, 2026
7862ed8
Merge branch 'audit-w8b-live-activities-3cf8' into audit-w8b-live-act…
iscekic Aug 28, 2026
61bd849
Merge branch 'audit-w8b-live-activities-3cf8' into audit-w8b-live-act…
iscekic Aug 28, 2026
88b6f23
Merge branch 'audit-w8b-live-activities-3cf8' into audit-w8b-live-act…
iscekic Sep 2, 2026
c0f2a4c
Merge branch 'audit-w8b-live-activities-3cf8' into audit-w8b-live-act…
iscekic Sep 3, 2026
3008e61
Merge branch 'audit-w8b-live-activities-3cf8' into audit-w8b-live-act…
iscekic Sep 3, 2026
91d0a50
Merge branch 'audit-w8b-live-activities-3cf8' into audit-w8b-live-act…
iscekic Sep 3, 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
31 changes: 31 additions & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,37 @@ const config: ExpoConfig = {
},
],
'./plugins/withAndroidManifestFix',
// Aggregate "Active Agents" glanceable surfaces: one Live Activity plus Home
// Screen and Lock Screen widgets, rendered by src/glanceable-ios. The widget
// target reuses the existing app group; no second group is created.
[
'expo-widgets',
{
groupIdentifier: 'group.com.kilocode.kiloapp',
bundleIdentifier: 'com.kilocode.kiloapp.ExpoWidgetsTarget',
enablePushNotifications: true,
widgets: [
{
name: 'ActiveAgentsWidget',
displayName: 'Active Agents',
description: 'Counts of running, needs-input, and reconnecting agents',
contentMarginsDisabled: false,
supportedFamilies: [
'systemSmall',
'systemMedium',
'systemLarge',
'accessoryCircular',
'accessoryRectangular',
'accessoryInline',
],
},
],
},
],
// Local Expo module for Android Live Updates (no-op until slice `and`).
'./plugins/withActiveAgentsLiveUpdate',
// No-op until slice `and` writes src/glanceable-android/widget-config.json.
'./plugins/withActiveAgentsAndroidWidget',
// Registered only when GOOGLE_IOS_CLIENT_ID is set — a guard for checkouts
// whose environment does not provide it.
...googleSignInPlugins,
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"ignoreDependencies": [
"expo-updates",
"expo-system-ui",
"react-native-android-widget",
"postcss",
"eslint-plugin-sonarjs",
"@sentry/cli"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"platforms": ["apple", "android"],
"apple": {
"modules": []
},
"android": {
"modules": []
}
}
12 changes: 12 additions & 0 deletions apps/mobile/modules/active-agents-live-update/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "active-agents-live-update",
"version": "0.1.0",
"main": "src/index.ts",
"types": "src/index.ts",
"private": true,
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
}
32 changes: 32 additions & 0 deletions apps/mobile/modules/active-agents-live-update/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Local Expo module for Android Live Updates.
//
// Slice `and` (level 3) adds the Android native implementation under
// modules/active-agents-live-update/android and the Android sink under
// src/glanceable-android. Until then every function is a no-op so the module
// loads in the main process on both platforms.

export function start(_snapshot: unknown): void {
// No-op until the Android native module lands.
}

export function update(_snapshot: unknown): void {
// No-op until the Android native module lands.
}

export function end(_immediate?: boolean): void {
// No-op until the Android native module lands.
}

export function isLiveUpdateCapable(): boolean {
return false;
}

// The Android sink registers itself from the main process once slice `and`
// provides @/glanceable-android/register. The try/catch keeps this import a
// no-op before that file exists.
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('@/glanceable-android/register');
Comment thread
iscekic marked this conversation as resolved.
} catch {
// glanceable-android/register does not exist until slice `and` lands.
}
4 changes: 4 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@expo-google-fonts/jetbrains-mono": "0.4.1",
"@expo/app-integrity": "57.0.1",
"@expo/react-native-action-sheet": "4.1.1",
"@expo/ui": "57.0.12",
"@formatjs/intl-durationformat": "^0.10.18",
"@formatjs/intl-listformat": "^8.3.13",
"@formatjs/intl-locale": "^5.3.10",
Expand All @@ -53,6 +54,7 @@
"@tanstack/react-query-persist-client": "catalog:",
"@trpc/client": "catalog:",
"@trpc/tanstack-react-query": "catalog:",
"active-agents-live-update": "file:./modules/active-agents-live-update",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"drizzle-orm": "catalog:",
Expand Down Expand Up @@ -94,6 +96,7 @@
"expo-store-review": "~57.0.2",
"expo-tracking-transparency": "~57.0.1",
"expo-web-browser": "~57.0.2",
"expo-widgets": "57.0.11",
"i18next": "^26.4.0",
"jotai": "2.20.2",
"lowlight": "3.3.0",
Expand All @@ -103,6 +106,7 @@
"react": "19.2.3",
"react-i18next": "^17.0.12",
"react-native": "0.86.2",
"react-native-android-widget": "^0.22.1",
"react-native-appsflyer": "6.18.0",
"react-native-css": "3.0.7",
"react-native-gesture-handler": "2.32.0",
Expand Down
27 changes: 27 additions & 0 deletions apps/mobile/plugins/withActiveAgentsAndroidWidget.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const fs = require('fs');
const path = require('path');

// Wraps react-native-android-widget so its config plugin only applies once the
// Android widget actually exists. The widget config file is created by slice
// `and` (level 3) at apps/mobile/src/glanceable-android/widget-config.json;
// before then this plugin is a no-op, so level 2 prebuilds are unaffected.
const WIDGET_CONFIG_PATH = path.resolve(__dirname, '../src/glanceable-android/widget-config.json');

function loadAndroidWidgetsPlugin() {
const resolved = require.resolve('react-native-android-widget/app.plugin.js');
const mod = require(resolved);
return typeof mod === 'function' ? mod : mod.default;
}

module.exports = function withActiveAgentsAndroidWidget(config) {
if (!fs.existsSync(WIDGET_CONFIG_PATH)) {
// No Android widget yet: keep the config untouched.
return config;
}
const widgetConfig = JSON.parse(fs.readFileSync(WIDGET_CONFIG_PATH, 'utf8'));
const widgets = Array.isArray(widgetConfig.widgets) ? widgetConfig.widgets : [];
if (widgets.length === 0) {
return config;
}
return loadAndroidWidgetsPlugin()(config, { widgets });
};
19 changes: 19 additions & 0 deletions apps/mobile/plugins/withActiveAgentsLiveUpdate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const fs = require('fs');
const path = require('path');

// Registers the local `active-agents-live-update` Expo module.
//
// The module is a `file:` dependency declared in apps/mobile/package.json, so
// pnpm links it into the app's node_modules and Expo autolinking reads its
// expo-module.config.json during prebuild. At this level it is JS-only (the
// native module lists are empty); slice `and` adds the Android native module.
// This plugin exists as the named hook in app.config.ts so a later level can
// extend registration without touching app.config.ts again.
module.exports = function withActiveAgentsLiveUpdate(config) {
const moduleDir = path.resolve(__dirname, '../modules/active-agents-live-update');
const moduleConfig = path.join(moduleDir, 'expo-module.config.json');
if (!fs.existsSync(moduleConfig)) {
throw new Error(`active-agents-live-update module config not found at ${moduleConfig}`);
}
return config;
};
6 changes: 6 additions & 0 deletions apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
import '@/i18n/rtl';
import '../global.css';
import '@/lib/cloud-agent-runtime';
// Enter the local module's JS in the main process on both platforms. Its
// Android branch stays a no-op until slice `and` lands; iOS runs the
// registered glanceable sink below.
import 'active-agents-live-update';
// Registers the iOS Live Activity and widget sink with the glanceable publisher.
import '@/glanceable-ios/register';

import { installE2EWebSocketLatency } from '@/lib/e2e-ws-latency';

Expand Down
112 changes: 112 additions & 0 deletions apps/mobile/src/glanceable-ios/active-agents-live-activity.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { Text, VStack } from '@expo/ui/swift-ui';
import {
accessibilityElement,
accessibilityLabel,
font,
foregroundStyle,
} from '@expo/ui/swift-ui/modifiers';
import { createLiveActivity } from 'expo-widgets';
import { PlatformColor } from 'react-native';

import { type GlanceableViewProps } from './view-props';

/* eslint-disable new-cap -- PlatformColor is a React Native factory function, not a constructor */

// The layout function below is marked with the `'widget'` directive, so Babel
// stringifies it and the watcher extension re-evaluates the source. Everything
// it references must be a watcher global (`Text`, `VStack`, the modifiers,
// `PlatformColor`) or a built-in. Do not call `@/` helpers or i18n from here —
// translated copy arrives through `props`. The inlined English fallbacks below
// only render while the gallery placeholder has no snapshot props.

export const ActiveAgentsLiveActivity = createLiveActivity<Partial<GlanceableViewProps>>(
'ActiveAgentsLiveActivity',
(props, environment) => {
'widget';

const dark = environment.colorScheme === 'dark';
const counts = props.countLines ?? [];
const hasCounts = counts.length > 0;
const primaryLabel = props.primaryLabel ?? null;
const primaryCount = String(props.primaryCount ?? 0);
const statusLine = props.statusLine ?? (hasCounts ? null : 'No work in progress');
const elapsedAnchor = props.elapsedAnchor ?? null;

const primaryForeground = foregroundStyle(PlatformColor('label'));
const mutedForeground = foregroundStyle(
dark ? PlatformColor('secondaryLabel') : PlatformColor('tertiaryLabel')
);

const countRows = counts.map(line => (
<Text key={line.label} modifiers={[font({ textStyle: 'body' }), primaryForeground]}>
{`${line.count} ${line.label}`}
</Text>
));

return {
banner: (
<VStack
alignment="leading"
spacing={4}
modifiers={[
accessibilityElement('combine'),
accessibilityLabel(props.accessibilityLabel ?? ''),
]}
>
{hasCounts ? (
<VStack alignment="leading" spacing={2}>
{countRows}
</VStack>
) : null}
{statusLine !== null ? <Text modifiers={[mutedForeground]}>{statusLine}</Text> : null}
{elapsedAnchor !== null ? (
<Text
date={new Date(elapsedAnchor)}
dateStyle="relative"
modifiers={[mutedForeground]}
/>
) : null}
</VStack>
),
compactLeading: (
<Text modifiers={[font({ textStyle: 'headline', weight: 'bold' }), primaryForeground]}>
{hasCounts ? primaryCount : statusLine}
</Text>
),
compactTrailing: (
<Text modifiers={[font({ textStyle: 'footnote' }), primaryForeground]}>
{hasCounts ? (primaryLabel ?? primaryCount) : ''}
</Text>
),
minimal: (
<Text modifiers={[font({ textStyle: 'headline', weight: 'bold' }), primaryForeground]}>
{hasCounts ? primaryCount : ''}
</Text>
),
expandedLeading: (
<VStack alignment="leading" spacing={2}>
{countRows}
</VStack>
),
expandedTrailing: (
<VStack alignment="leading" spacing={2}>
{statusLine !== null ? <Text modifiers={[mutedForeground]}>{statusLine}</Text> : null}
{elapsedAnchor !== null ? (
<Text
date={new Date(elapsedAnchor)}
dateStyle="relative"
modifiers={[mutedForeground]}
/>
) : null}
</VStack>
),
expandedBottom: (
<VStack alignment="leading" spacing={2}>
{statusLine !== null && !hasCounts ? (
<Text modifiers={[mutedForeground]}>{statusLine}</Text>
) : null}
</VStack>
),
};
}
);
Loading