Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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