diff --git a/apps/mobile/knip.json b/apps/mobile/knip.json
index 646cf15939..3fd5f4bac5 100644
--- a/apps/mobile/knip.json
+++ b/apps/mobile/knip.json
@@ -1,6 +1,6 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
- "entry": ["src/app/**/*.{ts,tsx}"],
+ "entry": ["src/app/**/*.{ts,tsx}", "src/glanceable-android/register.ts"],
"project": ["src/**/*.{ts,tsx}"],
"ignoreDependencies": [
"expo-updates",
diff --git a/apps/mobile/modules/active-agents-live-update/android/build.gradle b/apps/mobile/modules/active-agents-live-update/android/build.gradle
new file mode 100644
index 0000000000..69271ab620
--- /dev/null
+++ b/apps/mobile/modules/active-agents-live-update/android/build.gradle
@@ -0,0 +1,22 @@
+apply plugin: 'com.android.library'
+
+group = 'com.kilocode.activeagentsliveupdate'
+version = '0.1.0'
+
+def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
+apply from: expoModulesCorePlugin
+applyKotlinExpoModulesCorePlugin()
+useCoreDependencies()
+useExpoPublishing()
+useDefaultAndroidSdkVersions()
+
+android {
+ namespace "com.kilocode.activeagentsliveupdate"
+ defaultConfig {
+ versionCode 1
+ versionName "0.1.0"
+ }
+ lintOptions {
+ abortOnError false
+ }
+}
\ No newline at end of file
diff --git a/apps/mobile/modules/active-agents-live-update/android/src/main/AndroidManifest.xml b/apps/mobile/modules/active-agents-live-update/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..7083a574a0
--- /dev/null
+++ b/apps/mobile/modules/active-agents-live-update/android/src/main/AndroidManifest.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsDeadlineReceiver.kt b/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsDeadlineReceiver.kt
new file mode 100644
index 0000000000..d64d9758a7
--- /dev/null
+++ b/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsDeadlineReceiver.kt
@@ -0,0 +1,124 @@
+package com.kilocode.activeagentsliveupdate
+
+import android.app.AlarmManager
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.content.BroadcastReceiver
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import java.util.UUID
+
+/** One OS-owned widget deadline; an old delivery never changes a newer snapshot. */
+class ActiveAgentsDeadlineReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ when (intent.action) {
+ Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> restoreWidgetDeadline(context)
+ else -> expire(context, intent)
+ }
+ }
+
+ companion object {
+ private const val STORE = "active-agents-deadlines"
+ private const val SNAPSHOT = "widget-snapshot"
+ private const val WIDGET = "widget-expiry"
+ private const val NOTIFICATION = "notification-expiry"
+ private const val GENERATION = "generation"
+ private const val DEADLINE = "deadline"
+ internal const val NOTIFICATION_ID = 1001
+
+ @Synchronized
+ fun setWidgetSnapshot(context: Context, snapshot: String, expiresAt: Long) {
+ replace(context, WIDGET, expiresAt, snapshot)
+ }
+
+ fun getWidgetSnapshot(context: Context): String? =
+ context.getSharedPreferences(STORE, Context.MODE_PRIVATE).getString(SNAPSHOT, null)
+
+ /** Notification.Builder.setTimeoutAfter is unavailable on supported API 24–25. */
+ @Synchronized
+ fun setLegacyNotificationTimeout(context: Context, timeoutMs: Long) {
+ val deadline = if (timeoutMs > 0) System.currentTimeMillis() + timeoutMs else 0
+ replace(context, NOTIFICATION, deadline)
+ }
+
+ private fun replace(context: Context, action: String, deadline: Long, snapshot: String? = null) {
+ val generation = UUID.randomUUID().toString()
+ val preferences = context.getSharedPreferences(STORE, Context.MODE_PRIVATE)
+ val editor = preferences.edit()
+ .putLong(action, deadline)
+ .putString("$action-$GENERATION", generation)
+ if (snapshot != null) editor.putString(SNAPSHOT, snapshot)
+ // Commit before returning across the bridge so process exit cannot lose a blank.
+ check(editor.commit()) { "Cannot persist the active agents deadline" }
+
+ val intent = Intent(context, ActiveAgentsDeadlineReceiver::class.java)
+ .setAction(action)
+ .putExtra(DEADLINE, deadline)
+ .putExtra(GENERATION, generation)
+ val operation = PendingIntent.getBroadcast(
+ context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ val alarms = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ alarms.cancel(operation)
+ if (deadline <= System.currentTimeMillis()) {
+ operation.cancel()
+ return
+ }
+ if (Build.VERSION.SDK_INT >= 31) {
+ // No exact-alarm permission: Android can defer delivery while idle.
+ alarms.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, deadline, operation)
+ } else {
+ alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, deadline, operation)
+ }
+ }
+
+ @Synchronized
+ private fun restoreWidgetDeadline(context: Context) {
+ val preferences = context.getSharedPreferences(STORE, Context.MODE_PRIVATE)
+ val deadline = preferences.getLong(WIDGET, 0)
+ // Privacy and signed-out snapshots persist a zero deadline in the same commit.
+ if (deadline <= 0) return
+ // Keep the original expiry and snapshot; replace only the alarm generation.
+ replace(context, WIDGET, deadline)
+ // Also handle an expiry that passed while down or during alarm restoration.
+ expire(context, Intent(WIDGET)
+ .putExtra(DEADLINE, deadline)
+ .putExtra(GENERATION, preferences.getString("$WIDGET-$GENERATION", null)))
+ }
+
+ @Synchronized
+ private fun expire(context: Context, intent: Intent) {
+ val action = intent.action ?: return
+ if (action != WIDGET && action != NOTIFICATION) return
+ val preferences = context.getSharedPreferences(STORE, Context.MODE_PRIVATE)
+ val deadline = preferences.getLong(action, 0)
+ if (deadline <= 0 || deadline > System.currentTimeMillis() ||
+ deadline != intent.getLongExtra(DEADLINE, 0) ||
+ preferences.getString("$action-$GENERATION", null) != intent.getStringExtra(GENERATION)
+ ) return
+
+ check(preferences.edit().remove(action).remove("$action-$GENERATION").commit()) {
+ "Cannot consume the active agents deadline"
+ }
+ if (action == NOTIFICATION) {
+ val notifications = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ notifications.cancel(NOTIFICATION_ID)
+ return
+ }
+
+ // The installed widget provider starts its durable headless worker for each instance.
+ // That handler re-reads the stored snapshot, including any intervening privacy blank.
+ val provider = ComponentName(context.packageName, "${context.packageName}.widget.ActiveAgentsWidget")
+ val ids = AppWidgetManager.getInstance(context).getAppWidgetIds(provider)
+ if (ids.isEmpty()) return
+ context.sendBroadcast(
+ Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE)
+ .setComponent(provider)
+ .putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
+ )
+ }
+ }
+}
diff --git a/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt b/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt
new file mode 100644
index 0000000000..df0132a7c9
--- /dev/null
+++ b/apps/mobile/modules/active-agents-live-update/android/src/main/java/com/kilocode/activeagentsliveupdate/ActiveAgentsLiveUpdateModule.kt
@@ -0,0 +1,160 @@
+package com.kilocode.activeagentsliveupdate
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+/**
+ * Local Expo module for the Android aggregate ongoing notification.
+ *
+ * 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.
+ */
+class ActiveAgentsLiveUpdateModule : Module() {
+ override fun definition() = ModuleDefinition {
+ Name("ActiveAgentsLiveUpdate")
+
+ Function("isPromotionCapable") {
+ isPromotionCapable()
+ }
+
+ Function("start") { title: String, text: String, compactText: String?, promotion: Boolean ->
+ post(title, text, compactText, promotion, 0)
+ }
+
+ Function("update") { title: String, text: String, compactText: String?, promotion: Boolean, timeoutMs: Double ->
+ post(title, text, compactText, promotion, timeoutMs.toLong())
+ }
+
+ Function("end") {
+ dismiss()
+ }
+
+ Function("setWidgetSnapshot") { snapshot: String, expiresAt: Double ->
+ ActiveAgentsDeadlineReceiver.setWidgetSnapshot(context, snapshot, expiresAt.toLong())
+ }
+
+ Function("getWidgetSnapshot") {
+ ActiveAgentsDeadlineReceiver.getWidgetSnapshot(context)
+ }
+ }
+
+ private val context: Context
+ get() = appContext.reactContext ?: appContext.applicationContext
+
+ private val notificationManager: NotificationManager
+ get() = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+
+ private val notificationState
+ get() = context.getSharedPreferences("active_agents_notification", Context.MODE_PRIVATE)
+
+ private fun smallIconId(): Int =
+ context.resources.getIdentifier("notification_icon", "drawable", context.packageName)
+
+ private fun isPromotionCapable(): Boolean =
+ Build.VERSION.SDK_INT >= 36 &&
+ Build.VERSION.SDK_INT_FULL >= 36_001_000 &&
+ notificationManager.canPostPromotedNotifications()
+
+ private fun ensureChannel(title: String) {
+ if (Build.VERSION.SDK_INT < 26) {
+ return
+ }
+ if (notificationManager.getNotificationChannel(CHANNEL_ID) != null) {
+ return
+ }
+ val channel = NotificationChannel(CHANNEL_ID, title, NotificationManager.IMPORTANCE_DEFAULT)
+ channel.setSound(null, null)
+ channel.enableVibration(false)
+ channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
+ notificationManager.createNotificationChannel(channel)
+ }
+
+ private fun newBuilder(title: String): Notification.Builder {
+ if (Build.VERSION.SDK_INT >= 26) {
+ ensureChannel(title)
+ return Notification.Builder(context, CHANNEL_ID)
+ }
+ return legacyBuilder()
+ }
+
+ @Suppress("DEPRECATION")
+ private fun legacyBuilder(): Notification.Builder = Notification.Builder(context)
+
+ /** A PendingIntent that deep-links the app to the Open agents route. */
+ private fun openAgentsPendingIntent(): PendingIntent {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(OPEN_AGENTS_DEEP_LINK)).apply {
+ setPackage(context.packageName)
+ }
+ return PendingIntent.getActivity(
+ context,
+ OPEN_AGENTS_REQUEST_CODE,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ }
+
+ private fun post(title: String, text: String, compactText: String?, promotion: Boolean, timeoutMs: Long) {
+ val builder = newBuilder(title)
+ .setSmallIcon(smallIconId())
+ .setContentTitle(title)
+ .setContentText(text)
+ .setContentIntent(openAgentsPendingIntent())
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setSound(null)
+ .setCategory(Notification.CATEGORY_STATUS)
+
+ // API 36.1+ Live Update: promote only when the device reports the capability.
+ // setRequestPromotedOngoing does not exist; use the documented flag setter.
+ if (promotion && isPromotionCapable()) {
+ builder.setFlag(Notification.FLAG_PROMOTED_ONGOING, true)
+ builder.setShortCriticalText(compactText)
+ builder.setStyle(Notification.ProgressStyle())
+ }
+
+ // Commit before arming a timeout so process exit cannot lose cancellation state.
+ if (timeoutMs > 0) {
+ check(notificationState.edit().putBoolean(HAS_TIMEOUT, true).commit()) {
+ "Cannot persist the active agents notification timeout"
+ }
+ }
+
+ if (Build.VERSION.SDK_INT >= 26) {
+ // Ordinary updates must retain the notification so onlyAlertOnce suppresses repeat alerts.
+ if (timeoutMs <= 0 && notificationState.getBoolean(HAS_TIMEOUT, false)) {
+ notificationManager.cancel(ActiveAgentsDeadlineReceiver.NOTIFICATION_ID)
+ }
+ builder.setTimeoutAfter(timeoutMs.coerceAtLeast(0))
+ } else {
+ ActiveAgentsDeadlineReceiver.setLegacyNotificationTimeout(context, timeoutMs)
+ }
+ notificationManager.notify(ActiveAgentsDeadlineReceiver.NOTIFICATION_ID, builder.build())
+ if (timeoutMs <= 0) {
+ notificationState.edit().putBoolean(HAS_TIMEOUT, false).apply()
+ }
+ }
+
+ private fun dismiss() {
+ if (Build.VERSION.SDK_INT < 26) {
+ ActiveAgentsDeadlineReceiver.setLegacyNotificationTimeout(context, 0)
+ }
+ notificationManager.cancel(ActiveAgentsDeadlineReceiver.NOTIFICATION_ID)
+ notificationState.edit().remove(HAS_TIMEOUT).apply()
+ }
+
+ private companion object {
+ const val HAS_TIMEOUT = "has_timeout"
+ const val CHANNEL_ID = "active-agents"
+ const val OPEN_AGENTS_DEEP_LINK = "kiloapp:///cloud/sessions"
+ const val OPEN_AGENTS_REQUEST_CODE = 1002
+ }
+}
diff --git a/apps/mobile/modules/active-agents-live-update/expo-module.config.json b/apps/mobile/modules/active-agents-live-update/expo-module.config.json
index 77a2a11327..5c980694d2 100644
--- a/apps/mobile/modules/active-agents-live-update/expo-module.config.json
+++ b/apps/mobile/modules/active-agents-live-update/expo-module.config.json
@@ -4,6 +4,6 @@
"modules": []
},
"android": {
- "modules": []
+ "modules": ["com.kilocode.activeagentsliveupdate.ActiveAgentsLiveUpdateModule"]
}
}
diff --git a/apps/mobile/src/glanceable-android/active-agents-widget.test.ts b/apps/mobile/src/glanceable-android/active-agents-widget.test.ts
new file mode 100644
index 0000000000..1ce6c7a4f3
--- /dev/null
+++ b/apps/mobile/src/glanceable-android/active-agents-widget.test.ts
@@ -0,0 +1,193 @@
+import {
+ buildGlanceableSnapshot,
+ type GlanceableAgentsSnapshot,
+} from '@kilocode/app-shared/glanceable-agents-snapshot';
+import { describe, expect, it, vi } from 'vitest';
+
+import { renderActiveAgentsWidget } from './active-agents-widget';
+import { buildAndroidWidgetProps } from './widget-props';
+
+// Stub the widget primitives so the layout functions return inspectable trees
+// without loading react-native. The real components are exercised by prebuild.
+vi.mock('react-native-android-widget', () => ({
+ FlexWidget: (props: Record) => ({ kind: 'FlexWidget', props }),
+ TextWidget: (props: Record) => ({ kind: 'TextWidget', props }),
+ requestWidgetUpdate: () => undefined,
+}));
+
+const NOW = 1_750_000_000_000;
+
+type MockElement = {
+ kind: string;
+ props: {
+ text?: string;
+ clickAction?: string;
+ clickActionData?: { uri?: string };
+ accessibilityLabel?: string;
+ style?: { backgroundColor?: string };
+ children?: unknown;
+ };
+};
+
+const COPY: Record = {
+ 'glanceable.needsInput': 'Needs input',
+ 'glanceable.reconnecting': 'Reconnecting',
+ 'glanceable.running': 'Running',
+ 'glanceable.empty': 'No work in progress',
+ 'glanceable.expired': 'Status expired',
+ 'glanceable.stale': 'Updates delayed',
+ 'glanceable.openAgents': 'Open agents',
+};
+
+function translate(key: string): string {
+ return COPY[key] ?? key;
+}
+
+function snapshotFor(
+ sessions: { status: string }[],
+ revision = 0,
+ status?: GlanceableAgentsSnapshot['status']
+): GlanceableAgentsSnapshot {
+ return buildGlanceableSnapshot({
+ sessions,
+ userId: 'u1',
+ organizationId: null,
+ now: NOW,
+ previousRevision: revision,
+ ...(status === undefined ? {} : { status }),
+ });
+}
+
+function collectText(node: unknown): string[] {
+ if (node == null) {
+ return [];
+ }
+ if (Array.isArray(node)) {
+ return node.flatMap(item => collectText(item));
+ }
+ if (typeof node !== 'object') {
+ return [];
+ }
+ const element = node as MockElement;
+ const output: string[] = [];
+ if (typeof element.props.text === 'string') {
+ output.push(element.props.text);
+ }
+ if (element.props.children !== undefined) {
+ output.push(...collectText(element.props.children));
+ }
+ return output;
+}
+
+function render(props: ReturnType, width: number) {
+ return renderActiveAgentsWidget(props, {
+ widgetName: 'ActiveAgentsWidget',
+ widgetId: 1,
+ width,
+ height: 100,
+ screenInfo: { screenWidthDp: 400, screenHeightDp: 800, density: 2, densityDpi: 320 },
+ }) as unknown as { light: MockElement; dark: MockElement };
+}
+
+describe('renderActiveAgentsWidget', () => {
+ it('returns distinct light and dark layouts through the theme callback', () => {
+ const props = buildAndroidWidgetProps(snapshotFor([{ status: 'busy' }], 0), {}, translate);
+ const rep = render(props, 250);
+
+ expect(rep.light).toBeDefined();
+ expect(rep.dark).toBeDefined();
+ expect(rep.light).not.toBe(rep.dark);
+ expect(rep.light.props.style?.backgroundColor).toBe('#FFFFFF');
+ expect(rep.dark.props.style?.backgroundColor).toBe('#0B0F19');
+ });
+
+ it('shows only the primary count at a small width', () => {
+ const props = buildAndroidWidgetProps(
+ snapshotFor([{ status: 'question' }, { status: 'busy' }, { status: 'busy' }], 0),
+ {},
+ translate
+ );
+ const rep = render(props, 120);
+ const text = collectText(rep.light);
+
+ expect(text).toEqual(['1 Needs input']);
+ });
+
+ it('shows every non-zero count and the Open agents affordance at a wide width', () => {
+ const props = buildAndroidWidgetProps(
+ snapshotFor([{ status: 'question' }, { status: 'busy' }], 0),
+ {},
+ translate
+ );
+ const rep = render(props, 250);
+ const text = collectText(rep.light);
+
+ expect(text).toEqual(['1 Needs input', '1 Running', 'Open agents']);
+ });
+
+ it.each([
+ { width: 120, visibleText: ['2 Needs input'] },
+ {
+ width: 250,
+ visibleText: [
+ '2 Needs input',
+ '3 Reconnecting',
+ '4 Running',
+ 'Updates delayed',
+ 'Open agents',
+ ],
+ },
+ ])(
+ 'speaks stale numeric counts and keeps the deep link at width $width',
+ ({ width, visibleText }) => {
+ const props = buildAndroidWidgetProps(
+ {
+ ...snapshotFor([], 0, 'stale'),
+ needsInput: 2,
+ reconnecting: 3,
+ running: 4,
+ },
+ {},
+ translate
+ );
+ const rep = render(props, width);
+
+ for (const surface of [rep.light, rep.dark]) {
+ expect(surface.props.accessibilityLabel).toBe(
+ 'Updates delayed, 2 Needs input, 3 Reconnecting, 4 Running, Open agents'
+ );
+ expect(collectText(surface)).toEqual(visibleText);
+ expect(surface.props.clickAction).toBe('OPEN_URI');
+ expect(surface.props.clickActionData).toEqual({ uri: 'kiloapp:///cloud/sessions' });
+ }
+ }
+ );
+
+ it('hides counts and shows expired copy for an expired snapshot', () => {
+ const props = buildAndroidWidgetProps(
+ {
+ ...snapshotFor([{ status: 'busy' }], 0),
+ status: 'expired',
+ running: 0,
+ needsInput: 0,
+ reconnecting: 0,
+ },
+ {},
+ translate
+ );
+ const rep = render(props, 250);
+ const text = collectText(rep.light);
+
+ expect(text).toEqual(['Status expired']);
+ });
+
+ it('labels the whole widget with the Open agents deep-link click action', () => {
+ const props = buildAndroidWidgetProps(snapshotFor([{ status: 'busy' }], 0), {}, translate);
+ const rep = render(props, 250);
+
+ expect(rep.light.props.clickAction).toBe('OPEN_URI');
+ expect(rep.light.props.clickActionData).toEqual({ uri: 'kiloapp:///cloud/sessions' });
+ expect(rep.dark.props.clickAction).toBe('OPEN_URI');
+ expect(rep.dark.props.clickActionData).toEqual({ uri: 'kiloapp:///cloud/sessions' });
+ });
+});
diff --git a/apps/mobile/src/glanceable-android/active-agents-widget.tsx b/apps/mobile/src/glanceable-android/active-agents-widget.tsx
new file mode 100644
index 0000000000..5de7dd7766
--- /dev/null
+++ b/apps/mobile/src/glanceable-android/active-agents-widget.tsx
@@ -0,0 +1,129 @@
+/* eslint-disable react-native/no-inline-styles -- react-native-android-widget primitives take style objects; NativeWind className is unavailable in the widget host */
+
+'use no memo';
+
+import {
+ FlexWidget,
+ type HexColor,
+ TextWidget,
+ type WidgetInfo,
+ type WidgetRepresentation,
+} from 'react-native-android-widget';
+
+import { type AndroidWidgetProps } from './widget-props';
+
+export const WIDGET_NAME = 'ActiveAgentsWidget';
+
+/** Below this width (dp) the widget shows only the primary count. */
+const COMPACT_MAX_WIDTH_DP = 150;
+
+type Palette = { background: HexColor; primary: HexColor; muted: HexColor };
+
+const LIGHT: Palette = {
+ background: '#FFFFFF',
+ primary: '#111827',
+ muted: '#6B7280',
+};
+
+const DARK: Palette = {
+ background: '#0B0F19',
+ primary: '#F9FAFB',
+ muted: '#9CA3AF',
+};
+
+// This function is evaluated only through `renderActiveAgentsWidget` and the
+// library's `buildWidgetTree`. Everything it references is explicit so the
+// React Compiler is disabled ("use no memo") and the widget host can re-evaluate
+// the source. Translated copy arrives through `props`; the English fallbacks
+// below only render while the gallery placeholder has no snapshot props.
+
+function isCompact(info: WidgetInfo): boolean {
+ return info.width < COMPACT_MAX_WIDTH_DP;
+}
+
+function compactText(props: AndroidWidgetProps): string {
+ if (props.primaryLabel === null) {
+ return props.statusLine ?? '';
+ }
+ return `${props.primaryCount} ${props.primaryLabel}`;
+}
+
+function countRows(props: AndroidWidgetProps, color: HexColor) {
+ return props.countLines.map(line => (
+
+ ));
+}
+
+/** Compact widths show the primary count; wider cells show every non-zero count. */
+function renderPrimaryArea(props: AndroidWidgetProps, palette: Palette, compact: boolean) {
+ if (compact) {
+ return (
+
+ );
+ }
+ if (props.countLines.length === 0) {
+ return null;
+ }
+ return countRows(props, palette.primary);
+}
+
+function renderSurface(props: AndroidWidgetProps, palette: Palette, compact: boolean) {
+ return (
+
+
+ {renderPrimaryArea(props, palette, compact)}
+ {!compact && props.statusLine !== null ? (
+
+ ) : null}
+
+ {!compact && props.showOpenAgents ? (
+
+ ) : null}
+
+ );
+}
+
+/**
+ * Distinct light and dark layouts through the library's theme callback. Narrow
+ * widths show only the primary count; wider cells show every non-zero count.
+ */
+export function renderActiveAgentsWidget(
+ props: AndroidWidgetProps,
+ info: WidgetInfo
+): WidgetRepresentation {
+ const compact = isCompact(info);
+ return {
+ light: renderSurface(props, LIGHT, compact),
+ dark: renderSurface(props, DARK, compact),
+ };
+}
diff --git a/apps/mobile/src/glanceable-android/android-sink.test.ts b/apps/mobile/src/glanceable-android/android-sink.test.ts
new file mode 100644
index 0000000000..cc46007c49
--- /dev/null
+++ b/apps/mobile/src/glanceable-android/android-sink.test.ts
@@ -0,0 +1,538 @@
+/* eslint-disable max-lines -- one cohesive sink suite sharing the native + widget mock harness */
+import {
+ buildGlanceableSnapshot,
+ type GlanceableAgentsSnapshot,
+} from '@kilocode/app-shared/glanceable-agents-snapshot';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { i18n } from '@/i18n';
+
+import {
+ _resetAndroidSinkForTests,
+ androidSink,
+ getCurrentWidgetProps,
+ handleAppStateActive,
+} from './android-sink';
+import { _setPermissionReaderForTests, type NotificationPermissionStatus } from './permission';
+import { _resetAndroidPermissionAlertForTests } from './permission-alert';
+
+const mocks = vi.hoisted(() => {
+ let notification: {
+ title: string;
+ text: string;
+ compactText: string | null;
+ promotion: boolean;
+ } | null = null;
+
+ // Capture the requested bridge timeout, not Android's alarm cancellation behavior.
+ let notificationDeadline: number | null = null;
+ let widgetSnapshot: string | null = null;
+ let widgetDeadline = 0;
+
+ // eslint-disable-next-line max-params -- the fake models the native bridge's timeout argument
+ function post(
+ title: string,
+ text: string,
+ compactText: string | null,
+ promotion: boolean,
+ timeoutMs = 0
+ ): void {
+ notification = { title, text, compactText, promotion };
+ notificationDeadline = timeoutMs > 0 ? Date.now() + timeoutMs : null;
+ }
+
+ return {
+ native: {
+ isPromotionCapable: vi.fn(() => true),
+ start: vi.fn(post),
+ update: vi.fn(post),
+ end: vi.fn(() => {
+ notification = null;
+ notificationDeadline = null;
+ }),
+ setWidgetSnapshot: vi.fn((snapshot: string, deadline: number) => {
+ widgetSnapshot = snapshot;
+ widgetDeadline = deadline;
+ }),
+ getWidgetSnapshot: () => widgetSnapshot,
+ },
+ getNotification: () => notification,
+ getRequestedNotificationDeadline: () => notificationDeadline,
+ getWidgetDeadline: () => widgetDeadline,
+ requestWidgetUpdate: vi.fn(),
+ alert: vi.fn(),
+ };
+});
+
+vi.mock('expo', () => ({
+ requireOptionalNativeModule: () => mocks.native,
+}));
+
+// permission-alert statically imports react-native; stub it so the pure
+// node test graph never parses react-native's Flow sources.
+vi.mock('react-native', () => ({
+ Alert: { alert: (...args: unknown[]) => mocks.alert(...args) },
+ Linking: { openSettings: (): void => undefined },
+}));
+
+// The sink imports the widget layout, whose primitives are unreachable under
+// vitest; stub them so only the sink logic runs.
+vi.mock('react-native-android-widget', () => ({
+ FlexWidget: () => null,
+ TextWidget: () => null,
+ requestWidgetUpdate: (...args: unknown[]) => mocks.requestWidgetUpdate(...args),
+}));
+
+const NOW = 1_750_000_000_000;
+const CTX = { organizationId: null };
+
+function snapshotFor(
+ sessions: { status: string }[],
+ revision = 0,
+ status?: GlanceableAgentsSnapshot['status']
+): GlanceableAgentsSnapshot {
+ return buildGlanceableSnapshot({
+ sessions,
+ userId: 'u1',
+ organizationId: null,
+ now: NOW,
+ previousRevision: revision,
+ ...(status === undefined ? {} : { status }),
+ });
+}
+
+const MIXED = {
+ ...snapshotFor([], 0, 'happy'),
+ needsInput: 2,
+ reconnecting: 3,
+ running: 4,
+};
+
+async function flushAsync(): Promise {
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+}
+
+/** A permission reader whose resolution the test controls by hand. */
+function deferredPermission(): {
+ promise: Promise;
+ resolve: (status: NotificationPermissionStatus) => void;
+} {
+ let storedResolve: ((status: NotificationPermissionStatus) => void) | undefined = undefined;
+ const promise = new Promise(resolve => {
+ storedResolve = resolve;
+ });
+ return {
+ promise,
+ resolve: status => {
+ storedResolve?.(status);
+ },
+ };
+}
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(NOW);
+ mocks.native.setWidgetSnapshot('', 0);
+ _resetAndroidSinkForTests();
+ _resetAndroidPermissionAlertForTests();
+ // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules
+ _setPermissionReaderForTests(() => Promise.resolve('granted'));
+ mocks.native.isPromotionCapable.mockReturnValue(true);
+ mocks.native.end();
+ mocks.native.start.mockClear();
+ mocks.native.update.mockClear();
+ mocks.native.end.mockClear();
+ mocks.requestWidgetUpdate.mockClear();
+ mocks.alert.mockClear();
+});
+
+afterEach(() => {
+ _setPermissionReaderForTests(null);
+ vi.useRealTimers();
+});
+
+describe('androidSink start and update', () => {
+ it('forwards the ranked compact number and all counts on start and update', async () => {
+ androidSink.startOrUpdate(MIXED, CTX);
+ await flushAsync();
+ expect(mocks.getNotification()).toEqual({
+ title: 'Active agents',
+ text: '2 Needs input, 3 Reconnecting, 4 Running',
+ compactText: '2',
+ promotion: true,
+ });
+
+ androidSink.startOrUpdate({ ...MIXED, revision: 2, needsInput: 0 }, CTX);
+ await flushAsync();
+ expect(mocks.getNotification()).toEqual({
+ title: 'Active agents',
+ text: '3 Reconnecting, 4 Running',
+ compactText: '3',
+ promotion: true,
+ });
+
+ androidSink.startOrUpdate({ ...MIXED, revision: 3, needsInput: 0, reconnecting: 0 }, CTX);
+ await flushAsync();
+ expect(mocks.getNotification()).toEqual({
+ title: 'Active agents',
+ text: '4 Running',
+ compactText: '4',
+ promotion: true,
+ });
+ expect(mocks.native.start).toHaveBeenCalledTimes(1);
+ expect(mocks.native.update).toHaveBeenCalledTimes(2);
+ });
+
+ it('keeps the full summary when the device cannot promote', async () => {
+ mocks.native.isPromotionCapable.mockReturnValue(false);
+ androidSink.startOrUpdate(MIXED, CTX);
+ await flushAsync();
+
+ expect(mocks.getNotification()).toEqual({
+ title: 'Active agents',
+ text: '2 Needs input, 3 Reconnecting, 4 Running',
+ compactText: '2',
+ promotion: false,
+ });
+ });
+
+ it('forwards compact text when concurrent permission checks start then update', async () => {
+ const deferred = deferredPermission();
+ // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules
+ _setPermissionReaderForTests(() => deferred.promise);
+ androidSink.startOrUpdate(MIXED, CTX);
+ androidSink.startOrUpdate({ ...MIXED, revision: 2, needsInput: 0 }, CTX);
+ deferred.resolve('granted');
+ await flushAsync();
+
+ expect(mocks.getNotification()).toEqual({
+ title: 'Active agents',
+ text: '3 Reconnecting, 4 Running',
+ compactText: '3',
+ promotion: true,
+ });
+ expect(mocks.native.start).toHaveBeenCalledTimes(1);
+ expect(mocks.native.update).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not start ongoing when notification permission is denied', async () => {
+ // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules
+ _setPermissionReaderForTests(() => Promise.resolve('denied'));
+ androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 0), CTX);
+ await flushAsync();
+
+ expect(mocks.native.start).not.toHaveBeenCalled();
+ expect(mocks.native.update).not.toHaveBeenCalled();
+ });
+
+ it('does not post after endImmediate during an in-flight permission check', async () => {
+ const deferred = deferredPermission();
+ // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules
+ _setPermissionReaderForTests(() => deferred.promise);
+
+ androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 0), CTX);
+ await flushAsync();
+ androidSink.endImmediate();
+ deferred.resolve('granted');
+ await flushAsync();
+
+ expect(mocks.native.start).not.toHaveBeenCalled();
+ expect(mocks.native.update).not.toHaveBeenCalled();
+ });
+
+ it('starts once permission is later granted for eligible work', async () => {
+ // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules
+ _setPermissionReaderForTests(() => Promise.resolve('denied'));
+ androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 0), CTX);
+ await flushAsync();
+ expect(mocks.native.start).not.toHaveBeenCalled();
+
+ // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules
+ _setPermissionReaderForTests(() => Promise.resolve('granted'));
+ androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 1), CTX);
+ await flushAsync();
+ expect(mocks.native.start).toHaveBeenCalledTimes(1);
+ });
+
+ it('discards an older revision without overwriting the latest', async () => {
+ androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 4), CTX);
+ await flushAsync();
+ androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 5), CTX);
+ await flushAsync();
+ expect(mocks.native.start).toHaveBeenCalledTimes(1);
+ expect(mocks.native.update).toHaveBeenCalledTimes(1);
+
+ androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 4), CTX);
+ await flushAsync();
+ expect(mocks.native.update).toHaveBeenCalledTimes(1);
+ });
+
+ it.each(['waiting', 'empty', 'expired', 'signed_out', 'privacy'] as const)(
+ 'never starts for a %s snapshot without eligible work',
+ async status => {
+ androidSink.startOrUpdate(snapshotFor([], 0, status), CTX);
+ await flushAsync();
+
+ expect(mocks.getNotification()).toBeNull();
+ expect(mocks.native.start).not.toHaveBeenCalled();
+ expect(mocks.native.update).not.toHaveBeenCalled();
+ }
+ );
+});
+
+describe('androidSink widget publish and end', () => {
+ it('publishes the widget snapshot on every publish', () => {
+ androidSink.publish(snapshotFor([{ status: 'busy' }], 0));
+
+ expect(mocks.requestWidgetUpdate).toHaveBeenCalledTimes(1);
+ expect(mocks.requestWidgetUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({ widgetName: 'ActiveAgentsWidget' })
+ );
+ expect(getCurrentWidgetProps()?.statusLine).toBeNull();
+ expect(getCurrentWidgetProps()?.primaryCount).toBe(1);
+ });
+
+ it('publishes the stale warning and retained counts through the native bridge', async () => {
+ androidSink.startOrUpdate(MIXED, CTX);
+ await flushAsync();
+ androidSink.publish({ ...MIXED, revision: 2, status: 'stale' });
+
+ const notification = mocks.getNotification();
+ expect(notification?.text).toContain(i18n.t('glanceable.stale'));
+ expect(notification?.text).toContain('2 Needs input, 3 Reconnecting, 4 Running');
+ expect(notification?.compactText).toBe('2');
+ expect(getCurrentWidgetProps()?.accessibilityLabel).toContain(
+ '2 Needs input, 3 Reconnecting, 4 Running, Open agents'
+ );
+ });
+
+ it.each([
+ ['privacy', 'Agents hidden'],
+ ['signed_out', 'Sign in to see agents'],
+ ] as const)('cancels both deadlines immediately for %s', async (status, copy) => {
+ androidSink.publish(MIXED);
+ androidSink.startOrUpdate(MIXED, CTX);
+ await flushAsync();
+ androidSink.publish(snapshotFor([], 1, 'empty'));
+ expect(mocks.getRequestedNotificationDeadline()).toBe(NOW + 8000);
+
+ androidSink.publish(snapshotFor([], 2, status));
+ expect(mocks.getNotification()).toBeNull();
+ expect(mocks.getRequestedNotificationDeadline()).toBeNull();
+ expect(mocks.getWidgetDeadline()).toBe(0);
+ vi.setSystemTime(NOW + 28_800_001);
+ expect(getCurrentWidgetProps()?.statusLine).toBe(copy);
+ expect(getCurrentWidgetProps()?.countLines).toEqual([]);
+ androidSink.endImmediate();
+ expect(mocks.getNotification()).toBeNull();
+ });
+
+ it('does not update the notification from publish before it has started', () => {
+ androidSink.publish(snapshotFor([], 1, 'empty'));
+
+ expect(mocks.native.start).not.toHaveBeenCalled();
+ expect(mocks.native.update).not.toHaveBeenCalled();
+ });
+
+ it('dismisses a leftover native notification for an ineligible snapshot', () => {
+ androidSink.publish(snapshotFor([], 1, 'empty'));
+
+ expect(mocks.native.end).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps the widget truthful after end', () => {
+ androidSink.publish(snapshotFor([{ status: 'busy' }], 0));
+ expect(getCurrentWidgetProps()).not.toBeNull();
+
+ androidSink.endImmediate();
+ expect(mocks.native.end).toHaveBeenCalledTimes(1);
+ expect(getCurrentWidgetProps()).not.toBeNull();
+ expect(getCurrentWidgetProps()?.primaryCount).toBe(1);
+ });
+
+ it.each(['happy', 'stale'] as const)(
+ 'hands Android the original %s expiry without a JS timer',
+ status => {
+ const snapshot = snapshotFor([{ status: 'busy' }], 0, status);
+ androidSink.publish(snapshot);
+ expect(mocks.getWidgetDeadline()).toBe(NOW + 28_800_000);
+ expect(vi.getTimerCount()).toBe(0);
+
+ vi.setSystemTime(NOW + 28_799_999);
+ expect(getCurrentWidgetProps()?.primaryCount).toBe(1);
+ vi.setSystemTime(NOW + 28_800_000);
+ expect(getCurrentWidgetProps()?.statusLine).toBe('Status expired');
+ expect(getCurrentWidgetProps()?.countLines).toEqual([]);
+ expect(getCurrentWidgetProps()?.primaryCount).toBe(0);
+ expect(getCurrentWidgetProps()?.showOpenAgents).toBe(false);
+ }
+ );
+
+ it('replaces the old widget deadline and keeps it when only the notification ends', () => {
+ androidSink.publish(MIXED);
+ const newer = buildGlanceableSnapshot({
+ sessions: [{ status: 'busy' }],
+ userId: 'u1',
+ organizationId: null,
+ now: NOW + 60_000,
+ previousRevision: MIXED.revision,
+ });
+ androidSink.publish(newer);
+ androidSink.endImmediate();
+ expect(mocks.getWidgetDeadline()).toBe(NOW + 28_860_000);
+ vi.setSystemTime(NOW + 28_800_000);
+ expect(getCurrentWidgetProps()?.primaryCount).toBe(1);
+ expect(mocks.getNotification()).toBeNull();
+ });
+
+ it('does not extend the successful deadline when stale data is published later', () => {
+ androidSink.publish(MIXED);
+ vi.setSystemTime(NOW + 60_000);
+ androidSink.publish({ ...MIXED, status: 'stale', revision: 2 });
+ expect(mocks.getWidgetDeadline()).toBe(NOW + 28_800_000);
+ expect(getCurrentWidgetProps()?.primaryCount).toBe(2);
+ vi.setSystemTime(NOW + 28_800_000);
+ expect(getCurrentWidgetProps()?.countLines).toEqual([]);
+ });
+
+ it('passes an eight-second terminal timeout that survives clearing JS state', async () => {
+ androidSink.startOrUpdate(MIXED, CTX);
+ await flushAsync();
+ androidSink.publish(snapshotFor([], 1, 'empty'));
+ _resetAndroidSinkForTests();
+
+ expect(mocks.getNotification()).toMatchObject({
+ text: 'No work in progress',
+ compactText: null,
+ });
+ expect(mocks.getRequestedNotificationDeadline()).toBe(NOW + 8000);
+ expect(mocks.getWidgetDeadline()).toBe(0);
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it('keeps the first terminal deadline across later empty updates', async () => {
+ androidSink.startOrUpdate(MIXED, CTX);
+ await flushAsync();
+ androidSink.publish(snapshotFor([], 1, 'empty'));
+ vi.setSystemTime(NOW + 4000);
+ androidSink.publish(snapshotFor([], 2, 'empty'));
+ expect(mocks.getRequestedNotificationDeadline()).toBe(NOW + 8000);
+ vi.setSystemTime(NOW + 8000);
+ androidSink.publish(snapshotFor([], 3, 'empty'));
+ expect(mocks.getNotification()).toBeNull();
+ });
+
+ it('allows the same terminal revision to retry after a native post rejects', async () => {
+ androidSink.startOrUpdate(MIXED, CTX);
+ await flushAsync();
+ const empty = snapshotFor([], 1, 'empty');
+ mocks.native.update.mockImplementationOnce(() => {
+ throw new Error('Cannot persist the active agents notification timeout');
+ });
+
+ expect(() => {
+ androidSink.publish(empty);
+ }).toThrow('Cannot persist the active agents notification timeout');
+ expect(mocks.getNotification()?.text).toBe('2 Needs input, 3 Reconnecting, 4 Running');
+ expect(mocks.getRequestedNotificationDeadline()).toBeNull();
+
+ vi.setSystemTime(NOW + 3000);
+ androidSink.publish(empty);
+ expect(mocks.getNotification()).toMatchObject({
+ text: 'No work in progress',
+ compactText: null,
+ });
+ expect(mocks.getRequestedNotificationDeadline()).toBe(NOW + 8000);
+ });
+
+ it.each(['publish', 'startOrUpdate', 'JS reload'] as const)(
+ 'requests untimed eligible work through %s after terminal copy',
+ async method => {
+ androidSink.publish(MIXED);
+ androidSink.startOrUpdate(MIXED, CTX);
+ await flushAsync();
+ androidSink.publish(snapshotFor([], 1, 'empty'));
+ expect(mocks.getRequestedNotificationDeadline()).toBe(NOW + 8000);
+ vi.setSystemTime(NOW + 4000);
+ if (method === 'JS reload') {
+ _resetAndroidSinkForTests();
+ }
+ const newer = { ...MIXED, revision: 3 };
+ if (method === 'publish') {
+ androidSink.publish(newer);
+ } else {
+ androidSink.startOrUpdate(newer, CTX);
+ await flushAsync();
+ }
+
+ expect(mocks.getRequestedNotificationDeadline()).toBeNull();
+ expect(mocks.getNotification()).toMatchObject({
+ text: '2 Needs input, 3 Reconnecting, 4 Running',
+ compactText: '2',
+ });
+ expect(mocks.getWidgetDeadline()).toBe(method === 'publish' ? NOW + 28_800_000 : 0);
+ }
+ );
+
+ it('rejects a pending start when its successful snapshot expires', async () => {
+ const deferred = deferredPermission();
+ // eslint-disable-next-line promise-function-async, prefer-await-to-then -- the test controls permission resolution
+ _setPermissionReaderForTests(() => deferred.promise);
+ androidSink.startOrUpdate(MIXED, CTX);
+ vi.setSystemTime(NOW + 28_800_000);
+ deferred.resolve('granted');
+ await flushAsync();
+
+ expect(mocks.getNotification()).toBeNull();
+ });
+
+ it('rejects a pending start after an empty snapshot cancels the work', async () => {
+ const deferred = deferredPermission();
+ // eslint-disable-next-line promise-function-async, prefer-await-to-then -- the test controls permission resolution
+ _setPermissionReaderForTests(() => deferred.promise);
+ androidSink.startOrUpdate(MIXED, CTX);
+ androidSink.publish(snapshotFor([], 1, 'empty'));
+ deferred.resolve('granted');
+ await flushAsync();
+
+ expect(mocks.getNotification()).toBeNull();
+ expect(mocks.getWidgetDeadline()).toBe(0);
+ });
+});
+
+describe('handleAppStateActive permission alert', () => {
+ it('shows the permission alert once for denied work when the app foregrounds', async () => {
+ // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules
+ _setPermissionReaderForTests(() => Promise.resolve('denied'));
+ androidSink.startOrUpdate(snapshotFor([{ status: 'busy' }], 0), CTX);
+ await flushAsync();
+ expect(mocks.native.start).not.toHaveBeenCalled();
+
+ await handleAppStateActive();
+ expect(mocks.alert).toHaveBeenCalledTimes(1);
+
+ await handleAppStateActive();
+ expect(mocks.alert).toHaveBeenCalledTimes(1);
+ });
+
+ it('forwards the pending compact number when permission is granted on foreground', async () => {
+ // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules
+ _setPermissionReaderForTests(() => Promise.resolve('denied'));
+ androidSink.startOrUpdate(MIXED, CTX);
+ await flushAsync();
+ expect(mocks.getNotification()).toBeNull();
+
+ // eslint-disable-next-line promise-function-async, prefer-await-to-then -- tension between lint rules
+ _setPermissionReaderForTests(() => Promise.resolve('granted'));
+ await handleAppStateActive();
+ expect(mocks.getNotification()).toEqual({
+ title: 'Active agents',
+ text: '2 Needs input, 3 Reconnecting, 4 Running',
+ compactText: '2',
+ promotion: true,
+ });
+ expect(mocks.alert).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/mobile/src/glanceable-android/android-sink.ts b/apps/mobile/src/glanceable-android/android-sink.ts
new file mode 100644
index 0000000000..652ed29d64
--- /dev/null
+++ b/apps/mobile/src/glanceable-android/android-sink.ts
@@ -0,0 +1,216 @@
+import {
+ GLANCEABLE_TERMINAL_MS,
+ type GlanceableAgentsSnapshot,
+ isEligibleGlanceableWork,
+} from '@kilocode/app-shared/glanceable-agents-snapshot';
+import { requestWidgetUpdate } from 'react-native-android-widget';
+
+import { i18n } from '@/i18n';
+import { type GlanceableSink, type GlanceableSinkContext } from '@/lib/glanceable/sink-registry';
+
+import { renderActiveAgentsWidget, WIDGET_NAME } from './active-agents-widget';
+import {
+ end as endLiveUpdate,
+ setWidgetSnapshot,
+ start as startLiveUpdate,
+ update as updateLiveUpdate,
+} from './live-update';
+import { isNotificationPermissionGranted } from './permission';
+import { showAndroidPermissionAlertOnce } from './permission-alert';
+import {
+ type AndroidWidgetProps,
+ buildCompactNotificationText,
+ buildCurrentWidgetProps,
+ buildOngoingNotificationText,
+} from './widget-props';
+
+/**
+ * Android owns the widget expiry and notification timeout. The sink supplies
+ * translated copy, persists the latest snapshot, and fences pending starts.
+ * Ending the ongoing notification never cancels a still-eligible widget expiry.
+ */
+const NOTIFICATION_TITLE_KEY = 'glanceable.channelName';
+
+function translate(key: string): string {
+ return i18n.t(key);
+}
+
+let lastWidgetSnapshot: GlanceableAgentsSnapshot | null = null;
+let notificationActive = false;
+let revision = 0;
+let pending: { snapshot: GlanceableAgentsSnapshot; ctx: GlanceableSinkContext } | null = null;
+let startEpoch = 0;
+let terminalExpiresAt: number | null = null;
+
+/** A delayed render must check the current snapshot and its deadline, not cached props. */
+export function getCurrentWidgetProps(): AndroidWidgetProps | null {
+ return lastWidgetSnapshot === null
+ ? null
+ : buildCurrentWidgetProps(lastWidgetSnapshot, translate);
+}
+
+function renderWidgetNow(props: AndroidWidgetProps): void {
+ void requestWidgetUpdate({
+ widgetName: WIDGET_NAME,
+ renderWidget: info => renderActiveAgentsWidget(getCurrentWidgetProps() ?? props, info),
+ });
+}
+
+function hasCurrentWork(snapshot: GlanceableAgentsSnapshot): boolean {
+ return (
+ (snapshot.status === 'happy' || snapshot.status === 'stale') &&
+ isEligibleGlanceableWork(snapshot) &&
+ Date.parse(snapshot.expiresAt) > Date.now()
+ );
+}
+
+function endNotification(): void {
+ endLiveUpdate();
+ notificationActive = false;
+ revision = 0;
+ pending = null;
+ startEpoch += 1;
+ terminalExpiresAt = null;
+}
+
+/**
+ * Start the ongoing notification once permission is granted. Permission-denied
+ * emits record the latest eligible snapshot so a later gesture can restart it.
+ */
+async function tryStartOrUpdate(
+ snapshot: GlanceableAgentsSnapshot,
+ ctx: GlanceableSinkContext
+): Promise {
+ if (!hasCurrentWork(snapshot)) {
+ pending = null;
+ return;
+ }
+ if (notificationActive && snapshot.revision <= revision) {
+ return;
+ }
+ const title = translate(NOTIFICATION_TITLE_KEY);
+ const text = buildOngoingNotificationText(snapshot, {}, translate);
+ const compactText = buildCompactNotificationText(snapshot, {});
+
+ if (notificationActive) {
+ updateLiveUpdate(title, text, compactText);
+ terminalExpiresAt = null;
+ revision = snapshot.revision;
+ return;
+ }
+
+ const epoch = startEpoch;
+ const granted = await isNotificationPermissionGranted();
+ if (epoch !== startEpoch || !hasCurrentWork(snapshot)) {
+ return;
+ }
+ if (granted) {
+ // eslint-disable-next-line typescript-eslint/no-unnecessary-condition -- a concurrent start/retry can set notificationActive while awaiting permission
+ if (notificationActive) {
+ if (snapshot.revision > revision) {
+ updateLiveUpdate(title, text, compactText);
+ terminalExpiresAt = null;
+ revision = snapshot.revision;
+ }
+ return;
+ }
+ startLiveUpdate(title, text, compactText);
+ notificationActive = true;
+ terminalExpiresAt = null;
+ revision = snapshot.revision;
+ pending = null;
+ return;
+ }
+ pending = { snapshot, ctx };
+}
+
+/** Retry a pending start after permission turns granted. Caller owns the check. */
+function retryPendingStart(): void {
+ const p = pending;
+ if (p === null || notificationActive || !hasCurrentWork(p.snapshot)) {
+ return;
+ }
+ const title = translate(NOTIFICATION_TITLE_KEY);
+ startLiveUpdate(
+ title,
+ buildOngoingNotificationText(p.snapshot, {}, translate),
+ buildCompactNotificationText(p.snapshot, {})
+ );
+ notificationActive = true;
+ terminalExpiresAt = null;
+ revision = p.snapshot.revision;
+ pending = null;
+}
+
+/**
+ * App foreground: when the ongoing cannot start (denied) and work is pending,
+ * show the Open Settings alert once. When permission is granted, start at once.
+ * The alert needs a foreground Activity, so this never runs on the headless path.
+ */
+export async function handleAppStateActive(): Promise {
+ if (pending === null) {
+ return;
+ }
+ if (await isNotificationPermissionGranted()) {
+ retryPendingStart();
+ return;
+ }
+ showAndroidPermissionAlertOnce();
+}
+
+export const androidSink: GlanceableSink = {
+ publish(snapshot) {
+ lastWidgetSnapshot = snapshot;
+ setWidgetSnapshot(snapshot);
+ const props = buildCurrentWidgetProps(snapshot, translate);
+ renderWidgetNow(props);
+ const eligible = hasCurrentWork(snapshot);
+ if (eligible) {
+ terminalExpiresAt = null;
+ } else {
+ pending = null;
+ startEpoch += 1;
+ if (
+ snapshot.status === 'privacy' ||
+ snapshot.status === 'signed_out' ||
+ !notificationActive
+ ) {
+ // Also dismiss the fixed native id after a JS restart, without starting an empty ongoing.
+ endNotification();
+ return;
+ }
+ terminalExpiresAt ??= Date.now() + GLANCEABLE_TERMINAL_MS;
+ if (terminalExpiresAt <= Date.now()) {
+ endNotification();
+ return;
+ }
+ }
+ if (notificationActive && snapshot.revision > revision) {
+ updateLiveUpdate(
+ translate(NOTIFICATION_TITLE_KEY),
+ eligible
+ ? buildOngoingNotificationText(snapshot, {}, translate)
+ : (props.statusLine ?? translate('glanceable.empty')),
+ eligible ? buildCompactNotificationText(snapshot, {}) : null,
+ terminalExpiresAt === null ? 0 : Math.max(1, terminalExpiresAt - Date.now())
+ );
+ revision = snapshot.revision;
+ }
+ },
+
+ startOrUpdate(snapshot, ctx) {
+ void tryStartOrUpdate(snapshot, ctx);
+ },
+
+ endImmediate: endNotification,
+};
+
+/** Test-only: drop JS state without touching Android-owned storage or deadlines. */
+export function _resetAndroidSinkForTests(): void {
+ lastWidgetSnapshot = null;
+ notificationActive = false;
+ revision = 0;
+ pending = null;
+ startEpoch += 1;
+ terminalExpiresAt = null;
+}
diff --git a/apps/mobile/src/glanceable-android/live-update.ts b/apps/mobile/src/glanceable-android/live-update.ts
new file mode 100644
index 0000000000..4859b65ce0
--- /dev/null
+++ b/apps/mobile/src/glanceable-android/live-update.ts
@@ -0,0 +1,81 @@
+import {
+ type GlanceableAgentsSnapshot,
+ glanceableAgentsSnapshotSchema,
+ isEligibleGlanceableWork,
+} from '@kilocode/app-shared/glanceable-agents-snapshot';
+import { requireOptionalNativeModule } from 'expo';
+
+/**
+ * JS wrapper over the local `ActiveAgentsLiveUpdate` native module. The native
+ * side owns the notification id, the channel, and the promotion gate; the JS
+ * side owns the translated copy and the revision guard (see android-sink).
+ */
+
+type LiveUpdateNativeModule = {
+ isPromotionCapable(): boolean;
+ start(title: string, text: string, compactText: string | null, promotion: boolean): void;
+ update(
+ title: string,
+ text: string,
+ compactText: string | null,
+ promotion: boolean,
+ timeoutMs: number
+ ): void;
+ end(): void;
+ setWidgetSnapshot(snapshot: string, expiresAt: number): void;
+ getWidgetSnapshot(): string | null;
+};
+
+const nativeModule = requireOptionalNativeModule('ActiveAgentsLiveUpdate');
+
+/**
+ * API 36.1+ promotion capability: SDK_INT_FULL >= 36_001_000 and
+ * NotificationManager.canPostPromotedNotifications(). Mirrors the native gate.
+ */
+function isPromotionCapable(): boolean {
+ return nativeModule?.isPromotionCapable() ?? false;
+}
+
+export function start(title: string, text: string, compactText: string | null): void {
+ nativeModule?.start(title, text, compactText, isPromotionCapable());
+}
+
+// eslint-disable-next-line max-params -- translated bridge fields plus the native terminal timeout
+export function update(
+ title: string,
+ text: string,
+ compactText: string | null,
+ timeoutMs = 0
+): void {
+ nativeModule?.update(title, text, compactText, isPromotionCapable(), timeoutMs);
+}
+
+export function end(): void {
+ nativeModule?.end();
+}
+
+/** Persist before rendering; the native receiver owns the single future expiry. */
+export function setWidgetSnapshot(snapshot: GlanceableAgentsSnapshot): void {
+ const expiresAt = Date.parse(snapshot.expiresAt);
+ const needsExpiry =
+ (snapshot.status === 'happy' || snapshot.status === 'stale') &&
+ isEligibleGlanceableWork(snapshot) &&
+ Number.isFinite(expiresAt) &&
+ expiresAt > Date.now();
+ nativeModule?.setWidgetSnapshot(JSON.stringify(snapshot), needsExpiry ? expiresAt : 0);
+}
+
+/** Native storage is authoritative even when an obsolete headless task was already queued. */
+export function getStoredWidgetSnapshot(): GlanceableAgentsSnapshot | null {
+ const raw = nativeModule?.getWidgetSnapshot();
+ if (raw == null) {
+ return null;
+ }
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ const result = glanceableAgentsSnapshotSchema.safeParse(parsed);
+ return result.success ? result.data : null;
+ } catch {
+ return null;
+ }
+}
diff --git a/apps/mobile/src/glanceable-android/permission-alert.ts b/apps/mobile/src/glanceable-android/permission-alert.ts
new file mode 100644
index 0000000000..be5e7aae76
--- /dev/null
+++ b/apps/mobile/src/glanceable-android/permission-alert.ts
@@ -0,0 +1,27 @@
+import { Alert, Linking } from 'react-native';
+
+import { i18n } from '@/i18n';
+
+/**
+ * The one in-app Open Settings alert for a denied Android notification
+ * permission. Shown at most once per missing permission, and only from a widget
+ * tap that cannot start the ongoing notification — never on publisher start.
+ */
+
+let alertShown = false;
+
+export function showAndroidPermissionAlertOnce(): void {
+ if (alertShown) {
+ return;
+ }
+ alertShown = true;
+ Alert.alert(i18n.t('notifications.disabledTitle'), i18n.t('notifications.disabledMessage'), [
+ { text: i18n.t('common.cancel'), style: 'cancel' },
+ { text: i18n.t('common.openSettings'), onPress: () => void Linking.openSettings() },
+ ]);
+}
+
+/** Test-only: drop the once-per-missing-permission latch between cases. */
+export function _resetAndroidPermissionAlertForTests(): void {
+ alertShown = false;
+}
diff --git a/apps/mobile/src/glanceable-android/permission.ts b/apps/mobile/src/glanceable-android/permission.ts
new file mode 100644
index 0000000000..c0f266fc79
--- /dev/null
+++ b/apps/mobile/src/glanceable-android/permission.ts
@@ -0,0 +1,33 @@
+/**
+ * Notification-permission reader for the Android ongoing surface. The default
+ * reads expo-notifications lazily so pure test suites never load React Native;
+ * tests inject a synchronous reader instead.
+ */
+
+export type NotificationPermissionStatus = 'granted' | 'denied' | 'undetermined';
+
+type PermissionReader = () => Promise;
+
+let permissionReader: PermissionReader | null = null;
+
+async function defaultPermissionReader(): Promise {
+ // Lazy require keeps expo-notifications (→ expo-modules-core → RN) out of the
+ // unit-test graph, matching the persist/deep-link-launch pattern.
+ // eslint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires, unicorn/prefer-module -- lazy native load
+ const { getNotificationPermissionStatus } = require('@/lib/notifications') as {
+ getNotificationPermissionStatus: () => Promise;
+ };
+ const status = await getNotificationPermissionStatus();
+ return status;
+}
+
+export async function isNotificationPermissionGranted(): Promise {
+ const reader = permissionReader ?? defaultPermissionReader;
+ const status = await reader();
+ return status === 'granted';
+}
+
+/** Test-only: replace the reader so permission state is controllable per case. */
+export function _setPermissionReaderForTests(reader: PermissionReader | null): void {
+ permissionReader = reader;
+}
diff --git a/apps/mobile/src/glanceable-android/register.test.ts b/apps/mobile/src/glanceable-android/register.test.ts
new file mode 100644
index 0000000000..2978c7896e
--- /dev/null
+++ b/apps/mobile/src/glanceable-android/register.test.ts
@@ -0,0 +1,319 @@
+import {
+ buildGlanceableSnapshot,
+ type GlanceableAgentsSnapshot,
+} from '@kilocode/app-shared/glanceable-agents-snapshot';
+import { isValidElement, type ReactNode } from 'react';
+import { type WidgetRepresentation, type WidgetTaskHandler } from 'react-native-android-widget';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => {
+ let snapshot: string | null = null;
+ let deadline = 0;
+ return {
+ registerWidgetTaskHandler: vi.fn<(handler: WidgetTaskHandler) => void>(),
+ native: {
+ setWidgetSnapshot: (next: string, expiresAt: number) => {
+ snapshot = next;
+ deadline = expiresAt;
+ },
+ getWidgetSnapshot: () => snapshot,
+ end: vi.fn(),
+ },
+ getDeadline: () => deadline,
+ resetNativeState: () => {
+ snapshot = null;
+ deadline = 0;
+ },
+ };
+});
+
+vi.mock('expo', () => ({ requireOptionalNativeModule: () => mocks.native }));
+vi.mock('react-native', () => ({
+ AppState: { addEventListener: vi.fn() },
+ Alert: { alert: vi.fn() },
+ Linking: { openSettings: vi.fn() },
+}));
+vi.mock('react-native-android-widget', () => ({
+ registerWidgetTaskHandler: mocks.registerWidgetTaskHandler,
+ requestWidgetUpdate: vi.fn().mockResolvedValue(undefined),
+ FlexWidget: () => null,
+ TextWidget: () => null,
+}));
+
+const NOW = 1_750_000_000_000;
+const store = new Map();
+const secureStore = {
+ setItemAsync: vi.fn(async (key: string, value: string) => {
+ store.set(key, value);
+ await Promise.resolve();
+ }),
+ getItemAsync: vi.fn<(key: string) => Promise>(),
+};
+
+function snapshotFor(
+ sessions: { status: string }[] = [
+ { status: 'question' },
+ { status: 'retry' },
+ { status: 'busy' },
+ { status: 'busy' },
+ ],
+ status: GlanceableAgentsSnapshot['status'] = 'happy'
+): GlanceableAgentsSnapshot {
+ return buildGlanceableSnapshot({
+ sessions,
+ status,
+ userId: 'u1',
+ organizationId: null,
+ now: NOW,
+ });
+}
+
+async function registerAfterRestart(snapshot: GlanceableAgentsSnapshot | null) {
+ const persist = await import('@/lib/glanceable/persist');
+ persist._setSecureStoreForTests(secureStore);
+ if (snapshot !== null) {
+ persist.persistGlanceableSink.publish(snapshot);
+ }
+
+ // Keep only native storage across the simulated JS process restart.
+ vi.resetModules();
+ const freshPersist = await import('@/lib/glanceable/persist');
+ freshPersist._setSecureStoreForTests(secureStore);
+ await import('./register');
+ const handler = mocks.registerWidgetTaskHandler.mock.lastCall?.[0];
+ if (handler === undefined) {
+ throw new Error('The widget task handler was not registered');
+ }
+ return handler;
+}
+
+async function runWidgetTask(handler: WidgetTaskHandler, width: number) {
+ const renders: WidgetRepresentation[] = [];
+ await handler({
+ widgetAction: 'WIDGET_UPDATE',
+ widgetInfo: {
+ widgetName: 'ActiveAgentsWidget',
+ widgetId: 1,
+ width,
+ height: 100,
+ screenInfo: { screenWidthDp: 400, screenHeightDp: 800, density: 2, densityDpi: 320 },
+ },
+ renderWidget: widget => {
+ renders.push(widget);
+ },
+ });
+ const [rendered] = renders;
+ if (rendered === undefined || !('light' in rendered)) {
+ throw new Error('The widget task did not render its themed layouts');
+ }
+ return rendered;
+}
+
+function collectText(node: ReactNode): string[] {
+ if (Array.isArray(node)) {
+ return node.flatMap((child: ReactNode) => collectText(child));
+ }
+ if (!isValidElement<{ text?: string; children?: ReactNode }>(node)) {
+ return [];
+ }
+ const text = node.props.text === undefined ? [] : [node.props.text];
+ return [...text, ...collectText(node.props.children)];
+}
+
+beforeEach(() => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+ vi.setSystemTime(NOW);
+ mocks.resetNativeState();
+ store.clear();
+ secureStore.getItemAsync.mockReset().mockImplementation(async key => {
+ await Promise.resolve();
+ return store.get(key) ?? null;
+ });
+});
+
+afterEach(() => {
+ vi.clearAllTimers();
+ vi.useRealTimers();
+});
+
+describe.each([120, 250])('registered widget handler at %d dp', width => {
+ it('restores unexpired persisted counts after a fresh process starts', async () => {
+ const handler = await registerAfterRestart(snapshotFor());
+ const rendered = await runWidgetTask(handler, width);
+ const expected =
+ width === 120
+ ? ['1 Needs input']
+ : ['1 Needs input', '1 Reconnecting', '2 Running', 'Open agents'];
+
+ expect(collectText(rendered.light)).toEqual(expected);
+ expect(collectText(rendered.dark)).toEqual(expected);
+ expect(rendered.light.props).toMatchObject({
+ clickAction: 'OPEN_URI',
+ clickActionData: { uri: 'kiloapp:///cloud/sessions' },
+ });
+ });
+
+ it.each([
+ ['happy', 0],
+ ['happy', 1],
+ ['stale', 0],
+ ['stale', 1],
+ ] as const)('hides %s counts %d ms after expiry', async (status, elapsed) => {
+ const stored = snapshotFor(undefined, status);
+ const handler = await registerAfterRestart(stored);
+ vi.setSystemTime(Date.parse(stored.expiresAt) + elapsed);
+
+ const rendered = await runWidgetTask(handler, width);
+
+ expect(collectText(rendered.light)).toEqual(['Status expired']);
+ expect(collectText(rendered.dark)).toEqual(['Status expired']);
+ });
+
+ it('renders the existing placeholder when no snapshot is persisted', async () => {
+ const handler = await registerAfterRestart(null);
+
+ const rendered = await runWidgetTask(handler, width);
+
+ expect(collectText(rendered.light)).toEqual(['No work in progress']);
+ expect(collectText(rendered.dark)).toEqual(['No work in progress']);
+ });
+
+ it('renders the existing placeholder when native storage cannot be read', async () => {
+ const handler = await registerAfterRestart(snapshotFor());
+ secureStore.getItemAsync.mockRejectedValueOnce(new Error('SecureStore unavailable'));
+
+ const rendered = await runWidgetTask(handler, width);
+
+ expect(collectText(rendered.light)).toEqual(['No work in progress']);
+ expect(collectText(rendered.dark)).toEqual(['No work in progress']);
+ });
+
+ it.each([
+ ['privacy', 'Agents hidden'],
+ ['signed_out', 'Sign in to see agents'],
+ ] as const)('preserves the %s blank even after expiry', async (status, copy) => {
+ const stored = snapshotFor([], status);
+ const handler = await registerAfterRestart(stored);
+ vi.setSystemTime(Date.parse(stored.expiresAt) + 1);
+
+ const rendered = await runWidgetTask(handler, width);
+
+ expect(collectText(rendered.light)).toEqual([copy]);
+ expect(collectText(rendered.dark)).toEqual([copy]);
+ });
+
+ it('prefers newer live widget props to the persisted snapshot', async () => {
+ const stored = snapshotFor();
+ const handler = await registerAfterRestart(stored);
+ const { androidSink } = await import('./android-sink');
+ androidSink.publish({ ...snapshotFor([{ status: 'busy' }]), revision: stored.revision + 1 });
+
+ const rendered = await runWidgetTask(handler, width);
+ const expected = width === 120 ? ['1 Running'] : ['1 Running', 'Open agents'];
+
+ expect(collectText(rendered.light)).toEqual(expected);
+ expect(collectText(rendered.dark)).toEqual(expected);
+ });
+
+ it('re-reads native state when an old expiry task reaches newer work', async () => {
+ const old = snapshotFor();
+ const handler = await registerAfterRestart(old);
+ const { androidSink } = await import('./android-sink');
+ androidSink.publish(old);
+ const newer = buildGlanceableSnapshot({
+ sessions: [{ status: 'busy' }],
+ userId: 'u2',
+ organizationId: null,
+ now: NOW + 60_000,
+ previousRevision: old.revision,
+ });
+ mocks.native.setWidgetSnapshot(JSON.stringify(newer), Date.parse(newer.expiresAt));
+ vi.setSystemTime(Date.parse(old.expiresAt));
+
+ const rendered = await runWidgetTask(handler, width);
+ const expected = width === 120 ? ['1 Running'] : ['1 Running', 'Open agents'];
+ expect(collectText(rendered.light)).toEqual(expected);
+ expect(collectText(rendered.dark)).toEqual(expected);
+ });
+
+ it.each([
+ ['privacy', 'Agents hidden'],
+ ['signed_out', 'Sign in to see agents'],
+ ] as const)('reads a native %s blank instead of stale legacy storage', async (status, copy) => {
+ const old = snapshotFor();
+ const handler = await registerAfterRestart(old);
+ mocks.native.setWidgetSnapshot(JSON.stringify(snapshotFor([], status)), 0);
+ vi.setSystemTime(Date.parse(old.expiresAt));
+
+ const rendered = await runWidgetTask(handler, width);
+ expect(collectText(rendered.light)).toEqual([copy]);
+ expect(collectText(rendered.dark)).toEqual([copy]);
+ expect(mocks.getDeadline()).toBe(0);
+ });
+
+ it.each(['happy', 'stale'] as const)(
+ 'renders native %s state after a JS reload without extending its expiry',
+ async status => {
+ const snapshot = snapshotFor(undefined, status);
+ const expiresAt = Date.parse(snapshot.expiresAt);
+ mocks.native.setWidgetSnapshot(JSON.stringify(snapshot), expiresAt);
+ vi.setSystemTime(NOW + 7_200_000);
+ const handler = await registerAfterRestart(null);
+
+ const current = await runWidgetTask(handler, width);
+ expect(collectText(current.light)).toContain('1 Needs input');
+ expect(collectText(current.dark)).toContain('1 Needs input');
+ expect(mocks.getDeadline()).toBe(expiresAt);
+
+ vi.setSystemTime(expiresAt);
+ const reloaded = await registerAfterRestart(null);
+ const expired = await runWidgetTask(reloaded, width);
+ expect(collectText(expired.light)).toEqual(['Status expired']);
+ expect(collectText(expired.dark)).toEqual(['Status expired']);
+ }
+ );
+
+ it('expires counts in an already-running handler without a JavaScript timer', async () => {
+ const snapshot = snapshotFor();
+ const handler = await registerAfterRestart(snapshot);
+ await runWidgetTask(handler, width);
+ expect(mocks.getDeadline()).toBe(Date.parse(snapshot.expiresAt));
+ vi.setSystemTime(Date.parse(snapshot.expiresAt));
+
+ const rendered = await runWidgetTask(handler, width);
+ expect(collectText(rendered.light)).toEqual(['Status expired']);
+ expect(collectText(rendered.dark)).toEqual(['Status expired']);
+ });
+
+ it.each([
+ ['invalid JSON', '{'],
+ ['missing fields', JSON.stringify({ status: 'happy' })],
+ ['negative counts', JSON.stringify({ ...snapshotFor(), running: -1 })],
+ ])('rejects a native snapshot with %s', async (_reason, raw) => {
+ const handler = await registerAfterRestart(null);
+ mocks.native.setWidgetSnapshot(raw, 0);
+
+ const rendered = await runWidgetTask(handler, width);
+ expect(collectText(rendered.light)).toEqual(['No work in progress']);
+ expect(collectText(rendered.dark)).toEqual(['No work in progress']);
+ });
+
+ it('keeps live widget props published while restoration is pending', async () => {
+ const stored = snapshotFor();
+ const handler = await registerAfterRestart(stored);
+ const { androidSink } = await import('./android-sink');
+ const read = Promise.withResolvers();
+ secureStore.getItemAsync.mockReturnValueOnce(read.promise);
+
+ const rendering = runWidgetTask(handler, width);
+ androidSink.publish({ ...snapshotFor([{ status: 'busy' }]), revision: stored.revision + 1 });
+ read.resolve(JSON.stringify(stored));
+ const rendered = await rendering;
+ const expected = width === 120 ? ['1 Running'] : ['1 Running', 'Open agents'];
+
+ expect(collectText(rendered.light)).toEqual(expected);
+ expect(collectText(rendered.dark)).toEqual(expected);
+ });
+});
diff --git a/apps/mobile/src/glanceable-android/register.ts b/apps/mobile/src/glanceable-android/register.ts
new file mode 100644
index 0000000000..812fb43474
--- /dev/null
+++ b/apps/mobile/src/glanceable-android/register.ts
@@ -0,0 +1,56 @@
+import { AppState } from 'react-native';
+import {
+ registerWidgetTaskHandler,
+ type WidgetTaskHandlerProps,
+} from 'react-native-android-widget';
+
+import { i18n } from '@/i18n';
+import { getLastGlanceableSnapshot, restorePersistedGlanceable } from '@/lib/glanceable/persist';
+import { registerGlanceableSink } from '@/lib/glanceable/sink-registry';
+
+import { renderActiveAgentsWidget } from './active-agents-widget';
+import { androidSink, getCurrentWidgetProps, handleAppStateActive } from './android-sink';
+import { getStoredWidgetSnapshot, setWidgetSnapshot } from './live-update';
+import { buildCurrentWidgetProps, buildGenericWidgetProps } from './widget-props';
+
+// Register the Android sink at import time. The main-app import of the local
+// live-update module loads this file, so the sink subscribes before any widget
+// render. No React dependency here: the publisher is plain state.
+registerGlanceableSink(androidSink);
+
+// The permission alert needs a foreground Activity; RN Android's AlertModule
+// no-ops in headless JS. Show it when the app returns to the foreground instead.
+AppState.addEventListener('change', state => {
+ if (state === 'active') {
+ void handleAppStateActive();
+ }
+});
+
+function translate(key: string): string {
+ return i18n.t(key);
+}
+
+registerWidgetTaskHandler(async (task: WidgetTaskHandlerProps) => {
+ const { widgetInfo, renderWidget } = task;
+
+ // Re-read native storage even in a live process. An old alarm can already have
+ // queued this task when newer work or a privacy blank replaces its deadline.
+ const stored = getStoredWidgetSnapshot();
+ let props =
+ stored === null ? getCurrentWidgetProps() : buildCurrentWidgetProps(stored, translate);
+ if (props === null) {
+ // Migrate the existing mirror when this installation has no native snapshot yet.
+ await restorePersistedGlanceable();
+ const snapshot = getLastGlanceableSnapshot();
+ if (snapshot !== null && getCurrentWidgetProps() === null) {
+ setWidgetSnapshot(snapshot);
+ }
+ props =
+ snapshot === null
+ ? buildGenericWidgetProps(translate)
+ : buildCurrentWidgetProps(snapshot, translate);
+ // A live publish during restoration owns the widget.
+ props = getCurrentWidgetProps() ?? props;
+ }
+ renderWidget(renderActiveAgentsWidget(props, widgetInfo));
+});
diff --git a/apps/mobile/src/glanceable-android/widget-config.json b/apps/mobile/src/glanceable-android/widget-config.json
new file mode 100644
index 0000000000..39be56193c
--- /dev/null
+++ b/apps/mobile/src/glanceable-android/widget-config.json
@@ -0,0 +1,16 @@
+{
+ "widgets": [
+ {
+ "name": "ActiveAgentsWidget",
+ "label": "Active agents",
+ "description": "Shows your active agents at a glance.",
+ "minWidth": "110dp",
+ "minHeight": "40dp",
+ "targetCellWidth": 2,
+ "targetCellHeight": 1,
+ "maxResizeWidth": "360dp",
+ "maxResizeHeight": "120dp",
+ "resizeMode": "horizontal|vertical"
+ }
+ ]
+}
diff --git a/apps/mobile/src/glanceable-android/widget-props.test.ts b/apps/mobile/src/glanceable-android/widget-props.test.ts
new file mode 100644
index 0000000000..2fe145a0f6
--- /dev/null
+++ b/apps/mobile/src/glanceable-android/widget-props.test.ts
@@ -0,0 +1,239 @@
+import {
+ buildGlanceableSnapshot,
+ type GlanceableAgentsSnapshot,
+} from '@kilocode/app-shared/glanceable-agents-snapshot';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ buildAndroidWidgetProps,
+ buildCompactNotificationText,
+ buildCurrentWidgetProps,
+ buildOngoingNotificationText,
+} from './widget-props';
+
+const NOW = 1_750_000_000_000;
+
+const COPY: Record = {
+ 'glanceable.needsInput': 'Needs input',
+ 'glanceable.reconnecting': 'Reconnecting',
+ 'glanceable.running': 'Running',
+ 'glanceable.waiting': 'Waiting for agents',
+ 'glanceable.empty': 'No work in progress',
+ 'glanceable.stale': 'Updates delayed',
+ 'glanceable.expired': 'Status expired',
+ 'glanceable.signedOut': 'Sign in to see agents',
+ 'glanceable.privacy': 'Agents hidden',
+ 'glanceable.openAgents': 'Open agents',
+};
+const translate = (key: string): string => COPY[key] ?? key;
+
+function snapshotFor(
+ sessions: { status: string }[],
+ revision = 0,
+ status?: GlanceableAgentsSnapshot['status']
+): GlanceableAgentsSnapshot {
+ return buildGlanceableSnapshot({
+ sessions,
+ userId: 'u1',
+ organizationId: null,
+ now: NOW,
+ previousRevision: revision,
+ ...(status === undefined ? {} : { status }),
+ });
+}
+
+const MIXED = {
+ ...snapshotFor([], 0, 'happy'),
+ needsInput: 2,
+ reconnecting: 3,
+ running: 4,
+};
+
+describe('buildAndroidWidgetProps', () => {
+ it('ranks the compact primary count and keeps all expanded numeric counts', () => {
+ const props = buildAndroidWidgetProps(MIXED, {}, translate);
+ expect(props.primaryLabel).toBe('Needs input');
+ expect(props.primaryCount).toBe(2);
+ expect(props.countLines).toEqual([
+ { label: 'Needs input', count: 2 },
+ { label: 'Reconnecting', count: 3 },
+ { label: 'Running', count: 4 },
+ ]);
+ });
+
+ it.each([
+ ['happy', '2 Needs input, 3 Reconnecting, 4 Running, Open agents'],
+ ['stale', 'Updates delayed, 2 Needs input, 3 Reconnecting, 4 Running, Open agents'],
+ ] as const)(
+ 'includes numeric counts and the action in the %s spoken label',
+ (status, expected) => {
+ const props = buildAndroidWidgetProps({ ...MIXED, status }, {}, translate);
+ expect(props.accessibilityLabel).toBe(expected);
+ }
+ );
+
+ it('applies the locked copy matrix per status', () => {
+ const cases: [
+ GlanceableAgentsSnapshot['status'],
+ { status: string }[],
+ string,
+ number,
+ boolean,
+ ][] = [
+ ['waiting', [], 'Waiting for agents', 0, false],
+ ['empty', [], 'No work in progress', 0, false],
+ ['stale', [{ status: 'busy' }], 'Updates delayed', 1, true],
+ ['expired', [], 'Status expired', 0, false],
+ ['signed_out', [], 'Sign in to see agents', 0, false],
+ ['privacy', [], 'Agents hidden', 0, false],
+ ];
+ for (const [status, sessions, statusLine, counts, showOpenAgents] of cases) {
+ const props = buildAndroidWidgetProps(snapshotFor(sessions, 0, status), {}, translate);
+ expect(props.statusLine).toBe(statusLine);
+ expect(props.countLines).toHaveLength(counts);
+ expect(props.showOpenAgents).toBe(showOpenAgents);
+ }
+ });
+
+ it('carries no title, organization name, or raw id into the widget payload', () => {
+ const snapshot = buildGlanceableSnapshot({
+ sessions: [{ status: 'busy' }],
+ userId: 'user-9f3a-leak',
+ organizationId: 'org-acme-7-leak',
+ now: NOW,
+ previousEligibleStartedAt: new Date(NOW - 60_000).toISOString(),
+ });
+
+ const props = buildAndroidWidgetProps(snapshot, {}, translate);
+ const json = JSON.stringify(props);
+
+ expect(Object.keys(props).toSorted()).toEqual([
+ 'accessibilityLabel',
+ 'countLines',
+ 'openAgentsLabel',
+ 'primaryCount',
+ 'primaryLabel',
+ 'showOpenAgents',
+ 'statusLine',
+ ]);
+ expect(json).not.toContain('user-9f3a-leak');
+ expect(json).not.toContain('org-acme-7-leak');
+ expect(json).not.toContain(snapshot.scopeKey);
+ expect(json).not.toContain(snapshot.updatedAt);
+ expect(json).not.toContain('revision');
+ expect(json).not.toContain('title');
+ });
+});
+
+describe('current widget deadline rendering', () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it.each(['happy', 'stale'] as const)('hides expired %s counts and the visible action', status => {
+ vi.useFakeTimers();
+ vi.setSystemTime(NOW + 28_800_000);
+ const props = buildCurrentWidgetProps({ ...MIXED, status }, translate);
+ expect(props.statusLine).toBe('Status expired');
+ expect(props.countLines).toEqual([]);
+ expect(props.accessibilityLabel).toBe('Status expired, Open agents');
+ expect(props.showOpenAgents).toBe(false);
+ });
+
+ it.each([
+ ['privacy', 'Agents hidden'],
+ ['signed_out', 'Sign in to see agents'],
+ ['empty', 'No work in progress'],
+ ['waiting', 'Waiting for agents'],
+ ] as const)('preserves %s copy beyond an old deadline', (status, expected) => {
+ vi.useFakeTimers();
+ vi.setSystemTime(NOW + 28_800_001);
+ const props = buildCurrentWidgetProps({ ...MIXED, status }, translate);
+ expect(props.statusLine).toBe(expected);
+ expect(props.accessibilityLabel).toBe(`${expected}, Open agents`);
+ expect(props.countLines).toEqual([]);
+ expect(props.showOpenAgents).toBe(false);
+ });
+
+ it('hides counts when the stored expiry is not a valid date', () => {
+ const props = buildCurrentWidgetProps({ ...MIXED, expiresAt: 'invalid' }, translate);
+ expect(props.statusLine).toBe('Status expired');
+ expect(props.countLines).toEqual([]);
+ });
+});
+
+describe('buildOngoingNotificationText', () => {
+ it('lists every ranked numeric count for happy work', () => {
+ expect(buildOngoingNotificationText(MIXED, {}, translate)).toBe(
+ '2 Needs input, 3 Reconnecting, 4 Running'
+ );
+ });
+
+ it('adds the translated stale warning without losing eligible counts', () => {
+ expect(buildOngoingNotificationText({ ...MIXED, status: 'stale' }, {}, translate)).toBe(
+ 'Updates delayed, 2 Needs input, 3 Reconnecting, 4 Running'
+ );
+ });
+
+ it('keeps stale copy when there are no retained counts', () => {
+ expect(buildOngoingNotificationText(snapshotFor([], 0, 'stale'), {}, translate)).toBe(
+ 'Updates delayed'
+ );
+ });
+
+ it('uses empty copy when there is no eligible work', () => {
+ expect(buildOngoingNotificationText(snapshotFor([]), {}, translate)).toBe(
+ 'No work in progress'
+ );
+ });
+});
+
+describe('buildCompactNotificationText', () => {
+ it.each([
+ { needsInput: 2, reconnecting: 3, running: 4, expected: '2' },
+ { needsInput: 0, reconnecting: 3, running: 4, expected: '3' },
+ { needsInput: 0, reconnecting: 0, running: 4, expected: '4' },
+ { needsInput: 0, reconnecting: 0, running: 0, expected: null },
+ ])('uses the ranked primary number $expected, not the total or full summary', counts => {
+ const snapshot = { ...MIXED, ...counts };
+ expect(buildCompactNotificationText(snapshot, {})).toBe(counts.expected);
+ expect(buildCompactNotificationText({ ...snapshot, status: 'stale' }, {})).toBe(
+ counts.expected
+ );
+ });
+});
+
+describe('status precedence and count hiding', () => {
+ it.each([
+ ['waiting', 'Waiting for agents'],
+ ['empty', 'No work in progress'],
+ ['expired', 'Status expired'],
+ ['signed_out', 'Sign in to see agents'],
+ ['privacy', 'Agents hidden'],
+ ] as const)('hides counts on every Android surface for %s', (status, expected) => {
+ const snapshot = { ...MIXED, status };
+ const props = buildAndroidWidgetProps(snapshot, {}, translate);
+ expect(props.statusLine).toBe(expected);
+ expect(props.countLines).toEqual([]);
+ expect(props.primaryLabel).toBeNull();
+ expect(props.primaryCount).toBe(0);
+ expect(props.showOpenAgents).toBe(false);
+ expect(buildOngoingNotificationText(snapshot, {}, translate)).toBe(expected);
+ expect(buildCompactNotificationText(snapshot, {})).toBeNull();
+ });
+
+ it.each([
+ [{ signedOut: true, orgInvalid: true }, 'Sign in to see agents'],
+ [{ orgInvalid: true }, 'Agents hidden'],
+ ] as const)('honors auth overrides before stale counts: %j', (flags, expected) => {
+ const snapshot = { ...MIXED, status: 'stale' as const };
+ const props = buildAndroidWidgetProps(snapshot, flags, translate);
+ expect(props.statusLine).toBe(expected);
+ expect(props.countLines).toEqual([]);
+ expect(props.primaryLabel).toBeNull();
+ expect(props.primaryCount).toBe(0);
+ expect(props.showOpenAgents).toBe(false);
+ expect(buildOngoingNotificationText(snapshot, flags, translate)).toBe(expected);
+ expect(buildCompactNotificationText(snapshot, flags)).toBeNull();
+ });
+});
diff --git a/apps/mobile/src/glanceable-android/widget-props.ts b/apps/mobile/src/glanceable-android/widget-props.ts
new file mode 100644
index 0000000000..71434f679c
--- /dev/null
+++ b/apps/mobile/src/glanceable-android/widget-props.ts
@@ -0,0 +1,141 @@
+import { type GlanceableAgentsSnapshot } from '@kilocode/app-shared/glanceable-agents-snapshot';
+
+import {
+ glanceableCountLines,
+ glanceableSpokenLabel,
+ glanceableStatusCopyKey,
+ type GlanceableSurfaceFlags,
+ primaryGlanceableCount,
+ resolveGlanceableStatus,
+} from '@/lib/glanceable/presentation';
+
+/** One translated count line for an Android surface. */
+type AndroidWidgetCount = { label: string; count: number };
+
+/**
+ * The props the Android widget renders. The builder below is the only producer,
+ * so a title, organization name, account id, or raw session id can never reach
+ * the widget host. Android has no elapsed timer, so there is no elapsed anchor.
+ */
+export type AndroidWidgetProps = {
+ /** Translated locked copy; null while counts show (happy). Stale carries both. */
+ statusLine: string | null;
+ /** Non-zero count lines in rank order (needs-input, reconnecting, running). */
+ countLines: AndroidWidgetCount[];
+ /** Top-ranked count label for compact widths; null when no eligible work. */
+ primaryLabel: string | null;
+ /** Top-ranked count value for compact widths; 0 when no eligible work. */
+ primaryCount: number;
+ /** Translated "Open agents" affordance. */
+ openAgentsLabel: string;
+ /** True for happy and stale — the only statuses that show counts. */
+ showOpenAgents: boolean;
+ /** Spoken label: status words, counts, then Open agents. Never a title or id. */
+ accessibilityLabel: string;
+};
+
+/** Build the Android widget props from a snapshot, surface flags, and a translator. */
+export function buildAndroidWidgetProps(
+ snapshot: GlanceableAgentsSnapshot,
+ flags: GlanceableSurfaceFlags,
+ translate: (key: string) => string
+): AndroidWidgetProps {
+ const status = resolveGlanceableStatus(snapshot, flags);
+ const statusKey = glanceableStatusCopyKey(snapshot, flags);
+ const showCounts = status === 'happy' || status === 'stale';
+ const primary = showCounts ? primaryGlanceableCount(snapshot) : null;
+
+ return {
+ statusLine: statusKey === null ? null : translate(statusKey),
+ countLines: (showCounts ? glanceableCountLines(snapshot) : []).map(line => ({
+ label: translate(line.key),
+ count: line.count,
+ })),
+ primaryLabel: primary === null ? null : translate(primary.key),
+ primaryCount: primary === null ? 0 : primary.count,
+ openAgentsLabel: translate('glanceable.openAgents'),
+ showOpenAgents: showCounts,
+ accessibilityLabel: glanceableSpokenLabel(snapshot, flags, translate),
+ };
+}
+
+/** Every redraw checks the data deadline, including a task queued by an older alarm. */
+export function buildCurrentWidgetProps(
+ snapshot: GlanceableAgentsSnapshot,
+ translate: (key: string) => string
+): AndroidWidgetProps {
+ const expiresAt = Date.parse(snapshot.expiresAt);
+ if (
+ (snapshot.status === 'happy' || snapshot.status === 'stale') &&
+ (!Number.isFinite(expiresAt) || expiresAt <= Date.now())
+ ) {
+ return buildExpiredWidgetProps(snapshot, translate);
+ }
+ return buildAndroidWidgetProps(snapshot, {}, translate);
+}
+
+/** Zero-count expired props: the single future redraw hides counts at expiresAt. */
+function buildExpiredWidgetProps(
+ snapshot: GlanceableAgentsSnapshot,
+ translate: (key: string) => string
+): AndroidWidgetProps {
+ return buildAndroidWidgetProps(
+ {
+ ...snapshot,
+ status: 'expired',
+ running: 0,
+ needsInput: 0,
+ reconnecting: 0,
+ eligibleStartedAt: null,
+ },
+ {},
+ translate
+ );
+}
+
+/** Gallery placeholder: empty copy and no counts, with no snapshot behind it. */
+export function buildGenericWidgetProps(translate: (key: string) => string): AndroidWidgetProps {
+ const empty = translate('glanceable.empty');
+ return {
+ statusLine: empty,
+ countLines: [],
+ primaryLabel: null,
+ primaryCount: 0,
+ openAgentsLabel: translate('glanceable.openAgents'),
+ showOpenAgents: false,
+ accessibilityLabel: empty,
+ };
+}
+
+/**
+ * Ongoing notification: every ranked count, with a warning when stale, otherwise
+ * the locked status copy. Never a title, organization name, or id.
+ */
+export function buildOngoingNotificationText(
+ snapshot: GlanceableAgentsSnapshot,
+ flags: GlanceableSurfaceFlags,
+ translate: (key: string) => string
+): string {
+ const status = resolveGlanceableStatus(snapshot, flags);
+ if (status === 'happy' || status === 'stale') {
+ const lines = glanceableCountLines(snapshot);
+ if (lines.length > 0) {
+ const counts = lines.map(line => `${line.count} ${translate(line.key)}`).join(', ');
+ return status === 'stale' ? `${translate('glanceable.stale')}, ${counts}` : counts;
+ }
+ }
+ return translate(glanceableStatusCopyKey(snapshot, flags) ?? 'glanceable.empty');
+}
+
+/** The promoted chip shows only the primary number; the full text keeps all labels. */
+export function buildCompactNotificationText(
+ snapshot: GlanceableAgentsSnapshot,
+ flags: GlanceableSurfaceFlags
+): string | null {
+ const status = resolveGlanceableStatus(snapshot, flags);
+ if (status !== 'happy' && status !== 'stale') {
+ return null;
+ }
+ const primary = primaryGlanceableCount(snapshot);
+ return primary === null ? null : String(primary.count);
+}
diff --git a/apps/mobile/src/i18n/locales/af.json b/apps/mobile/src/i18n/locales/af.json
index d6d12d53ba..1b4ee043ca 100644
--- a/apps/mobile/src/i18n/locales/af.json
+++ b/apps/mobile/src/i18n/locales/af.json
@@ -3202,6 +3202,7 @@
"openAgents": "Maak agente oop",
"running": "LOOP",
"needsInput": "benodig invoer",
- "reconnecting": "Verbind tans weer"
+ "reconnecting": "Verbind tans weer",
+ "channelName": "Aktiewe agente"
}
}
diff --git a/apps/mobile/src/i18n/locales/am.json b/apps/mobile/src/i18n/locales/am.json
index 279c02b9f8..5b7ada46af 100644
--- a/apps/mobile/src/i18n/locales/am.json
+++ b/apps/mobile/src/i18n/locales/am.json
@@ -3202,6 +3202,7 @@
"openAgents": "ወኪሎችን ይክፈቱ",
"running": "በስራ ላይ",
"needsInput": "ግብዓት ይፈልጋል",
- "reconnecting": "እንደገና በመገናኘት ላይ"
+ "reconnecting": "እንደገና በመገናኘት ላይ",
+ "channelName": "ንቁ ወኪሎች"
}
}
diff --git a/apps/mobile/src/i18n/locales/ar.json b/apps/mobile/src/i18n/locales/ar.json
index c78bb4b9fc..e8af7140d7 100644
--- a/apps/mobile/src/i18n/locales/ar.json
+++ b/apps/mobile/src/i18n/locales/ar.json
@@ -3286,6 +3286,7 @@
"openAgents": "فتح الوكلاء",
"running": "قيد التشغيل",
"needsInput": "يتطلب إدخالًا",
- "reconnecting": "جارٍ إعادة الاتصال"
+ "reconnecting": "جارٍ إعادة الاتصال",
+ "channelName": "الوكلاء النشطون"
}
}
diff --git a/apps/mobile/src/i18n/locales/az.json b/apps/mobile/src/i18n/locales/az.json
index 83b88830e0..67e9b552c7 100644
--- a/apps/mobile/src/i18n/locales/az.json
+++ b/apps/mobile/src/i18n/locales/az.json
@@ -3202,6 +3202,7 @@
"openAgents": "Agentləri açın",
"running": "İŞLƏYİR",
"needsInput": "GİRİŞ TƏLƏB OLUNUR",
- "reconnecting": "Yenidən qoşulur"
+ "reconnecting": "Yenidən qoşulur",
+ "channelName": "Aktiv agentlər"
}
}
diff --git a/apps/mobile/src/i18n/locales/be.json b/apps/mobile/src/i18n/locales/be.json
index bf323988fc..c418ee0c38 100644
--- a/apps/mobile/src/i18n/locales/be.json
+++ b/apps/mobile/src/i18n/locales/be.json
@@ -3244,6 +3244,7 @@
"openAgents": "Адкрыць агентаў",
"running": "ПРАЦУЕ",
"needsInput": "патрабуецца ўвод",
- "reconnecting": "Паўторнае падключэнне"
+ "reconnecting": "Паўторнае падключэнне",
+ "channelName": "Актыўныя агенты"
}
}
diff --git a/apps/mobile/src/i18n/locales/bg.json b/apps/mobile/src/i18n/locales/bg.json
index 99024530e1..5cd8407ff4 100644
--- a/apps/mobile/src/i18n/locales/bg.json
+++ b/apps/mobile/src/i18n/locales/bg.json
@@ -3202,6 +3202,7 @@
"openAgents": "Отворете агентите",
"running": "Изпълнява се",
"needsInput": "изисква въвеждане",
- "reconnecting": "Повторно свързване"
+ "reconnecting": "Повторно свързване",
+ "channelName": "Активни агенти"
}
}
diff --git a/apps/mobile/src/i18n/locales/bn.json b/apps/mobile/src/i18n/locales/bn.json
index 1a683c1555..ba4620cb81 100644
--- a/apps/mobile/src/i18n/locales/bn.json
+++ b/apps/mobile/src/i18n/locales/bn.json
@@ -3202,6 +3202,7 @@
"openAgents": "এজেন্টগুলি খুলুন",
"running": "চলছে",
"needsInput": "ইনপুট প্রয়োজন",
- "reconnecting": "পুনরায় সংযোগ করা হচ্ছে"
+ "reconnecting": "পুনরায় সংযোগ করা হচ্ছে",
+ "channelName": "সক্রিয় এজেন্ট"
}
}
diff --git a/apps/mobile/src/i18n/locales/bs.json b/apps/mobile/src/i18n/locales/bs.json
index ad2c5bb2ee..0e49ae771e 100644
--- a/apps/mobile/src/i18n/locales/bs.json
+++ b/apps/mobile/src/i18n/locales/bs.json
@@ -3223,6 +3223,7 @@
"openAgents": "Otvorite agente",
"running": "RADI",
"needsInput": "treba unos",
- "reconnecting": "Ponovno povezivanje"
+ "reconnecting": "Ponovno povezivanje",
+ "channelName": "Aktivni agenti"
}
}
diff --git a/apps/mobile/src/i18n/locales/ca.json b/apps/mobile/src/i18n/locales/ca.json
index 9e95791e39..424f8144dc 100644
--- a/apps/mobile/src/i18n/locales/ca.json
+++ b/apps/mobile/src/i18n/locales/ca.json
@@ -3223,6 +3223,7 @@
"openAgents": "Obre els agents",
"running": "EN EXECUCIÓ",
"needsInput": "requereix entrada",
- "reconnecting": "Reconnectant"
+ "reconnecting": "Reconnectant",
+ "channelName": "Agents actius"
}
}
diff --git a/apps/mobile/src/i18n/locales/ckb.json b/apps/mobile/src/i18n/locales/ckb.json
index 3128184dd0..2c1311bd58 100644
--- a/apps/mobile/src/i18n/locales/ckb.json
+++ b/apps/mobile/src/i18n/locales/ckb.json
@@ -3202,6 +3202,7 @@
"openAgents": "کردنەوەی ئەجێنتەکان",
"running": "لە کاردایە",
"needsInput": "پێویستی بە داخڵکردن",
- "reconnecting": "لە پەیوەستبوونەوەدایە"
+ "reconnecting": "لە پەیوەستبوونەوەدایە",
+ "channelName": "ئەجێنتە چالاکەکان"
}
}
diff --git a/apps/mobile/src/i18n/locales/cs.json b/apps/mobile/src/i18n/locales/cs.json
index 5e2eced1b1..6b1877c7ad 100644
--- a/apps/mobile/src/i18n/locales/cs.json
+++ b/apps/mobile/src/i18n/locales/cs.json
@@ -3244,6 +3244,7 @@
"openAgents": "Otevřít agenty",
"running": "BĚŽÍ",
"needsInput": "vyžaduje vstup",
- "reconnecting": "Obnovování připojení"
+ "reconnecting": "Obnovování připojení",
+ "channelName": "Aktivní agenti"
}
}
diff --git a/apps/mobile/src/i18n/locales/cy.json b/apps/mobile/src/i18n/locales/cy.json
index 5f362a52c6..c89bfe339d 100644
--- a/apps/mobile/src/i18n/locales/cy.json
+++ b/apps/mobile/src/i18n/locales/cy.json
@@ -3286,6 +3286,7 @@
"openAgents": "Agorwch asiantau",
"running": "YN RHEDEG",
"needsInput": "angen mewnbwn",
- "reconnecting": "Yn ailgysylltu"
+ "reconnecting": "Yn ailgysylltu",
+ "channelName": "Asiantau gweithredol"
}
}
diff --git a/apps/mobile/src/i18n/locales/da.json b/apps/mobile/src/i18n/locales/da.json
index 266c995879..df2630b2f1 100644
--- a/apps/mobile/src/i18n/locales/da.json
+++ b/apps/mobile/src/i18n/locales/da.json
@@ -3202,6 +3202,7 @@
"openAgents": "Åbn agenter",
"running": "KØRER",
"needsInput": "kræver input",
- "reconnecting": "Genopretter forbindelsen"
+ "reconnecting": "Genopretter forbindelsen",
+ "channelName": "Aktive agenter"
}
}
diff --git a/apps/mobile/src/i18n/locales/de.json b/apps/mobile/src/i18n/locales/de.json
index 9b5d2d7531..c2091c7fd8 100644
--- a/apps/mobile/src/i18n/locales/de.json
+++ b/apps/mobile/src/i18n/locales/de.json
@@ -3202,6 +3202,7 @@
"openAgents": "Agenten öffnen",
"running": "LÄUFT",
"needsInput": "Eingabe erforderlich",
- "reconnecting": "Verbindung wird wiederhergestellt"
+ "reconnecting": "Verbindung wird wiederhergestellt",
+ "channelName": "Aktive Agenten"
}
}
diff --git a/apps/mobile/src/i18n/locales/el.json b/apps/mobile/src/i18n/locales/el.json
index 1afed8347c..2fc7506b84 100644
--- a/apps/mobile/src/i18n/locales/el.json
+++ b/apps/mobile/src/i18n/locales/el.json
@@ -3202,6 +3202,7 @@
"openAgents": "Ανοίξτε τους πράκτορες",
"running": "Σε εξέλιξη",
"needsInput": "χρειάζεται είσοδο",
- "reconnecting": "Επανασύνδεση"
+ "reconnecting": "Επανασύνδεση",
+ "channelName": "Ενεργοί πράκτορες"
}
}
diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json
index 511ae214ec..74b46f2668 100644
--- a/apps/mobile/src/i18n/locales/en.json
+++ b/apps/mobile/src/i18n/locales/en.json
@@ -3202,6 +3202,7 @@
"openAgents": "Open agents",
"running": "Running",
"needsInput": "Needs input",
- "reconnecting": "Reconnecting"
+ "reconnecting": "Reconnecting",
+ "channelName": "Active agents"
}
}
diff --git a/apps/mobile/src/i18n/locales/es.json b/apps/mobile/src/i18n/locales/es.json
index ad24dc8f7a..15e9b24f29 100644
--- a/apps/mobile/src/i18n/locales/es.json
+++ b/apps/mobile/src/i18n/locales/es.json
@@ -3223,6 +3223,7 @@
"openAgents": "Abrir agentes",
"running": "EN EJECUCIÓN",
"needsInput": "requiere entrada",
- "reconnecting": "Reconectando"
+ "reconnecting": "Reconectando",
+ "channelName": "Agentes activos"
}
}
diff --git a/apps/mobile/src/i18n/locales/et.json b/apps/mobile/src/i18n/locales/et.json
index 5c6f1acb69..efedf0e21f 100644
--- a/apps/mobile/src/i18n/locales/et.json
+++ b/apps/mobile/src/i18n/locales/et.json
@@ -3202,6 +3202,7 @@
"openAgents": "Avage agendid",
"running": "TÖÖTAB",
"needsInput": "vajab sisendit",
- "reconnecting": "Ühenduse taastamine"
+ "reconnecting": "Ühenduse taastamine",
+ "channelName": "Aktiivsed agendid"
}
}
diff --git a/apps/mobile/src/i18n/locales/eu.json b/apps/mobile/src/i18n/locales/eu.json
index 6816b5ebdd..007513a714 100644
--- a/apps/mobile/src/i18n/locales/eu.json
+++ b/apps/mobile/src/i18n/locales/eu.json
@@ -3202,6 +3202,7 @@
"openAgents": "Ireki agenteak",
"running": "Exekutatzen",
"needsInput": "sarreraren zain",
- "reconnecting": "Berriro konektatzen"
+ "reconnecting": "Berriro konektatzen",
+ "channelName": "Agente aktiboak"
}
}
diff --git a/apps/mobile/src/i18n/locales/fa.json b/apps/mobile/src/i18n/locales/fa.json
index 7f9dfc7d81..c5686f38c6 100644
--- a/apps/mobile/src/i18n/locales/fa.json
+++ b/apps/mobile/src/i18n/locales/fa.json
@@ -3202,6 +3202,7 @@
"openAgents": "عاملها را باز کنید",
"running": "در حال اجرا",
"needsInput": "نیاز به ورودی",
- "reconnecting": "در حال اتصال مجدد"
+ "reconnecting": "در حال اتصال مجدد",
+ "channelName": "عاملهای فعال"
}
}
diff --git a/apps/mobile/src/i18n/locales/fi.json b/apps/mobile/src/i18n/locales/fi.json
index 9574f10953..63c2436f4e 100644
--- a/apps/mobile/src/i18n/locales/fi.json
+++ b/apps/mobile/src/i18n/locales/fi.json
@@ -3202,6 +3202,7 @@
"openAgents": "Avaa agentit",
"running": "KÄYNNISSÄ",
"needsInput": "vaatii syötettä",
- "reconnecting": "Yhdistetään uudelleen"
+ "reconnecting": "Yhdistetään uudelleen",
+ "channelName": "Aktiiviset agentit"
}
}
diff --git a/apps/mobile/src/i18n/locales/fil.json b/apps/mobile/src/i18n/locales/fil.json
index 0fb827ddc8..c8c7507ac2 100644
--- a/apps/mobile/src/i18n/locales/fil.json
+++ b/apps/mobile/src/i18n/locales/fil.json
@@ -3202,6 +3202,7 @@
"openAgents": "Buksan ang mga agent",
"running": "TUMATAKBO",
"needsInput": "kailangan ng input",
- "reconnecting": "Muling kumokonekta"
+ "reconnecting": "Muling kumokonekta",
+ "channelName": "Mga aktibong agent"
}
}
diff --git a/apps/mobile/src/i18n/locales/fr.json b/apps/mobile/src/i18n/locales/fr.json
index 87b5af0562..ec83f5440e 100644
--- a/apps/mobile/src/i18n/locales/fr.json
+++ b/apps/mobile/src/i18n/locales/fr.json
@@ -3223,6 +3223,7 @@
"openAgents": "Ouvrir les agents",
"running": "EN COURS",
"needsInput": "saisie requise",
- "reconnecting": "Reconnexion en cours"
+ "reconnecting": "Reconnexion en cours",
+ "channelName": "Agents actifs"
}
}
diff --git a/apps/mobile/src/i18n/locales/ga.json b/apps/mobile/src/i18n/locales/ga.json
index a240f956b2..5e4c91f5d2 100644
--- a/apps/mobile/src/i18n/locales/ga.json
+++ b/apps/mobile/src/i18n/locales/ga.json
@@ -3265,6 +3265,7 @@
"openAgents": "Oscail gníomhairí",
"running": "AG RITH",
"needsInput": "teastaíonn ionchur",
- "reconnecting": "Ag athcheangal"
+ "reconnecting": "Ag athcheangal",
+ "channelName": "Gníomhairí gníomhacha"
}
}
diff --git a/apps/mobile/src/i18n/locales/gl.json b/apps/mobile/src/i18n/locales/gl.json
index 26faac0f14..f3a70de821 100644
--- a/apps/mobile/src/i18n/locales/gl.json
+++ b/apps/mobile/src/i18n/locales/gl.json
@@ -3202,6 +3202,7 @@
"openAgents": "Abrir axentes",
"running": "Executando",
"needsInput": "precisa entrada",
- "reconnecting": "Reconectando"
+ "reconnecting": "Reconectando",
+ "channelName": "Axentes activos"
}
}
diff --git a/apps/mobile/src/i18n/locales/gu.json b/apps/mobile/src/i18n/locales/gu.json
index 189a6d3be7..c271998e8f 100644
--- a/apps/mobile/src/i18n/locales/gu.json
+++ b/apps/mobile/src/i18n/locales/gu.json
@@ -3202,6 +3202,7 @@
"openAgents": "એજન્ટો ખોલો",
"running": "ચાલી રહ્યું છે",
"needsInput": "ઇનપુટ જરૂરી",
- "reconnecting": "ફરી કનેક્ટ થઈ રહ્યું છે"
+ "reconnecting": "ફરી કનેક્ટ થઈ રહ્યું છે",
+ "channelName": "સક્રિય એજન્ટો"
}
}
diff --git a/apps/mobile/src/i18n/locales/ha.json b/apps/mobile/src/i18n/locales/ha.json
index 9db5546541..183f2b80f6 100644
--- a/apps/mobile/src/i18n/locales/ha.json
+++ b/apps/mobile/src/i18n/locales/ha.json
@@ -3202,6 +3202,7 @@
"openAgents": "Buɗe wakilai",
"running": "Ana gudana",
"needsInput": "yana buƙatar bayani",
- "reconnecting": "Ana sake haɗawa"
+ "reconnecting": "Ana sake haɗawa",
+ "channelName": "Wakilai da ke aiki"
}
}
diff --git a/apps/mobile/src/i18n/locales/he.json b/apps/mobile/src/i18n/locales/he.json
index 506cad5462..d18236d0a7 100644
--- a/apps/mobile/src/i18n/locales/he.json
+++ b/apps/mobile/src/i18n/locales/he.json
@@ -3223,6 +3223,7 @@
"openAgents": "פתח סוכנים",
"running": "רץ",
"needsInput": "נדרש קלט",
- "reconnecting": "מתחבר מחדש"
+ "reconnecting": "מתחבר מחדש",
+ "channelName": "סוכנים פעילים"
}
}
diff --git a/apps/mobile/src/i18n/locales/hi.json b/apps/mobile/src/i18n/locales/hi.json
index 60947511e1..4ae7b35d52 100644
--- a/apps/mobile/src/i18n/locales/hi.json
+++ b/apps/mobile/src/i18n/locales/hi.json
@@ -3202,6 +3202,7 @@
"openAgents": "एजेंट खोलें",
"running": "चालू",
"needsInput": "इनपुट आवश्यक",
- "reconnecting": "फिर से कनेक्ट हो रहा है"
+ "reconnecting": "फिर से कनेक्ट हो रहा है",
+ "channelName": "सक्रिय एजेंट"
}
}
diff --git a/apps/mobile/src/i18n/locales/hr.json b/apps/mobile/src/i18n/locales/hr.json
index 263fdaa48c..7046d3715b 100644
--- a/apps/mobile/src/i18n/locales/hr.json
+++ b/apps/mobile/src/i18n/locales/hr.json
@@ -3223,6 +3223,7 @@
"openAgents": "Otvorite agente",
"running": "RADI",
"needsInput": "treba unos",
- "reconnecting": "Ponovno povezivanje"
+ "reconnecting": "Ponovno povezivanje",
+ "channelName": "Aktivni agenti"
}
}
diff --git a/apps/mobile/src/i18n/locales/ht.json b/apps/mobile/src/i18n/locales/ht.json
index 44aee58be6..0f458c784b 100644
--- a/apps/mobile/src/i18n/locales/ht.json
+++ b/apps/mobile/src/i18n/locales/ht.json
@@ -3202,6 +3202,7 @@
"openAgents": "Louvri ajans yo",
"running": "AP KOURI",
"needsInput": "bezwen input",
- "reconnecting": "Ap rekonekte"
+ "reconnecting": "Ap rekonekte",
+ "channelName": "Ajans aktif yo"
}
}
diff --git a/apps/mobile/src/i18n/locales/hu.json b/apps/mobile/src/i18n/locales/hu.json
index 5813696bc2..3009fd6a05 100644
--- a/apps/mobile/src/i18n/locales/hu.json
+++ b/apps/mobile/src/i18n/locales/hu.json
@@ -3202,6 +3202,7 @@
"openAgents": "Ügynökök megnyitása",
"running": "Folyamatban",
"needsInput": "bemenetet igényel",
- "reconnecting": "Újracsatlakozás"
+ "reconnecting": "Újracsatlakozás",
+ "channelName": "Aktív ügynökök"
}
}
diff --git a/apps/mobile/src/i18n/locales/hy.json b/apps/mobile/src/i18n/locales/hy.json
index f30a70dd75..7b6d4c9435 100644
--- a/apps/mobile/src/i18n/locales/hy.json
+++ b/apps/mobile/src/i18n/locales/hy.json
@@ -3202,6 +3202,7 @@
"openAgents": "Բացեք գործակալները",
"running": "Ընթացքի մեջ է",
"needsInput": "մուտքագրման կարիք ունի",
- "reconnecting": "Կրկին միացում"
+ "reconnecting": "Կրկին միացում",
+ "channelName": "Ակտիվ գործակալներ"
}
}
diff --git a/apps/mobile/src/i18n/locales/id.json b/apps/mobile/src/i18n/locales/id.json
index 28e02535b6..a3254f067b 100644
--- a/apps/mobile/src/i18n/locales/id.json
+++ b/apps/mobile/src/i18n/locales/id.json
@@ -3202,6 +3202,7 @@
"openAgents": "Buka agen",
"running": "BERJALAN",
"needsInput": "memerlukan input",
- "reconnecting": "Menghubungkan kembali"
+ "reconnecting": "Menghubungkan kembali",
+ "channelName": "Agen aktif"
}
}
diff --git a/apps/mobile/src/i18n/locales/ig.json b/apps/mobile/src/i18n/locales/ig.json
index c17e85e58c..2ff50d86aa 100644
--- a/apps/mobile/src/i18n/locales/ig.json
+++ b/apps/mobile/src/i18n/locales/ig.json
@@ -3202,6 +3202,7 @@
"openAgents": "Mepee ndị ọrụ",
"running": "NA-AGBA",
"needsInput": "chọrọ ntinye",
- "reconnecting": "Na-ejikọ ọzọ"
+ "reconnecting": "Na-ejikọ ọzọ",
+ "channelName": "Ndị ọrụ na-arụ ọrụ"
}
}
diff --git a/apps/mobile/src/i18n/locales/is.json b/apps/mobile/src/i18n/locales/is.json
index 0918a986fe..af9f5a572b 100644
--- a/apps/mobile/src/i18n/locales/is.json
+++ b/apps/mobile/src/i18n/locales/is.json
@@ -3202,6 +3202,7 @@
"openAgents": "Opna umboð",
"running": "Í gangi",
"needsInput": "þarfnast inntaks",
- "reconnecting": "Tengist aftur"
+ "reconnecting": "Tengist aftur",
+ "channelName": "Virk umboð"
}
}
diff --git a/apps/mobile/src/i18n/locales/it.json b/apps/mobile/src/i18n/locales/it.json
index 8aad0e857b..8bdd22a3f1 100644
--- a/apps/mobile/src/i18n/locales/it.json
+++ b/apps/mobile/src/i18n/locales/it.json
@@ -3223,6 +3223,7 @@
"openAgents": "Apri agenti",
"running": "IN ESECUZIONE",
"needsInput": "richiede input",
- "reconnecting": "Riconnessione in corso"
+ "reconnecting": "Riconnessione in corso",
+ "channelName": "Agenti attivi"
}
}
diff --git a/apps/mobile/src/i18n/locales/ja.json b/apps/mobile/src/i18n/locales/ja.json
index 83ce883471..54e1029390 100644
--- a/apps/mobile/src/i18n/locales/ja.json
+++ b/apps/mobile/src/i18n/locales/ja.json
@@ -3202,6 +3202,7 @@
"openAgents": "エージェントを開く",
"running": "実行中",
"needsInput": "入力が必要",
- "reconnecting": "再接続中"
+ "reconnecting": "再接続中",
+ "channelName": "アクティブなエージェント"
}
}
diff --git a/apps/mobile/src/i18n/locales/ka.json b/apps/mobile/src/i18n/locales/ka.json
index d4e298981a..14450163af 100644
--- a/apps/mobile/src/i18n/locales/ka.json
+++ b/apps/mobile/src/i18n/locales/ka.json
@@ -3202,6 +3202,7 @@
"openAgents": "აგენტების გახსნა",
"running": "მუშაობს",
"needsInput": "მოითხოვს შეყვანას",
- "reconnecting": "კავშირის აღდგენა"
+ "reconnecting": "კავშირის აღდგენა",
+ "channelName": "აქტიური აგენტები"
}
}
diff --git a/apps/mobile/src/i18n/locales/kk.json b/apps/mobile/src/i18n/locales/kk.json
index 63398c3da4..4c944a7290 100644
--- a/apps/mobile/src/i18n/locales/kk.json
+++ b/apps/mobile/src/i18n/locales/kk.json
@@ -3202,6 +3202,7 @@
"openAgents": "Агенттерді ашу",
"running": "Орындалуда",
"needsInput": "енгізу қажет",
- "reconnecting": "Қайта қосылуда"
+ "reconnecting": "Қайта қосылуда",
+ "channelName": "Белсенді агенттер"
}
}
diff --git a/apps/mobile/src/i18n/locales/km.json b/apps/mobile/src/i18n/locales/km.json
index 951b891c95..4ae77a6a52 100644
--- a/apps/mobile/src/i18n/locales/km.json
+++ b/apps/mobile/src/i18n/locales/km.json
@@ -3202,6 +3202,7 @@
"openAgents": "បើកភ្នាក់ងារ",
"running": "កំពុងដំណើរការ",
"needsInput": "ត្រូវការបញ្ចូល",
- "reconnecting": "កំពុងភ្ជាប់ឡើងវិញ"
+ "reconnecting": "កំពុងភ្ជាប់ឡើងវិញ",
+ "channelName": "ភ្នាក់ងារសកម្ម"
}
}
diff --git a/apps/mobile/src/i18n/locales/kn.json b/apps/mobile/src/i18n/locales/kn.json
index aa081b63d1..47ed0eae90 100644
--- a/apps/mobile/src/i18n/locales/kn.json
+++ b/apps/mobile/src/i18n/locales/kn.json
@@ -3202,6 +3202,7 @@
"openAgents": "ಏಜೆಂಟ್ಗಳನ್ನು ತೆರೆಯಿರಿ",
"running": "ಚಾಲನೆಯಲ್ಲಿದೆ",
"needsInput": "ಇನ್ಪುಟ್ ಅಗತ್ಯವಿದೆ",
- "reconnecting": "ಮರುಸಂಪರ್ಕಿಸಲಾಗುತ್ತಿದೆ"
+ "reconnecting": "ಮರುಸಂಪರ್ಕಿಸಲಾಗುತ್ತಿದೆ",
+ "channelName": "ಸಕ್ರಿಯ ಏಜೆಂಟ್ಗಳು"
}
}
diff --git a/apps/mobile/src/i18n/locales/ko.json b/apps/mobile/src/i18n/locales/ko.json
index de38c321ae..5439a2bab3 100644
--- a/apps/mobile/src/i18n/locales/ko.json
+++ b/apps/mobile/src/i18n/locales/ko.json
@@ -3202,6 +3202,7 @@
"openAgents": "에이전트 열기",
"running": "실행 중",
"needsInput": "입력 필요",
- "reconnecting": "다시 연결 중"
+ "reconnecting": "다시 연결 중",
+ "channelName": "활성 에이전트"
}
}
diff --git a/apps/mobile/src/i18n/locales/lo.json b/apps/mobile/src/i18n/locales/lo.json
index 8bc6075cd2..8d6b277352 100644
--- a/apps/mobile/src/i18n/locales/lo.json
+++ b/apps/mobile/src/i18n/locales/lo.json
@@ -3202,6 +3202,7 @@
"openAgents": "ເປີດຕົວແທນ",
"running": "ກຳລັງດຳເນີນການ",
"needsInput": "ຕ້ອງການຂໍ້ມູນເຂົ້າ",
- "reconnecting": "ກຳລັງເຊື່ອມຕໍ່ຄືນ"
+ "reconnecting": "ກຳລັງເຊື່ອມຕໍ່ຄືນ",
+ "channelName": "ຕົວແທນທີ່ກຳລັງເຮັດວຽກ"
}
}
diff --git a/apps/mobile/src/i18n/locales/lt.json b/apps/mobile/src/i18n/locales/lt.json
index 16f66764b0..3bc314f349 100644
--- a/apps/mobile/src/i18n/locales/lt.json
+++ b/apps/mobile/src/i18n/locales/lt.json
@@ -3244,6 +3244,7 @@
"openAgents": "Atidaryti agentus",
"running": "Vykdoma",
"needsInput": "reikia įvesties",
- "reconnecting": "Jungiamasi iš naujo"
+ "reconnecting": "Jungiamasi iš naujo",
+ "channelName": "Aktyvūs agentai"
}
}
diff --git a/apps/mobile/src/i18n/locales/lv.json b/apps/mobile/src/i18n/locales/lv.json
index 51f3ea955f..05a7724241 100644
--- a/apps/mobile/src/i18n/locales/lv.json
+++ b/apps/mobile/src/i18n/locales/lv.json
@@ -3223,6 +3223,7 @@
"openAgents": "Atvērt aģentus",
"running": "DARBOJAS",
"needsInput": "nepieciešama ievade",
- "reconnecting": "Atkārtoti izveido savienojumu"
+ "reconnecting": "Atkārtoti izveido savienojumu",
+ "channelName": "Aktīvie aģenti"
}
}
diff --git a/apps/mobile/src/i18n/locales/mg.json b/apps/mobile/src/i18n/locales/mg.json
index b88b5054e0..1a3dc4d8ee 100644
--- a/apps/mobile/src/i18n/locales/mg.json
+++ b/apps/mobile/src/i18n/locales/mg.json
@@ -3202,6 +3202,7 @@
"openAgents": "Sokafy ny agent",
"running": "MANDEHA",
"needsInput": "mila fampidirana",
- "reconnecting": "Mampifandray indray"
+ "reconnecting": "Mampifandray indray",
+ "channelName": "Agent mavitrika"
}
}
diff --git a/apps/mobile/src/i18n/locales/mi.json b/apps/mobile/src/i18n/locales/mi.json
index 996cc80bd4..1dc66c66c0 100644
--- a/apps/mobile/src/i18n/locales/mi.json
+++ b/apps/mobile/src/i18n/locales/mi.json
@@ -3202,6 +3202,7 @@
"openAgents": "Whakatuwheratia ngā māngai",
"running": "Kei te oma",
"needsInput": "e hiahia ana ki te whakaurunga",
- "reconnecting": "Kei te hono anō"
+ "reconnecting": "Kei te hono anō",
+ "channelName": "Ngā māngai hohe"
}
}
diff --git a/apps/mobile/src/i18n/locales/mk.json b/apps/mobile/src/i18n/locales/mk.json
index b99d7bde6b..e4ada1704f 100644
--- a/apps/mobile/src/i18n/locales/mk.json
+++ b/apps/mobile/src/i18n/locales/mk.json
@@ -3202,6 +3202,7 @@
"openAgents": "Отворете ги агентите",
"running": "Во тек",
"needsInput": "бара внес",
- "reconnecting": "Повторно поврзување"
+ "reconnecting": "Повторно поврзување",
+ "channelName": "Активни агенти"
}
}
diff --git a/apps/mobile/src/i18n/locales/ml.json b/apps/mobile/src/i18n/locales/ml.json
index 70513a7aab..d6692e6a94 100644
--- a/apps/mobile/src/i18n/locales/ml.json
+++ b/apps/mobile/src/i18n/locales/ml.json
@@ -3202,6 +3202,7 @@
"openAgents": "ഏജന്റുകളെ തുറക്കുക",
"running": "പ്രവർത്തിക്കുന്നു",
"needsInput": "ഇൻപുട്ട് ആവശ്യമാണ്",
- "reconnecting": "വീണ്ടും ബന്ധിപ്പിക്കുന്നു"
+ "reconnecting": "വീണ്ടും ബന്ധിപ്പിക്കുന്നു",
+ "channelName": "സജീവ ഏജന്റുകൾ"
}
}
diff --git a/apps/mobile/src/i18n/locales/mn.json b/apps/mobile/src/i18n/locales/mn.json
index 96bf6fb332..1d34ea0a0e 100644
--- a/apps/mobile/src/i18n/locales/mn.json
+++ b/apps/mobile/src/i18n/locales/mn.json
@@ -3202,6 +3202,7 @@
"openAgents": "Агентуудыг нээх",
"running": "АЖИЛЛАЖ БАЙНА",
"needsInput": "оролт шаардлагатай",
- "reconnecting": "Дахин холбогдож байна"
+ "reconnecting": "Дахин холбогдож байна",
+ "channelName": "Идэвхтэй агентууд"
}
}
diff --git a/apps/mobile/src/i18n/locales/mr.json b/apps/mobile/src/i18n/locales/mr.json
index b2cfb3efd9..c719d9f452 100644
--- a/apps/mobile/src/i18n/locales/mr.json
+++ b/apps/mobile/src/i18n/locales/mr.json
@@ -3202,6 +3202,7 @@
"openAgents": "एजंट्स उघडा",
"running": "चालू आहे",
"needsInput": "इनपुट आवश्यक",
- "reconnecting": "पुन्हा जोडत आहे"
+ "reconnecting": "पुन्हा जोडत आहे",
+ "channelName": "सक्रिय एजंट्स"
}
}
diff --git a/apps/mobile/src/i18n/locales/ms.json b/apps/mobile/src/i18n/locales/ms.json
index e43ee7f27c..37b9e24910 100644
--- a/apps/mobile/src/i18n/locales/ms.json
+++ b/apps/mobile/src/i18n/locales/ms.json
@@ -3202,6 +3202,7 @@
"openAgents": "Buka ejen",
"running": "Sedang berjalan",
"needsInput": "perlu input",
- "reconnecting": "Menyambung semula"
+ "reconnecting": "Menyambung semula",
+ "channelName": "Ejen aktif"
}
}
diff --git a/apps/mobile/src/i18n/locales/mt.json b/apps/mobile/src/i18n/locales/mt.json
index 35c4df9481..cf0f0b7279 100644
--- a/apps/mobile/src/i18n/locales/mt.json
+++ b/apps/mobile/src/i18n/locales/mt.json
@@ -3265,6 +3265,7 @@
"openAgents": "Iftaħ l-aġenti",
"running": "Għaddej",
"needsInput": "jeħtieġ input",
- "reconnecting": "Qed jerġa' jaqbad"
+ "reconnecting": "Qed jerġa' jaqbad",
+ "channelName": "Aġenti attivi"
}
}
diff --git a/apps/mobile/src/i18n/locales/my.json b/apps/mobile/src/i18n/locales/my.json
index edcc519920..26475618a2 100644
--- a/apps/mobile/src/i18n/locales/my.json
+++ b/apps/mobile/src/i18n/locales/my.json
@@ -3202,6 +3202,7 @@
"openAgents": "agent များကို ဖွင့်ပါ",
"running": "လည်ပတ်နေသည်",
"needsInput": "ထည့်သွင်းမှု လိုအပ်သည်",
- "reconnecting": "ပြန်ချိတ်ဆက်နေသည်"
+ "reconnecting": "ပြန်ချိတ်ဆက်နေသည်",
+ "channelName": "လုပ်ဆောင်နေသော agent များ"
}
}
diff --git a/apps/mobile/src/i18n/locales/nb.json b/apps/mobile/src/i18n/locales/nb.json
index 43761b6c59..ec6a72b9ba 100644
--- a/apps/mobile/src/i18n/locales/nb.json
+++ b/apps/mobile/src/i18n/locales/nb.json
@@ -3202,6 +3202,7 @@
"openAgents": "Åpne agenter",
"running": "KJØRER",
"needsInput": "trenger innspill",
- "reconnecting": "Kobler til på nytt"
+ "reconnecting": "Kobler til på nytt",
+ "channelName": "Aktive agenter"
}
}
diff --git a/apps/mobile/src/i18n/locales/ne.json b/apps/mobile/src/i18n/locales/ne.json
index de1427f1cc..838478720b 100644
--- a/apps/mobile/src/i18n/locales/ne.json
+++ b/apps/mobile/src/i18n/locales/ne.json
@@ -3202,6 +3202,7 @@
"openAgents": "एजेन्टहरू खोल्नुहोस्",
"running": "चलिरहेको",
"needsInput": "इनपुट चाहिन्छ",
- "reconnecting": "पुनः जडान गर्दै"
+ "reconnecting": "पुनः जडान गर्दै",
+ "channelName": "सक्रिय एजेन्टहरू"
}
}
diff --git a/apps/mobile/src/i18n/locales/nl.json b/apps/mobile/src/i18n/locales/nl.json
index 41325b46f0..8c4666a517 100644
--- a/apps/mobile/src/i18n/locales/nl.json
+++ b/apps/mobile/src/i18n/locales/nl.json
@@ -3202,6 +3202,7 @@
"openAgents": "Agents openen",
"running": "Bezig",
"needsInput": "heeft invoer nodig",
- "reconnecting": "Opnieuw verbinden"
+ "reconnecting": "Opnieuw verbinden",
+ "channelName": "Actieve agents"
}
}
diff --git a/apps/mobile/src/i18n/locales/om.json b/apps/mobile/src/i18n/locales/om.json
index 1275746730..d5752001be 100644
--- a/apps/mobile/src/i18n/locales/om.json
+++ b/apps/mobile/src/i18n/locales/om.json
@@ -3202,6 +3202,7 @@
"openAgents": "Eejentoota banaa",
"running": "Hojii irra jira",
"needsInput": "seensa barbaada",
- "reconnecting": "Irra deebi'ee walqabachaa jira"
+ "reconnecting": "Irra deebi'ee walqabachaa jira",
+ "channelName": "Eejentoota hojii irra jiran"
}
}
diff --git a/apps/mobile/src/i18n/locales/or.json b/apps/mobile/src/i18n/locales/or.json
index 5f0ddff01a..7484c06307 100644
--- a/apps/mobile/src/i18n/locales/or.json
+++ b/apps/mobile/src/i18n/locales/or.json
@@ -3202,6 +3202,7 @@
"openAgents": "ଏଜେଣ୍ଟଗୁଡ଼ିକ ଖୋଲନ୍ତୁ",
"running": "ଚାଲୁଛି",
"needsInput": "ଇନପୁଟ୍ ଆବଶ୍ୟକ",
- "reconnecting": "ପୁଣି ସଂଯୋଗ ହେଉଛି"
+ "reconnecting": "ପୁଣି ସଂଯୋଗ ହେଉଛି",
+ "channelName": "ସକ୍ରିୟ ଏଜେଣ୍ଟଗୁଡ଼ିକ"
}
}
diff --git a/apps/mobile/src/i18n/locales/pa.json b/apps/mobile/src/i18n/locales/pa.json
index 693033ab74..1f02febb3f 100644
--- a/apps/mobile/src/i18n/locales/pa.json
+++ b/apps/mobile/src/i18n/locales/pa.json
@@ -3202,6 +3202,7 @@
"openAgents": "ਏਜੰਟ ਖੋਲ੍ਹੋ",
"running": "ਚੱਲ ਰਿਹਾ ਹੈ",
"needsInput": "ਇਨਪੁੱਟ ਦੀ ਲੋੜ ਹੈ",
- "reconnecting": "ਮੁੜ ਕਨੈਕਟ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ"
+ "reconnecting": "ਮੁੜ ਕਨੈਕਟ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ",
+ "channelName": "ਸਰਗਰਮ ਏਜੰਟ"
}
}
diff --git a/apps/mobile/src/i18n/locales/pl.json b/apps/mobile/src/i18n/locales/pl.json
index cad09ac2c4..556da10f5e 100644
--- a/apps/mobile/src/i18n/locales/pl.json
+++ b/apps/mobile/src/i18n/locales/pl.json
@@ -3244,6 +3244,7 @@
"openAgents": "Otwórz agentów",
"running": "W toku",
"needsInput": "wymaga danych",
- "reconnecting": "Ponowne łączenie"
+ "reconnecting": "Ponowne łączenie",
+ "channelName": "Aktywni agenci"
}
}
diff --git a/apps/mobile/src/i18n/locales/ps.json b/apps/mobile/src/i18n/locales/ps.json
index 93ecebe60c..7a0e195688 100644
--- a/apps/mobile/src/i18n/locales/ps.json
+++ b/apps/mobile/src/i18n/locales/ps.json
@@ -3202,6 +3202,7 @@
"openAgents": "اجنټان پرانیزئ",
"running": "روان",
"needsInput": "ورودی ته اړتیا لري",
- "reconnecting": "بیا نښلېږي"
+ "reconnecting": "بیا نښلېږي",
+ "channelName": "فعال اجنټان"
}
}
diff --git a/apps/mobile/src/i18n/locales/pt-BR.json b/apps/mobile/src/i18n/locales/pt-BR.json
index a90088c38b..c4aa487c44 100644
--- a/apps/mobile/src/i18n/locales/pt-BR.json
+++ b/apps/mobile/src/i18n/locales/pt-BR.json
@@ -3223,6 +3223,7 @@
"openAgents": "Abrir agentes",
"running": "Em execução",
"needsInput": "requer entrada",
- "reconnecting": "Reconectando"
+ "reconnecting": "Reconectando",
+ "channelName": "Agentes ativos"
}
}
diff --git a/apps/mobile/src/i18n/locales/pt.json b/apps/mobile/src/i18n/locales/pt.json
index b046797fb2..215759a7b1 100644
--- a/apps/mobile/src/i18n/locales/pt.json
+++ b/apps/mobile/src/i18n/locales/pt.json
@@ -3223,6 +3223,7 @@
"openAgents": "Abrir agentes",
"running": "EM EXECUÇÃO",
"needsInput": "requer entrada",
- "reconnecting": "A restabelecer ligação"
+ "reconnecting": "A restabelecer ligação",
+ "channelName": "Agentes ativos"
}
}
diff --git a/apps/mobile/src/i18n/locales/ro.json b/apps/mobile/src/i18n/locales/ro.json
index edc32efe7a..8725fe53ee 100644
--- a/apps/mobile/src/i18n/locales/ro.json
+++ b/apps/mobile/src/i18n/locales/ro.json
@@ -3223,6 +3223,7 @@
"openAgents": "Deschide agenții",
"running": "Rulează",
"needsInput": "necesită introducere",
- "reconnecting": "Se reconectează"
+ "reconnecting": "Se reconectează",
+ "channelName": "Agenți activi"
}
}
diff --git a/apps/mobile/src/i18n/locales/ru.json b/apps/mobile/src/i18n/locales/ru.json
index 9d0433771e..c8e591b265 100644
--- a/apps/mobile/src/i18n/locales/ru.json
+++ b/apps/mobile/src/i18n/locales/ru.json
@@ -3244,6 +3244,7 @@
"openAgents": "Открыть агентов",
"running": "Выполняется",
"needsInput": "требует ввода",
- "reconnecting": "Повторное подключение"
+ "reconnecting": "Повторное подключение",
+ "channelName": "Активные агенты"
}
}
diff --git a/apps/mobile/src/i18n/locales/si.json b/apps/mobile/src/i18n/locales/si.json
index b91444bd82..c6f64d4d94 100644
--- a/apps/mobile/src/i18n/locales/si.json
+++ b/apps/mobile/src/i18n/locales/si.json
@@ -3202,6 +3202,7 @@
"openAgents": "නියෝජිතයන් විවෘත කරන්න",
"running": "ධාවනය වෙමින්",
"needsInput": "ආදානය අවශ්යයි",
- "reconnecting": "නැවත සම්බන්ධ වෙමින්"
+ "reconnecting": "නැවත සම්බන්ධ වෙමින්",
+ "channelName": "සක්රිය නියෝජිතයන්"
}
}
diff --git a/apps/mobile/src/i18n/locales/sk.json b/apps/mobile/src/i18n/locales/sk.json
index 5a2da6540d..24a8d360d9 100644
--- a/apps/mobile/src/i18n/locales/sk.json
+++ b/apps/mobile/src/i18n/locales/sk.json
@@ -3244,6 +3244,7 @@
"openAgents": "Otvoriť agentov",
"running": "Prebieha",
"needsInput": "vyžaduje vstup",
- "reconnecting": "Opätovné pripájanie"
+ "reconnecting": "Opätovné pripájanie",
+ "channelName": "Aktívni agenti"
}
}
diff --git a/apps/mobile/src/i18n/locales/sl.json b/apps/mobile/src/i18n/locales/sl.json
index 6138aa0a4d..0aad448db8 100644
--- a/apps/mobile/src/i18n/locales/sl.json
+++ b/apps/mobile/src/i18n/locales/sl.json
@@ -3244,6 +3244,7 @@
"openAgents": "Odprite agente",
"running": "DELUJE",
"needsInput": "potrebuje vnos",
- "reconnecting": "Ponovno povezovanje"
+ "reconnecting": "Ponovno povezovanje",
+ "channelName": "Aktivni agenti"
}
}
diff --git a/apps/mobile/src/i18n/locales/so.json b/apps/mobile/src/i18n/locales/so.json
index 7b09b06741..24bf096d20 100644
--- a/apps/mobile/src/i18n/locales/so.json
+++ b/apps/mobile/src/i18n/locales/so.json
@@ -3202,6 +3202,7 @@
"openAgents": "Fur wakiillada",
"running": "Socodaya",
"needsInput": "u baahan wax-soo-gal",
- "reconnecting": "Dib u xiriirinaya"
+ "reconnecting": "Dib u xiriirinaya",
+ "channelName": "Wakiillada firfircoon"
}
}
diff --git a/apps/mobile/src/i18n/locales/sq.json b/apps/mobile/src/i18n/locales/sq.json
index e347f02620..b55329e94a 100644
--- a/apps/mobile/src/i18n/locales/sq.json
+++ b/apps/mobile/src/i18n/locales/sq.json
@@ -3202,6 +3202,7 @@
"openAgents": "Hapni agjentët",
"running": "Në ekzekutim",
"needsInput": "ka nevojë për të dhëna",
- "reconnecting": "Duke u rilidhur"
+ "reconnecting": "Duke u rilidhur",
+ "channelName": "Agjentët aktivë"
}
}
diff --git a/apps/mobile/src/i18n/locales/sr.json b/apps/mobile/src/i18n/locales/sr.json
index 9ff967432a..4132753bc9 100644
--- a/apps/mobile/src/i18n/locales/sr.json
+++ b/apps/mobile/src/i18n/locales/sr.json
@@ -3223,6 +3223,7 @@
"openAgents": "Otvorite agente",
"running": "U toku",
"needsInput": "zahteva unos",
- "reconnecting": "Ponovno povezivanje"
+ "reconnecting": "Ponovno povezivanje",
+ "channelName": "Aktivni agenti"
}
}
diff --git a/apps/mobile/src/i18n/locales/sv.json b/apps/mobile/src/i18n/locales/sv.json
index 23133bef21..7be04ca3d9 100644
--- a/apps/mobile/src/i18n/locales/sv.json
+++ b/apps/mobile/src/i18n/locales/sv.json
@@ -3202,6 +3202,7 @@
"openAgents": "Öppna agenter",
"running": "KÖRS",
"needsInput": "kräver indata",
- "reconnecting": "Återansluter"
+ "reconnecting": "Återansluter",
+ "channelName": "Aktiva agenter"
}
}
diff --git a/apps/mobile/src/i18n/locales/sw.json b/apps/mobile/src/i18n/locales/sw.json
index 074cfb9315..a34f9ff458 100644
--- a/apps/mobile/src/i18n/locales/sw.json
+++ b/apps/mobile/src/i18n/locales/sw.json
@@ -3202,6 +3202,7 @@
"openAgents": "Fungua mawakala",
"running": "Inaendelea",
"needsInput": "inahitaji mchango",
- "reconnecting": "Inaunganisha tena"
+ "reconnecting": "Inaunganisha tena",
+ "channelName": "Mawakala wanaofanya kazi"
}
}
diff --git a/apps/mobile/src/i18n/locales/ta.json b/apps/mobile/src/i18n/locales/ta.json
index 1888c8722e..476ce4b157 100644
--- a/apps/mobile/src/i18n/locales/ta.json
+++ b/apps/mobile/src/i18n/locales/ta.json
@@ -3202,6 +3202,7 @@
"openAgents": "முகவர்களைத் திறக்கவும்",
"running": "இயங்குகிறது",
"needsInput": "உள்ளீடு தேவை",
- "reconnecting": "மீண்டும் இணைக்கிறது"
+ "reconnecting": "மீண்டும் இணைக்கிறது",
+ "channelName": "செயலில் உள்ள முகவர்கள்"
}
}
diff --git a/apps/mobile/src/i18n/locales/te.json b/apps/mobile/src/i18n/locales/te.json
index 27fdafb1b4..78267afaa8 100644
--- a/apps/mobile/src/i18n/locales/te.json
+++ b/apps/mobile/src/i18n/locales/te.json
@@ -3202,6 +3202,7 @@
"openAgents": "ఏజెంట్లను తెరవండి",
"running": "నడుస్తోంది",
"needsInput": "ఇన్పుట్ అవసరం",
- "reconnecting": "మళ్లీ కనెక్ట్ అవుతోంది"
+ "reconnecting": "మళ్లీ కనెక్ట్ అవుతోంది",
+ "channelName": "చురుకైన ఏజెంట్లు"
}
}
diff --git a/apps/mobile/src/i18n/locales/th.json b/apps/mobile/src/i18n/locales/th.json
index be2437ae64..afc37c8ae7 100644
--- a/apps/mobile/src/i18n/locales/th.json
+++ b/apps/mobile/src/i18n/locales/th.json
@@ -3202,6 +3202,7 @@
"openAgents": "เปิดเอเจนต์",
"running": "กำลังทำงาน",
"needsInput": "ต้องป้อนข้อมูล",
- "reconnecting": "กำลังเชื่อมต่อใหม่"
+ "reconnecting": "กำลังเชื่อมต่อใหม่",
+ "channelName": "เอเจนต์ที่กำลังทำงาน"
}
}
diff --git a/apps/mobile/src/i18n/locales/tr.json b/apps/mobile/src/i18n/locales/tr.json
index 7117b3d714..9b4ad75bbf 100644
--- a/apps/mobile/src/i18n/locales/tr.json
+++ b/apps/mobile/src/i18n/locales/tr.json
@@ -3202,6 +3202,7 @@
"openAgents": "Ajanları açın",
"running": "Çalışıyor",
"needsInput": "Girdi gerekli",
- "reconnecting": "Yeniden bağlanılıyor"
+ "reconnecting": "Yeniden bağlanılıyor",
+ "channelName": "Etkin ajanlar"
}
}
diff --git a/apps/mobile/src/i18n/locales/uk.json b/apps/mobile/src/i18n/locales/uk.json
index 01b6312b42..462e15e333 100644
--- a/apps/mobile/src/i18n/locales/uk.json
+++ b/apps/mobile/src/i18n/locales/uk.json
@@ -3244,6 +3244,7 @@
"openAgents": "Відкрити агентів",
"running": "Виконується",
"needsInput": "потребує вводу",
- "reconnecting": "Повторне підключення"
+ "reconnecting": "Повторне підключення",
+ "channelName": "Активні агенти"
}
}
diff --git a/apps/mobile/src/i18n/locales/ur.json b/apps/mobile/src/i18n/locales/ur.json
index 4603f57ea1..51a4e2f11d 100644
--- a/apps/mobile/src/i18n/locales/ur.json
+++ b/apps/mobile/src/i18n/locales/ur.json
@@ -3202,6 +3202,7 @@
"openAgents": "ایجنٹس کھولیں",
"running": "چل رہا ہے",
"needsInput": "ان پٹ درکار",
- "reconnecting": "دوبارہ منسلک ہو رہا ہے"
+ "reconnecting": "دوبارہ منسلک ہو رہا ہے",
+ "channelName": "فعال ایجنٹس"
}
}
diff --git a/apps/mobile/src/i18n/locales/uz.json b/apps/mobile/src/i18n/locales/uz.json
index eaa3609da4..575f1032aa 100644
--- a/apps/mobile/src/i18n/locales/uz.json
+++ b/apps/mobile/src/i18n/locales/uz.json
@@ -3202,6 +3202,7 @@
"openAgents": "Agentlarni oching",
"running": "Ishlamoqda",
"needsInput": "kiritish kerak",
- "reconnecting": "Qayta ulanmoqda"
+ "reconnecting": "Qayta ulanmoqda",
+ "channelName": "Faol agentlar"
}
}
diff --git a/apps/mobile/src/i18n/locales/vi.json b/apps/mobile/src/i18n/locales/vi.json
index 86c8419a80..164bdaaebf 100644
--- a/apps/mobile/src/i18n/locales/vi.json
+++ b/apps/mobile/src/i18n/locales/vi.json
@@ -3202,6 +3202,7 @@
"openAgents": "Mở tác nhân",
"running": "ĐANG CHẠY",
"needsInput": "cần nhập",
- "reconnecting": "Đang kết nối lại"
+ "reconnecting": "Đang kết nối lại",
+ "channelName": "Tác nhân đang hoạt động"
}
}
diff --git a/apps/mobile/src/i18n/locales/yo.json b/apps/mobile/src/i18n/locales/yo.json
index eb769c9306..669d001c41 100644
--- a/apps/mobile/src/i18n/locales/yo.json
+++ b/apps/mobile/src/i18n/locales/yo.json
@@ -3202,6 +3202,7 @@
"openAgents": "Ṣii awọn aṣoju",
"running": "ǸJẸ́ ṢÍṢIṢẸ́",
"needsInput": "nilo igbewọle",
- "reconnecting": "Ti n tun sopọ"
+ "reconnecting": "Ti n tun sopọ",
+ "channelName": "Awọn aṣoju to n ṣiṣẹ"
}
}
diff --git a/apps/mobile/src/i18n/locales/zh-Hans.json b/apps/mobile/src/i18n/locales/zh-Hans.json
index 3fb3b32458..b3e610d7d7 100644
--- a/apps/mobile/src/i18n/locales/zh-Hans.json
+++ b/apps/mobile/src/i18n/locales/zh-Hans.json
@@ -3202,6 +3202,7 @@
"openAgents": "打开代理",
"running": "运行中",
"needsInput": "需要输入",
- "reconnecting": "正在重新连接"
+ "reconnecting": "正在重新连接",
+ "channelName": "活动代理"
}
}
diff --git a/apps/mobile/src/i18n/locales/zh-Hant.json b/apps/mobile/src/i18n/locales/zh-Hant.json
index 5d43030b65..d66c9e3e3e 100644
--- a/apps/mobile/src/i18n/locales/zh-Hant.json
+++ b/apps/mobile/src/i18n/locales/zh-Hant.json
@@ -3202,6 +3202,7 @@
"openAgents": "開啟代理",
"running": "執行中",
"needsInput": "需要輸入",
- "reconnecting": "正在重新連線"
+ "reconnecting": "正在重新連線",
+ "channelName": "使用中的代理"
}
}
diff --git a/apps/mobile/src/i18n/locales/zu.json b/apps/mobile/src/i18n/locales/zu.json
index 89fb9ef2c9..8e866a5cbc 100644
--- a/apps/mobile/src/i18n/locales/zu.json
+++ b/apps/mobile/src/i18n/locales/zu.json
@@ -3202,6 +3202,7 @@
"openAgents": "Vula ama-agent",
"running": "IYASEBENZA",
"needsInput": "idinga okokufaka",
- "reconnecting": "Ixhuma kabusha"
+ "reconnecting": "Ixhuma kabusha",
+ "channelName": "Ama-agent asebenzayo"
}
}
diff --git a/apps/mobile/src/lib/glanceable/presentation.test.ts b/apps/mobile/src/lib/glanceable/presentation.test.ts
index 87177b1294..c3112cab74 100644
--- a/apps/mobile/src/lib/glanceable/presentation.test.ts
+++ b/apps/mobile/src/lib/glanceable/presentation.test.ts
@@ -6,6 +6,7 @@ import {
} from '@kilocode/app-shared/glanceable-agents-snapshot';
import {
+ glanceableSpokenLabel,
glanceableSpokenLabelKeys,
glanceableStatusCopyKey,
primaryGlanceableCount,
@@ -106,3 +107,63 @@ describe('spoken label shape', () => {
]);
});
});
+
+describe('numeric spoken label', () => {
+ const copy: Record = {
+ 'glanceable.needsInput': 'Needs input',
+ 'glanceable.reconnecting': 'Reconnecting',
+ 'glanceable.running': 'Running',
+ 'glanceable.waiting': 'Waiting for agents',
+ 'glanceable.empty': 'No work in progress',
+ 'glanceable.stale': 'Updates delayed',
+ 'glanceable.expired': 'Status expired',
+ 'glanceable.signedOut': 'Sign in to see agents',
+ 'glanceable.privacy': 'Agents hidden',
+ 'glanceable.openAgents': 'Open agents',
+ };
+ const translate = (key: string): string => copy[key] ?? key;
+ const mixed = {
+ ...snapshot({ status: 'happy' }),
+ needsInput: 2,
+ reconnecting: 3,
+ running: 4,
+ };
+
+ it('speaks each numeric count in rank order before Open agents', () => {
+ expect(glanceableSpokenLabel(mixed, {}, translate)).toBe(
+ '2 Needs input, 3 Reconnecting, 4 Running, Open agents'
+ );
+ });
+
+ it('speaks the translated stale warning before retained numeric counts', () => {
+ expect(glanceableSpokenLabel({ ...mixed, status: 'stale' }, {}, translate)).toBe(
+ 'Updates delayed, 2 Needs input, 3 Reconnecting, 4 Running, Open agents'
+ );
+ });
+
+ it('speaks stale copy without inventing counts when none remain', () => {
+ expect(glanceableSpokenLabel(snapshot({ status: 'stale' }), {}, translate)).toBe(
+ 'Updates delayed, Open agents'
+ );
+ });
+
+ it.each([
+ ['waiting', 'Waiting for agents, Open agents'],
+ ['empty', 'No work in progress, Open agents'],
+ ['expired', 'Status expired, Open agents'],
+ ['signed_out', 'Sign in to see agents, Open agents'],
+ ['privacy', 'Agents hidden, Open agents'],
+ ] as const)('hides numeric counts when the status is %s', (status, expected) => {
+ expect(glanceableSpokenLabel({ ...mixed, status }, {}, translate)).toBe(expected);
+ });
+
+ it('keeps signed-out and privacy overrides ahead of stale counts', () => {
+ const stale = { ...mixed, status: 'stale' as const };
+ expect(glanceableSpokenLabel(stale, { signedOut: true, orgInvalid: true }, translate)).toBe(
+ 'Sign in to see agents, Open agents'
+ );
+ expect(glanceableSpokenLabel(stale, { orgInvalid: true }, translate)).toBe(
+ 'Agents hidden, Open agents'
+ );
+ });
+});
diff --git a/apps/mobile/src/lib/glanceable/presentation.ts b/apps/mobile/src/lib/glanceable/presentation.ts
index 5935d60d46..4d533ad2b6 100644
--- a/apps/mobile/src/lib/glanceable/presentation.ts
+++ b/apps/mobile/src/lib/glanceable/presentation.ts
@@ -105,3 +105,23 @@ export function glanceableSpokenLabelKeys(
parts.push('glanceable.openAgents');
return parts;
}
+
+/** Translated status, numeric counts in rank order, then the Open agents action. */
+export function glanceableSpokenLabel(
+ snapshot: GlanceableAgentsSnapshot,
+ flags: GlanceableSurfaceFlags,
+ translate: (key: string) => string
+): string {
+ const status = resolveGlanceableStatus(snapshot, flags);
+ const parts: string[] = [];
+ if (status !== 'happy') {
+ parts.push(translate(GLANCEABLE_STATUS_COPY_KEY[status]));
+ }
+ if (status === 'happy' || status === 'stale') {
+ for (const { key, count } of glanceableCountLines(snapshot)) {
+ parts.push(`${count} ${translate(key)}`);
+ }
+ }
+ parts.push(translate('glanceable.openAgents'));
+ return parts.join(', ');
+}
diff --git a/apps/mobile/vitest.pure.config.ts b/apps/mobile/vitest.pure.config.ts
index e3421f7196..d367647a9b 100644
--- a/apps/mobile/vitest.pure.config.ts
+++ b/apps/mobile/vitest.pure.config.ts
@@ -29,6 +29,7 @@ export default defineProject({
'src/lib/apple-iap/**/*.test.tsx',
'src/lib/glanceable/**/*.test.ts',
'src/glanceable-ios/**/*.test.ts',
+ 'src/glanceable-android/**/*.test.ts',
'src/lib/hooks/**/*.test.ts',
'src/lib/kilo-pass/**/*.test.ts',
'src/lib/kilo-pass/**/*.test.tsx',