diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 0cb550059e3..629cf4d815c 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -134,6 +134,7 @@ const config: ExpoConfig = { "expo-router", "expo-asset", "expo-font", + ["expo-notifications", { defaultChannel: "agent-activity" }], "expo-secure-store", ["@clerk/expo", { theme: "./clerk-theme.json", appleSignIn: !isIosPersonalTeamBuild }], "expo-web-browser", diff --git a/apps/mobile/modules/t3-native-controls/android/build.gradle b/apps/mobile/modules/t3-native-controls/android/build.gradle index ba0622b7f0e..b3959cccf37 100644 --- a/apps/mobile/modules/t3-native-controls/android/build.gradle +++ b/apps/mobile/modules/t3-native-controls/android/build.gradle @@ -15,5 +15,6 @@ android { } dependencies { + implementation 'androidx.core:core:1.18.0' implementation project(':expo-modules-core') } diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/AndroidManifest.xml b/apps/mobile/modules/t3-native-controls/android/src/main/AndroidManifest.xml index 94cbbcfc396..f87a9a3f7d4 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/AndroidManifest.xml +++ b/apps/mobile/modules/t3-native-controls/android/src/main/AndroidManifest.xml @@ -1 +1,18 @@ - + + + + + + + + + + + + diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3AgentActivityForegroundService.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3AgentActivityForegroundService.kt new file mode 100644 index 00000000000..4587ef267dd --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3AgentActivityForegroundService.kt @@ -0,0 +1,655 @@ +package expo.modules.t3nativecontrols + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.graphics.Color +import android.net.Uri +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.graphics.drawable.IconCompat +import org.json.JSONArray +import org.json.JSONObject + +class T3AgentActivityForegroundService : Service() { + private var serviceStartedAtMillis: Long = 0 + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + cancelOngoingAgentActivityNotifications() + super.onDestroy() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_STOP) { + serviceStartedAtMillis = 0 + stopForegroundCompat() + cancelOngoingAgentActivityNotifications() + stopSelf() + return START_NOT_STICKY + } + + if (serviceStartedAtMillis == 0L) { + serviceStartedAtMillis = System.currentTimeMillis() + } + + ensureNotificationChannel() + val fallbackTitle = + intent?.getStringExtra(EXTRA_FALLBACK_TITLE)?.takeIf { it.isNotBlank() } ?: DEFAULT_TITLE + val fallbackBody = + intent?.getStringExtra(EXTRA_FALLBACK_BODY)?.takeIf { it.isNotBlank() } ?: DEFAULT_BODY + val fallbackChipText = intent + ?.getStringExtra(EXTRA_FALLBACK_CHIP_TEXT) + ?.takeIf { it.isNotBlank() } + ?: DEFAULT_CHIP_TEXT + val entries = parseNotificationEntries(intent?.getStringExtra(EXTRA_NOTIFICATIONS_JSON)) + syncAgentActivityNotifications(fallbackTitle, fallbackBody, fallbackChipText, entries) + return START_NOT_STICKY + } + + private fun startForegroundCompat(notificationId: Int, notification: Notification) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + notificationId, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + return + } + startForeground(notificationId, notification) + } + + private fun stopForegroundCompat() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + return + } + + @Suppress("DEPRECATION") + stopForeground(true) + } + + private fun ensureNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + + val manager = getSystemService(NotificationManager::class.java) + val existing = manager.getNotificationChannel(SERVICE_CHANNEL_ID) + if (existing != null) { + return + } + + val channel = NotificationChannel( + SERVICE_CHANNEL_ID, + SERVICE_CHANNEL_NAME, + NotificationManager.IMPORTANCE_LOW, + ) + channel.description = SERVICE_CHANNEL_DESCRIPTION + channel.setShowBadge(false) + manager.createNotificationChannel(channel) + } + + private fun syncAgentActivityNotifications( + fallbackTitle: String, + fallbackBody: String, + fallbackChipText: String, + entries: List, + ) { + val manager = getSystemService(NotificationManager::class.java) + val acknowledgedTerminalKeys = + T3AgentActivityNotificationStore.pruneAcknowledgedTerminalKeys( + this, + entries.filter { !it.ongoing }.map { it.acknowledgementKey }.toSet(), + ) + val visibleEntries = entries.filter { entry -> + entry.ongoing || !acknowledgedTerminalKeys.contains(entry.acknowledgementKey) + } + val shouldShowGroupSummary = visibleEntries.size > 1 + val shouldUseAggregateForeground = visibleEntries.size != 1 || !visibleEntries[0].ongoing + val visibleProgress = progressSnapshotForEntries(visibleEntries) + val visibleThreadSummary = threadUpdateSummaryText(visibleEntries) + val aggregateForegroundEntry = AgentActivityNotificationEntry( + key = LIVE_NOTIFICATION_KEY, + acknowledgementKey = LIVE_NOTIFICATION_KEY, + title = fallbackTitle, + body = if (visibleEntries.isEmpty()) { + fallbackBody + } else { + visibleThreadSummary + }, + chipText = fallbackChipText, + phase = AgentActivityPhase.RUNNING, + deepLinkUrl = null, + subText = null, + ongoing = true, + requestPromoted = visibleEntries.isNotEmpty(), + occurredAtMillis = null, + progress = visibleProgress, + ) + val groupSummaryEntry = AgentActivityNotificationEntry( + key = GROUP_SUMMARY_NOTIFICATION_KEY, + acknowledgementKey = GROUP_SUMMARY_NOTIFICATION_KEY, + title = fallbackTitle, + body = visibleThreadSummary, + chipText = fallbackChipText, + phase = AgentActivityPhase.RUNNING, + deepLinkUrl = null, + subText = null, + ongoing = true, + requestPromoted = false, + occurredAtMillis = null, + progress = visibleProgress, + ) + + val foregroundEntry = if (shouldUseAggregateForeground) { + aggregateForegroundEntry + } else { + visibleEntries[0].copy(progress = visibleProgress) + } + val foregroundNotificationId = notificationIdForEntry(foregroundEntry) + val activeNotificationIds = mutableSetOf(foregroundNotificationId) + val activeOngoingNotificationIds = mutableSetOf(foregroundNotificationId) + val activeTerminalNotificationIds = mutableSetOf() + startForegroundCompat( + foregroundNotificationId, + buildNotification( + foregroundEntry, + foregroundNotificationId, + groupBehavior = NotificationGroupBehavior.NONE, + ), + ) + + if (shouldShowGroupSummary) { + val groupSummaryNotificationId = notificationIdForEntry(groupSummaryEntry) + activeNotificationIds.add(groupSummaryNotificationId) + activeOngoingNotificationIds.add(groupSummaryNotificationId) + manager.notify( + groupSummaryNotificationId, + buildNotification( + groupSummaryEntry, + groupSummaryNotificationId, + groupBehavior = NotificationGroupBehavior.SUMMARY, + ), + ) + } + + for (entry in visibleEntries) { + if (entry.key == foregroundEntry.key) { + continue + } + val notificationId = notificationIdForEntry(entry) + activeNotificationIds.add(notificationId) + if (entry.ongoing) { + activeOngoingNotificationIds.add(notificationId) + } else { + activeTerminalNotificationIds.add(notificationId) + } + manager.notify( + notificationId, + buildNotification( + if (shouldShowGroupSummary) { + entry.copy(requestPromoted = false, progress = null) + } else { + entry + }, + notificationId, + groupBehavior = if (shouldShowGroupSummary) { + NotificationGroupBehavior.CHILD + } else { + NotificationGroupBehavior.NONE + }, + ), + ) + } + + cancelStaleAgentActivityNotifications(manager, activeNotificationIds) + T3AgentActivityNotificationStore.writePostedOngoingNotificationIds( + this, + activeOngoingNotificationIds, + ) + T3AgentActivityNotificationStore.writePostedTerminalNotificationIds( + this, + activeTerminalNotificationIds, + ) + } + + private fun cancelStaleAgentActivityNotifications( + manager: NotificationManager, + activeNotificationIds: Set, + ) { + val previouslyPostedIds = + T3AgentActivityNotificationStore.readPostedOngoingNotificationIds(this) + + T3AgentActivityNotificationStore.readPostedTerminalNotificationIds(this) + + T3AgentActivityNotificationStore.readLegacyPostedNotificationIds(this) + + for (notificationId in previouslyPostedIds) { + if (!activeNotificationIds.contains(notificationId)) { + manager.cancel(notificationId) + } + } + if (!activeNotificationIds.contains(LIVE_NOTIFICATION_ID)) { + manager.cancel(LIVE_NOTIFICATION_ID) + } + if (!activeNotificationIds.contains(GROUP_SUMMARY_NOTIFICATION_ID)) { + manager.cancel(GROUP_SUMMARY_NOTIFICATION_ID) + } + T3AgentActivityNotificationStore.clearLegacyPostedNotificationIds(this) + } + + private fun cancelOngoingAgentActivityNotifications() { + val manager = getSystemService(NotificationManager::class.java) + for (notificationId in T3AgentActivityNotificationStore.readPostedOngoingNotificationIds(this)) { + manager.cancel(notificationId) + } + manager.cancel(LIVE_NOTIFICATION_ID) + manager.cancel(GROUP_SUMMARY_NOTIFICATION_ID) + T3AgentActivityNotificationStore.clearPostedOngoingNotificationIds(this) + } + + private fun buildNotification( + entry: AgentActivityNotificationEntry, + notificationId: Int, + groupBehavior: NotificationGroupBehavior, + ): Notification { + val contentIntent = if (entry.ongoing) { + buildActivityContentIntent(entry, notificationId) + } else { + buildTerminalNotificationActionIntent( + T3AgentActivityNotificationActionReceiver.ACTION_OPEN, + entry, + notificationId, + ) + } + + val builder = NotificationCompat.Builder(this, SERVICE_CHANNEL_ID) + .setSmallIcon(resolveSmallIcon()) + .setContentTitle(entry.title) + .setContentText(entry.body) + .setLocalOnly(true) + .setOnlyAlertOnce(true) + .setShowWhen(true) + .setWhen(entry.occurredAtMillis ?: serviceStartedAtMillis) + .setShortCriticalText(entry.chipText.take(LIVE_UPDATE_CHIP_TEXT_MAX_LENGTH)) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + + if (groupBehavior != NotificationGroupBehavior.NONE) { + builder.setGroup(AGENT_ACTIVITY_NOTIFICATION_GROUP_KEY) + } + + if (!entry.subText.isNullOrBlank()) { + builder.setSubText(entry.subText) + } + if (groupBehavior == NotificationGroupBehavior.SUMMARY) { + builder.setGroupSummary(true) + } + + if (entry.ongoing) { + val progressStyle = progressStyleForEntry(entry) + if (progressStyle != null) { + builder.setStyle(progressStyle) + } + builder + .setOngoing(true) + .setCategory( + if (entry.requestPromoted) { + NotificationCompat.CATEGORY_PROGRESS + } else { + NotificationCompat.CATEGORY_SERVICE + }, + ) + .setPriority( + if (entry.requestPromoted) { + NotificationCompat.PRIORITY_DEFAULT + } else { + NotificationCompat.PRIORITY_LOW + }, + ) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + + if (entry.requestPromoted) { + builder.setRequestPromotedOngoing(true) + } + } else { + builder + .setStyle(NotificationCompat.BigTextStyle().bigText(entry.body)) + .setAutoCancel(true) + .setOngoing(false) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setDeleteIntent( + buildTerminalNotificationActionIntent( + T3AgentActivityNotificationActionReceiver.ACTION_DISMISSED, + entry, + notificationId, + ), + ) + } + + if (contentIntent == null) { + return builder.build() + } + + builder.setContentIntent(contentIntent) + return builder.build() + } + + private fun progressStyleForEntry( + entry: AgentActivityNotificationEntry, + ): NotificationCompat.ProgressStyle? { + val progress = entry.progress + if (progress != null && progress.total > 0) { + val style = NotificationCompat.ProgressStyle() + .setProgress(progress.completed.coerceIn(0, progress.total)) + .setProgressIndeterminate(false) + .setStyledByProgress(true) + .setProgressTrackerIcon( + IconCompat.createWithResource(this, R.drawable.ic_t3_notification_tracker), + ) + for (phase in progress.phases) { + style.addProgressSegment( + NotificationCompat.ProgressStyle.Segment(1).setColor(progressSegmentColorForPhase(phase)), + ) + } + return style + } + + if (!entry.requestPromoted) { + return null + } + + return NotificationCompat.ProgressStyle() + .setProgressIndeterminate(true) + .setStyledByProgress(true) + .setProgressTrackerIcon( + IconCompat.createWithResource(this, R.drawable.ic_t3_notification_tracker), + ) + } + + private fun buildActivityContentIntent( + entry: AgentActivityNotificationEntry, + notificationId: Int, + ): PendingIntent? { + val launchIntent = packageManager.getLaunchIntentForPackage(packageName) + val targetIntent = entry.deepLinkUrl?.let { deepLinkUrl -> + Intent(Intent.ACTION_VIEW, Uri.parse(deepLinkUrl)).apply { + addCategory(Intent.CATEGORY_BROWSABLE) + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + setPackage(packageName) + } + } ?: launchIntent?.apply { + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + + return targetIntent?.let { intent -> + PendingIntent.getActivity( + this, + notificationId, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + } + + private fun buildTerminalNotificationActionIntent( + action: String, + entry: AgentActivityNotificationEntry, + notificationId: Int, + ): PendingIntent { + val requestCode = notificationId + + if (action == T3AgentActivityNotificationActionReceiver.ACTION_OPEN) { + OPEN_REQUEST_CODE_OFFSET + } else { + DISMISS_REQUEST_CODE_OFFSET + } + val intent = Intent(this, T3AgentActivityNotificationActionReceiver::class.java).apply { + this.action = action + putExtra(T3AgentActivityNotificationActionReceiver.EXTRA_NOTIFICATION_ID, notificationId) + putExtra( + T3AgentActivityNotificationActionReceiver.EXTRA_ACKNOWLEDGEMENT_KEY, + entry.acknowledgementKey, + ) + if (!entry.deepLinkUrl.isNullOrBlank()) { + putExtra(T3AgentActivityNotificationActionReceiver.EXTRA_DEEP_LINK_URL, entry.deepLinkUrl) + } + } + + return PendingIntent.getBroadcast( + this, + requestCode, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun resolveSmallIcon(): Int { + return R.drawable.ic_t3_notification + } + + private fun parseNotificationEntries(raw: String?): List { + if (raw.isNullOrBlank()) { + return emptyList() + } + + val array = try { + JSONArray(raw) + } catch (_: Exception) { + return emptyList() + } + val entries = mutableListOf() + for (index in 0 until array.length()) { + val item = array.optJSONObject(index) ?: continue + val key = item.nonBlankString("key") ?: continue + val title = item.nonBlankString("title") ?: continue + val body = item.nonBlankString("body") ?: continue + val chipText = item.nonBlankString("chipText") ?: continue + val ongoing = item.optBoolean("ongoing", true) + entries.add( + AgentActivityNotificationEntry( + key = key, + acknowledgementKey = item.nonBlankString("acknowledgementKey") ?: key, + title = title, + body = body, + chipText = chipText, + phase = AgentActivityPhase.fromWireValue(item.nonBlankString("phase"), ongoing), + deepLinkUrl = item.nonBlankString("deepLinkUrl"), + subText = item.nonBlankString("subText"), + ongoing = ongoing, + requestPromoted = ongoing, + occurredAtMillis = item.longOrNull("updatedAtMillis"), + progress = null, + ), + ) + } + return entries + } + + private fun notificationIdForEntry(entry: AgentActivityNotificationEntry): Int { + if (entry.key == LIVE_NOTIFICATION_KEY) { + return LIVE_NOTIFICATION_ID + } + if (entry.key == GROUP_SUMMARY_NOTIFICATION_KEY) { + return GROUP_SUMMARY_NOTIFICATION_ID + } + return THREAD_NOTIFICATION_ID_OFFSET + (entry.key.hashCode() and THREAD_NOTIFICATION_ID_MASK) + } + + private fun progressSnapshotForEntries( + entries: List, + ): AgentActivityProgressSnapshot? { + if (entries.isEmpty()) { + return null + } + return AgentActivityProgressSnapshot( + completed = entries.count { !it.ongoing }, + phases = entries.sortedWith(AGENT_ACTIVITY_PROGRESS_ENTRY_COMPARATOR).map { it.phase }, + ) + } + + private fun progressSegmentColorForPhase(phase: AgentActivityPhase): Int = + when (phase) { + AgentActivityPhase.WAITING_FOR_APPROVAL, + AgentActivityPhase.WAITING_FOR_INPUT -> PROGRESS_SEGMENT_REVIEW + AgentActivityPhase.FAILED -> PROGRESS_SEGMENT_FAILED + AgentActivityPhase.COMPLETED -> PROGRESS_SEGMENT_COMPLETED + AgentActivityPhase.RUNNING -> PROGRESS_SEGMENT_RUNNING + AgentActivityPhase.STARTING -> PROGRESS_SEGMENT_STARTING + AgentActivityPhase.STALE -> PROGRESS_SEGMENT_STALE + } + + private fun threadUpdateSummaryText(entries: List): String { + if (entries.isEmpty()) { + return DEFAULT_BODY + } + + val titles = entries.map { it.title.trim() }.filter { it.isNotEmpty() } + if (titles.isEmpty()) { + return DEFAULT_BODY + } + + val visibleTitles = titles.take(MAX_THREAD_SUMMARY_TITLES) + val remainingCount = titles.size - visibleTitles.size + val summary = visibleTitles.joinToString(", ") + if (remainingCount <= 0) { + return summary + } + return "$summary, +$remainingCount more" + } + + private fun JSONObject.nonBlankString(name: String): String? = + optString(name).takeIf { it.isNotBlank() } + + private fun JSONObject.longOrNull(name: String): Long? { + if (!has(name) || isNull(name)) { + return null + } + return optLong(name).takeIf { it > 0L } + } + + companion object { + private const val ACTION_START = "expo.modules.t3nativecontrols.AGENT_ACTIVITY_START" + private const val ACTION_STOP = "expo.modules.t3nativecontrols.AGENT_ACTIVITY_STOP" + private const val EXTRA_FALLBACK_TITLE = "fallbackTitle" + private const val EXTRA_FALLBACK_BODY = "fallbackBody" + private const val EXTRA_FALLBACK_CHIP_TEXT = "fallbackChipText" + private const val EXTRA_NOTIFICATIONS_JSON = "notificationsJson" + private const val LIVE_NOTIFICATION_KEY = "agent-activity-live" + private const val GROUP_SUMMARY_NOTIFICATION_KEY = "agent-activity-summary" + private const val LIVE_NOTIFICATION_ID = 4103 + private const val GROUP_SUMMARY_NOTIFICATION_ID = 4102 + private const val THREAD_NOTIFICATION_ID_OFFSET = 4104 + private const val THREAD_NOTIFICATION_ID_MASK = 0x00ffffff + private const val OPEN_REQUEST_CODE_OFFSET = 0x10000000 + private const val DISMISS_REQUEST_CODE_OFFSET = 0x20000000 + private const val AGENT_ACTIVITY_NOTIFICATION_GROUP_KEY = + "expo.modules.t3nativecontrols.AGENT_ACTIVITY_NOTIFICATIONS" + private const val SERVICE_CHANNEL_ID = "agent-activity-service" + private const val SERVICE_CHANNEL_NAME = "Agent Activity Connection" + private const val SERVICE_CHANNEL_DESCRIPTION = + "Keeps T3 Code connected to local agent updates while the app is in the background." + private const val DEFAULT_TITLE = "T3 Code" + private const val DEFAULT_BODY = "Agent updates are connected." + private const val DEFAULT_CHIP_TEXT = "T3Code" + private const val LIVE_UPDATE_CHIP_TEXT_MAX_LENGTH = 6 + private const val MAX_THREAD_SUMMARY_TITLES = 2 + private val PROGRESS_SEGMENT_COMPLETED = Color.rgb(230, 255, 244) + private val PROGRESS_SEGMENT_RUNNING = Color.rgb(56, 189, 248) + private val PROGRESS_SEGMENT_REVIEW = Color.rgb(251, 191, 36) + private val PROGRESS_SEGMENT_FAILED = Color.rgb(248, 113, 113) + private val PROGRESS_SEGMENT_STARTING = Color.rgb(148, 163, 184) + private val PROGRESS_SEGMENT_STALE = Color.rgb(100, 116, 139) + private val AGENT_ACTIVITY_PROGRESS_ENTRY_COMPARATOR = + compareByDescending { !it.ongoing } + .thenBy { it.phase.progressOrder } + + fun start( + context: Context, + fallbackTitle: String, + fallbackBody: String, + fallbackChipText: String, + notificationsJson: String, + ) { + val appContext = context.applicationContext + val intent = Intent(appContext, T3AgentActivityForegroundService::class.java).apply { + action = ACTION_START + putExtra(EXTRA_FALLBACK_TITLE, fallbackTitle) + putExtra(EXTRA_FALLBACK_BODY, fallbackBody) + putExtra(EXTRA_FALLBACK_CHIP_TEXT, fallbackChipText) + putExtra(EXTRA_NOTIFICATIONS_JSON, notificationsJson) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + appContext.startForegroundService(intent) + return + } + + appContext.startService(intent) + } + + fun stop(context: Context) { + val appContext = context.applicationContext + appContext.stopService(Intent(appContext, T3AgentActivityForegroundService::class.java)) + } + } + + private data class AgentActivityNotificationEntry( + val key: String, + val acknowledgementKey: String, + val title: String, + val body: String, + val chipText: String, + val phase: AgentActivityPhase, + val deepLinkUrl: String?, + val subText: String?, + val ongoing: Boolean, + val requestPromoted: Boolean, + val occurredAtMillis: Long?, + val progress: AgentActivityProgressSnapshot?, + ) + + private data class AgentActivityProgressSnapshot( + val completed: Int, + val phases: List, + ) { + val total: Int + get() = phases.size + } + + private enum class AgentActivityPhase(val wireValue: String, val progressOrder: Int) { + COMPLETED("completed", 0), + FAILED("failed", 1), + WAITING_FOR_APPROVAL("waiting_for_approval", 2), + WAITING_FOR_INPUT("waiting_for_input", 3), + RUNNING("running", 4), + STARTING("starting", 5), + STALE("stale", 6); + + companion object { + fun fromWireValue(value: String?, ongoing: Boolean): AgentActivityPhase { + for (phase in entries) { + if (phase.wireValue == value) { + return phase + } + } + return if (ongoing) { + RUNNING + } else { + COMPLETED + } + } + } + } + + private enum class NotificationGroupBehavior { + NONE, + CHILD, + SUMMARY, + } +} diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3AgentActivityNotificationActionReceiver.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3AgentActivityNotificationActionReceiver.kt new file mode 100644 index 00000000000..57628054436 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3AgentActivityNotificationActionReceiver.kt @@ -0,0 +1,65 @@ +package expo.modules.t3nativecontrols + +import android.app.NotificationManager +import android.content.ActivityNotFoundException +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.net.Uri + +class T3AgentActivityNotificationActionReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1) + val acknowledgementKey = intent.getStringExtra(EXTRA_ACKNOWLEDGEMENT_KEY).orEmpty() + T3AgentActivityNotificationStore.markTerminalAcknowledged(context, acknowledgementKey) + + if (notificationId > 0) { + context.getSystemService(NotificationManager::class.java).cancel(notificationId) + T3AgentActivityNotificationStore.removePostedTerminalNotificationId(context, notificationId) + } + + if (intent.action == ACTION_OPEN) { + openApplication(context, intent.getStringExtra(EXTRA_DEEP_LINK_URL)) + } + } + + private fun openApplication(context: Context, deepLinkUrl: String?) { + val launchIntent = if (!deepLinkUrl.isNullOrBlank()) { + Intent(Intent.ACTION_VIEW, Uri.parse(deepLinkUrl)).apply { + addCategory(Intent.CATEGORY_BROWSABLE) + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP or + Intent.FLAG_ACTIVITY_CLEAR_TOP, + ) + setPackage(context.packageName) + } + } else { + context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply { + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP or + Intent.FLAG_ACTIVITY_CLEAR_TOP, + ) + } + } ?: return + + try { + context.startActivity(launchIntent) + } catch (_: ActivityNotFoundException) { + context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(this) + } + } + } + + companion object { + const val ACTION_OPEN = "expo.modules.t3nativecontrols.AGENT_ACTIVITY_NOTIFICATION_OPEN" + const val ACTION_DISMISSED = + "expo.modules.t3nativecontrols.AGENT_ACTIVITY_NOTIFICATION_DISMISSED" + const val EXTRA_NOTIFICATION_ID = "notificationId" + const val EXTRA_ACKNOWLEDGEMENT_KEY = "acknowledgementKey" + const val EXTRA_DEEP_LINK_URL = "deepLinkUrl" + } +} diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3AgentActivityNotificationStore.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3AgentActivityNotificationStore.kt new file mode 100644 index 00000000000..c22cec1de9f --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3AgentActivityNotificationStore.kt @@ -0,0 +1,101 @@ +package expo.modules.t3nativecontrols + +import android.content.Context + +object T3AgentActivityNotificationStore { + fun readPostedOngoingNotificationIds(context: Context): Set = + readIntSet(context, PREFERENCE_POSTED_ONGOING_NOTIFICATION_IDS) + + fun writePostedOngoingNotificationIds(context: Context, notificationIds: Set) { + writeIntSet(context, PREFERENCE_POSTED_ONGOING_NOTIFICATION_IDS, notificationIds) + } + + fun clearPostedOngoingNotificationIds(context: Context) { + preferences(context) + .edit() + .remove(PREFERENCE_POSTED_ONGOING_NOTIFICATION_IDS) + .apply() + } + + fun readPostedTerminalNotificationIds(context: Context): Set = + readIntSet(context, PREFERENCE_POSTED_TERMINAL_NOTIFICATION_IDS) + + fun writePostedTerminalNotificationIds(context: Context, notificationIds: Set) { + writeIntSet(context, PREFERENCE_POSTED_TERMINAL_NOTIFICATION_IDS, notificationIds) + } + + fun removePostedTerminalNotificationId(context: Context, notificationId: Int) { + writePostedTerminalNotificationIds( + context, + readPostedTerminalNotificationIds(context) - notificationId, + ) + } + + fun readLegacyPostedNotificationIds(context: Context): Set = + readIntSet(context, PREFERENCE_LEGACY_POSTED_NOTIFICATION_IDS) + + fun clearLegacyPostedNotificationIds(context: Context) { + preferences(context) + .edit() + .remove(PREFERENCE_LEGACY_POSTED_NOTIFICATION_IDS) + .apply() + } + + fun markTerminalAcknowledged(context: Context, acknowledgementKey: String) { + if (acknowledgementKey.isBlank()) { + return + } + preferences(context) + .edit() + .putStringSet( + PREFERENCE_ACKNOWLEDGED_TERMINAL_KEYS, + readAcknowledgedTerminalKeys(context) + acknowledgementKey, + ) + .apply() + } + + fun pruneAcknowledgedTerminalKeys( + context: Context, + currentTerminalAcknowledgementKeys: Set, + ): Set { + val retained = readAcknowledgedTerminalKeys(context).intersect(currentTerminalAcknowledgementKeys) + if (retained.size != readAcknowledgedTerminalKeys(context).size) { + preferences(context) + .edit() + .putStringSet(PREFERENCE_ACKNOWLEDGED_TERMINAL_KEYS, retained) + .apply() + } + return retained + } + + private fun readAcknowledgedTerminalKeys(context: Context): Set = + preferences(context) + .getStringSet(PREFERENCE_ACKNOWLEDGED_TERMINAL_KEYS, emptySet()) + ?.toSet() + ?: emptySet() + + private fun readIntSet(context: Context, preferenceName: String): Set = + preferences(context) + .getStringSet(preferenceName, emptySet()) + ?.mapNotNull { it.toIntOrNull() } + ?.toSet() + ?: emptySet() + + private fun writeIntSet(context: Context, preferenceName: String, values: Set) { + preferences(context) + .edit() + .putStringSet(preferenceName, values.map { it.toString() }.toSet()) + .apply() + } + + private fun preferences(context: Context) = + context.applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + + private const val PREFERENCES_NAME = "t3_agent_activity_notifications" + private const val PREFERENCE_POSTED_ONGOING_NOTIFICATION_IDS = + "posted_ongoing_notification_ids" + private const val PREFERENCE_POSTED_TERMINAL_NOTIFICATION_IDS = + "posted_terminal_notification_ids" + private const val PREFERENCE_LEGACY_POSTED_NOTIFICATION_IDS = "posted_notification_ids" + private const val PREFERENCE_ACKNOWLEDGED_TERMINAL_KEYS = "acknowledged_terminal_keys" +} diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt index 4d5f02aaa9c..4812f4ed23c 100644 --- a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt @@ -7,6 +7,40 @@ class T3NativeControlsModule : Module() { override fun definition() = ModuleDefinition { Name("T3NativeControls") + AsyncFunction("startAgentActivityForegroundServiceAsync") { + fallbackTitle: String, + fallbackBody: String, + fallbackChipText: String, + notificationsJson: String, + -> + T3AgentActivityForegroundService.start( + requireReactContext(), + fallbackTitle, + fallbackBody, + fallbackChipText, + notificationsJson, + ) + } + + AsyncFunction("updateAgentActivityForegroundServiceAsync") { + fallbackTitle: String, + fallbackBody: String, + fallbackChipText: String, + notificationsJson: String, + -> + T3AgentActivityForegroundService.start( + requireReactContext(), + fallbackTitle, + fallbackBody, + fallbackChipText, + notificationsJson, + ) + } + + AsyncFunction("stopAgentActivityForegroundServiceAsync") { + T3AgentActivityForegroundService.stop(requireReactContext()) + } + View(T3HeaderButtonView::class) { Prop("label") { view: T3HeaderButtonView, label: String -> view.setLabel(label) @@ -18,4 +52,7 @@ class T3NativeControlsModule : Module() { Events("onTriggered") } } + + private fun requireReactContext() = + appContext.reactContext ?: throw IllegalStateException("App context is not available") } diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/ic_t3_notification.xml b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/ic_t3_notification.xml new file mode 100644 index 00000000000..3def8b4a230 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/ic_t3_notification.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/ic_t3_notification_tracker.xml b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/ic_t3_notification_tracker.xml new file mode 100644 index 00000000000..f092ed61af9 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/res/drawable/ic_t3_notification_tracker.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index ca76cda982f..0a026e2cf80 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -26,6 +26,7 @@ import { useClerkSettingsSheetDetent, } from "../features/cloud/ClerkSettingsSheetDetent"; import { useAgentNotificationNavigation } from "../features/agent-awareness/notificationNavigation"; +import { useAndroidLocalAgentActivityUpdates } from "../features/agent-awareness/useAndroidLocalAgentActivityUpdates"; import { AdaptiveWorkspaceLayout, useAdaptiveWorkspaceLayout, @@ -51,6 +52,7 @@ function AppNavigatorContent() { const colorScheme = useColorScheme(); const statusBarBg = useThemeColor("--color-status-bar"); useAgentNotificationNavigation(); + useAndroidLocalAgentActivityUpdates(); useThreadOutboxDrain(); if (state.isLoadingConnections) { diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx index fc78679ff2c..5192cf26b45 100644 --- a/apps/mobile/src/app/settings/index.tsx +++ b/apps/mobile/src/app/settings/index.tsx @@ -27,6 +27,7 @@ import { } from "../../features/cloud/publicConfig"; import { useAdaptiveWorkspaceLayout } from "../../features/layout/AdaptiveWorkspaceLayout"; import { WorkspaceSidebarToolbar } from "../../features/layout/workspace-sidebar-toolbar"; +import type { SavedRemoteConnection } from "../../lib/connection"; import { runtime } from "../../lib/runtime"; import { loadPreferences } from "../../lib/storage"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -60,7 +61,8 @@ export default function SettingsRouteScreen() { function LocalSettingsRouteScreen() { const insets = useSafeAreaInsets(); const { savedConnectionsById } = useSavedRemoteConnections(); - const environmentCount = Object.keys(savedConnectionsById).length; + const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]); + const environmentCount = connections.length; return ( @@ -83,6 +85,9 @@ function LocalSettingsRouteScreen() { value={`${environmentCount}`} href="/settings/environments" /> + {Platform.OS === "android" ? ( + + ) : null} @@ -112,7 +117,7 @@ function ConfiguredSettingsRouteScreen() { }, [isLoaded, isSignedIn, user?.primaryEmailAddress?.emailAddress]); const refreshNotifications = useCallback(async () => { - if (process.env.EXPO_OS !== "ios") { + if (Platform.OS !== "ios") { setNotificationStatus("unsupported"); return; } @@ -378,22 +383,28 @@ function ConfiguredSettingsRouteScreen() { value={`${environmentCount}`} href="/settings/environments" /> - - + {Platform.OS === "android" ? ( + + ) : ( + <> + + + + )} @@ -404,6 +415,196 @@ function ConfiguredSettingsRouteScreen() { ); } +function AndroidLocalAgentActivitySettingsRows(props: { + readonly connections: ReadonlyArray; +}) { + const [notificationStatus, setNotificationStatus] = useState("checking"); + const [activityStatus, setActivityStatus] = useState("checking"); + const activitySwitchIsEnabled = + activityStatus === "linking" || + (activityStatus === "enabled" && notificationStatus === "enabled"); + + const refreshNotifications = useCallback(async () => { + const result = await settlePromise(() => Notifications.getPermissionsAsync()); + if (result._tag === "Failure") { + reportAtomCommandResult(result, { label: "android notification permission refresh" }); + setNotificationStatus("disabled"); + return; + } + setNotificationStatus(result.value.granted ? "enabled" : "disabled"); + }, []); + + useEffect(() => { + void refreshNotifications(); + }, [refreshNotifications]); + + useEffect(() => { + void (async () => { + const result = await settlePromise(() => loadPreferences()); + if (result._tag === "Failure") { + reportAtomCommandResult(result, { label: "android agent activity preference load" }); + setActivityStatus("disabled"); + return; + } + setActivityStatus(result.value.liveActivitiesEnabled === false ? "disabled" : "enabled"); + })(); + }, []); + + const requestNotifications = useCallback( + async (options?: { readonly showGrantedAlert?: boolean }): Promise => { + const result = await settleAsyncResult(() => + runtime.runPromiseExit(requestAgentNotificationPermission), + ); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Alert.alert( + "Notifications unavailable", + error instanceof Error ? error.message : "Could not request notification permission.", + ); + } + return false; + } + if (result.value.type === "granted") { + setNotificationStatus("enabled"); + if (options?.showGrantedAlert !== false) { + Alert.alert( + "Notifications enabled", + "Agent Activity notifications are enabled for this device.", + ); + } + return true; + } + if (result.value.type === "unsupported") { + setNotificationStatus("unsupported"); + Alert.alert( + "Notifications unavailable", + "Agent Activity notifications are not available on this platform.", + ); + return false; + } + setNotificationStatus("disabled"); + if (result.value.canAskAgain) { + Alert.alert("Notifications disabled", "Notifications were not enabled."); + return false; + } + Alert.alert( + "Notifications disabled", + "Notifications were denied for this app. Open Settings to enable them.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Open Settings", onPress: () => void Linking.openSettings() }, + ], + ); + return false; + }, + [], + ); + + const handleDeviceNotificationsChange = useCallback( + (enabled: boolean) => { + if (enabled) { + void requestNotifications(); + return; + } + + Alert.alert( + "Disable notifications", + "Notification permission is controlled by Android. Open Settings to disable notifications for T3 Code.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Open Settings", onPress: () => void Linking.openSettings() }, + ], + ); + }, + [requestNotifications], + ); + + const saveLocalAgentActivityPreference = useCallback( + async (enabled: boolean) => { + const updateResult = await settleAsyncResult(() => + runtime.runPromiseExit( + setLiveActivityUpdatesEnabled({ + enabled, + clerkToken: null, + connections: props.connections, + }), + ), + ); + if (updateResult._tag === "Failure") { + setActivityStatus(enabled ? "disabled" : "enabled"); + if (!isAtomCommandInterrupted(updateResult)) { + const error = squashAtomCommandFailure(updateResult); + Alert.alert( + "Agent Activity unavailable", + error instanceof Error ? error.message : "Could not update Agent Activity settings.", + ); + } + return false; + } + return true; + }, + [props.connections], + ); + + const handleAgentActivityChange = useCallback( + (enabled: boolean) => { + if (!enabled) { + setActivityStatus("disabled"); + void saveLocalAgentActivityPreference(false); + return; + } + + void (async () => { + setActivityStatus("linking"); + const notificationsEnabled = + notificationStatus === "enabled" || + (await requestNotifications({ showGrantedAlert: false })); + if (!notificationsEnabled) { + setActivityStatus("disabled"); + return; + } + + const saved = await saveLocalAgentActivityPreference(true); + setActivityStatus(saved ? "enabled" : "disabled"); + if (saved) { + Alert.alert( + "Agent Activity enabled", + props.connections.length > 0 + ? `${props.connections.length} environment${props.connections.length === 1 ? "" : "s"} connected for local Agent Activity updates.` + : "Agent Activity updates are enabled. Add an environment to start receiving updates.", + ); + } + })(); + }, + [ + notificationStatus, + props.connections.length, + requestNotifications, + saveLocalAgentActivityPreference, + ], + ); + + return ( + <> + + + + ); +} + type SymbolName = ComponentProps["name"]; function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) { diff --git a/apps/mobile/src/features/agent-awareness/androidNotifications.ts b/apps/mobile/src/features/agent-awareness/androidNotifications.ts new file mode 100644 index 00000000000..295514153db --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/androidNotifications.ts @@ -0,0 +1,46 @@ +import * as Notifications from "expo-notifications"; +import { Platform } from "react-native"; + +import type { AndroidAgentActivityAlert } from "./localAgentActivity"; + +export const ANDROID_AGENT_ACTIVITY_NOTIFICATION_CHANNEL_ID = "agent-activity"; + +let channelReady = false; + +export async function ensureAndroidAgentActivityNotificationChannel(): Promise { + if (Platform.OS !== "android" || channelReady) { + return; + } + + await Notifications.setNotificationChannelAsync(ANDROID_AGENT_ACTIVITY_NOTIFICATION_CHANNEL_ID, { + name: "Agent Activity", + description: "Local alerts when an agent needs attention or finishes.", + importance: Notifications.AndroidImportance.DEFAULT, + lockscreenVisibility: Notifications.AndroidNotificationVisibility.PUBLIC, + showBadge: true, + enableVibrate: true, + vibrationPattern: [0, 250, 250, 250], + }); + channelReady = true; +} + +export async function scheduleAndroidAgentActivityAlert( + alert: AndroidAgentActivityAlert, +): Promise { + if (Platform.OS !== "android") { + return; + } + + await ensureAndroidAgentActivityNotificationChannel(); + await Notifications.scheduleNotificationAsync({ + identifier: alert.identifier, + content: { + title: alert.title, + body: alert.body, + data: alert.data, + priority: Notifications.AndroidNotificationPriority.DEFAULT, + autoDismiss: true, + }, + trigger: { channelId: ANDROID_AGENT_ACTIVITY_NOTIFICATION_CHANNEL_ID }, + }); +} diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts index 932376e8bce..c9caae1de59 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts @@ -8,6 +8,8 @@ import { savePreferencesPatch } from "../../lib/storage"; import { linkEnvironmentToCloud } from "../cloud/linkEnvironment"; import { refreshAgentAwarenessRegistration } from "./remoteRegistration"; +const preferenceListeners = new Set<(enabled: boolean) => void>(); + export class LiveActivityPreferenceSaveError extends Schema.TaggedErrorClass()( "LiveActivityPreferenceSaveError", { @@ -20,6 +22,21 @@ export class LiveActivityPreferenceSaveError extends Schema.TaggedErrorClass void, +): () => void { + preferenceListeners.add(listener); + return () => { + preferenceListeners.delete(listener); + }; +} + +function notifyLiveActivityPreferenceChanged(enabled: boolean): void { + for (const listener of preferenceListeners) { + listener(enabled); + } +} + export function setLiveActivityUpdatesEnabled(input: { readonly enabled: boolean; readonly clerkToken: string | null; @@ -31,6 +48,10 @@ export function setLiveActivityUpdatesEnabled(input: { catch: (cause) => new LiveActivityPreferenceSaveError({ enabled: input.enabled, cause }), }); + yield* Effect.sync(() => { + notifyLiveActivityPreferenceChanged(input.enabled); + }); + yield* refreshAgentAwarenessRegistration(); const clerkToken = input.clerkToken; diff --git a/apps/mobile/src/features/agent-awareness/localAgentActivity.test.ts b/apps/mobile/src/features/agent-awareness/localAgentActivity.test.ts new file mode 100644 index 00000000000..194416146da --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/localAgentActivity.test.ts @@ -0,0 +1,551 @@ +import { describe, expect, it } from "@effect/vitest"; +import type { + EnvironmentId, + OrchestrationThread, + OrchestrationProjectShell, + OrchestrationShellSnapshot, + OrchestrationThreadShell, + ProjectId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { EventId, ProviderInstanceId } from "@t3tools/contracts"; + +import { + agentActivityStatesFromShell, + buildAndroidAgentActivityServiceStatus, + reconcileAndroidAgentActivityAlerts, +} from "./localAgentActivity"; + +const NOW = "2026-06-28T10:00:00.000Z"; +const NOW_MS = Date.parse(NOW); +const ENVIRONMENT_ID = "env-1" as EnvironmentId; +const PROJECT_ID = "project-1" as ProjectId; +const THREAD_ID = "thread-1" as ThreadId; + +function project(overrides: Partial = {}): OrchestrationProjectShell { + return { + id: PROJECT_ID, + title: "t3code", + workspaceRoot: "/repo/t3code", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +function thread(overrides: Partial = {}): OrchestrationThreadShell { + return { + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Fix Android notifications", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + session: null, + latestUserMessageAt: NOW, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +} + +function snapshot(threads: ReadonlyArray): OrchestrationShellSnapshot { + return { + snapshotSequence: 1, + projects: [project()], + threads, + updatedAt: NOW, + }; +} + +function threadDetail(overrides: Partial = {}): OrchestrationThread { + return { + ...thread(), + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + ...overrides, + }; +} + +describe("Android local agent activity", () => { + it("projects agent states from the existing shell snapshot", () => { + const states = agentActivityStatesFromShell({ + environmentId: ENVIRONMENT_ID, + snapshot: snapshot([ + thread({ + session: { + threadId: THREAD_ID, + status: "running", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: "turn-1" as TurnId, + lastError: null, + updatedAt: NOW, + }, + }), + ]), + }); + + expect(states).toHaveLength(1); + expect(states[0]).toMatchObject({ + environmentId: ENVIRONMENT_ID, + threadId: THREAD_ID, + phase: "running", + headline: "Agent is working", + deepLink: "/threads/env-1/thread-1", + }); + }); + + it("keeps the foreground service connected when updates are enabled", () => { + const status = buildAndroidAgentActivityServiceStatus({ + enabled: true, + environmentCount: 2, + states: [], + nowMs: NOW_MS, + }); + + expect(status).toEqual({ + type: "running", + fallbackTitle: "T3 Code", + fallbackChipText: "T3Code", + fallbackBody: "Agent updates are connected to 2 environments.", + }); + }); + + it("builds a thread notification with compact chip text for a single active agent", () => { + const startedAt = "2026-06-28T09:54:20.000Z"; + const [running] = agentActivityStatesFromShell({ + environmentId: ENVIRONMENT_ID, + snapshot: snapshot([ + thread({ + latestTurn: { + turnId: "turn-1" as TurnId, + state: "running", + requestedAt: startedAt, + startedAt, + completedAt: null, + assistantMessageId: null, + }, + session: { + threadId: THREAD_ID, + status: "running", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: "turn-1" as TurnId, + lastError: null, + updatedAt: NOW, + }, + }), + ]), + }); + + const status = buildAndroidAgentActivityServiceStatus({ + enabled: true, + environmentCount: 1, + states: running ? [running] : [], + threadDetails: new Map( + running + ? [ + [ + "env-1:thread-1", + threadDetail({ + latestTurn: { + turnId: "turn-1" as TurnId, + state: "running", + requestedAt: startedAt, + startedAt, + completedAt: null, + assistantMessageId: null, + }, + activities: [ + { + id: EventId.make("tool-1"), + kind: "tool.updated", + tone: "tool", + summary: "Run tests", + payload: { + title: "Run tests", + itemType: "command_execution", + detail: "/bin/zsh -lc 'vp test'", + }, + turnId: "turn-1" as TurnId, + sequence: 1, + createdAt: "2026-06-28T09:55:00.000Z", + }, + ], + }), + ], + ] + : [], + ), + nowMs: NOW_MS, + }); + + expect(status).toMatchObject({ + type: "running", + fallbackChipText: "Work", + notifications: [ + { + key: "env-1:thread-1", + title: "Fix Android notifications", + body: "Working for 5m 40s - Run tests: vp test", + chipText: "Work", + phase: "running", + deepLink: "/threads/env-1/thread-1", + ongoing: true, + }, + ], + }); + }); + + it("builds one compact notification for each active thread", () => { + const secondThreadId = "thread-2" as ThreadId; + const states = agentActivityStatesFromShell({ + environmentId: ENVIRONMENT_ID, + snapshot: snapshot([ + thread({ + session: { + threadId: THREAD_ID, + status: "running", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: "turn-1" as TurnId, + lastError: null, + updatedAt: NOW, + }, + }), + thread({ + id: secondThreadId, + title: "Ship live update chip", + session: { + threadId: secondThreadId, + status: "running", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: "turn-2" as TurnId, + lastError: null, + updatedAt: NOW, + }, + }), + ]), + }); + + const status = buildAndroidAgentActivityServiceStatus({ + enabled: true, + environmentCount: 1, + states, + nowMs: NOW_MS, + }); + + expect(status).toMatchObject({ + type: "running", + fallbackChipText: "W2/2", + notifications: [ + { + key: "env-1:thread-1", + title: "Fix Android notifications", + chipText: "Work", + ongoing: true, + }, + { + key: "env-1:thread-2", + title: "Ship live update chip", + chipText: "Work", + ongoing: true, + }, + ], + }); + }); + + it("prioritizes completed work in the aggregate live update chip", () => { + const states = agentActivityStatesFromShell({ + environmentId: ENVIRONMENT_ID, + snapshot: snapshot([ + thread({ + latestTurn: { + turnId: "turn-completed" as TurnId, + state: "completed", + requestedAt: NOW, + startedAt: NOW, + completedAt: NOW, + assistantMessageId: null, + }, + }), + thread({ + id: "thread-2" as ThreadId, + title: "Running one", + session: { + threadId: "thread-2" as ThreadId, + status: "running", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: "turn-2" as TurnId, + lastError: null, + updatedAt: NOW, + }, + }), + thread({ + id: "thread-3" as ThreadId, + title: "Running two", + session: { + threadId: "thread-3" as ThreadId, + status: "running", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: "turn-3" as TurnId, + lastError: null, + updatedAt: NOW, + }, + }), + thread({ + id: "thread-4" as ThreadId, + title: "Running three", + session: { + threadId: "thread-4" as ThreadId, + status: "running", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: "turn-4" as TurnId, + lastError: null, + updatedAt: NOW, + }, + }), + ]), + }); + + const status = buildAndroidAgentActivityServiceStatus({ + enabled: true, + environmentCount: 1, + states, + nowMs: NOW_MS, + }); + + expect(status).toMatchObject({ + type: "running", + fallbackChipText: "D1/4", + }); + }); + + it("keeps generated live update chip labels shorter than 7 characters", () => { + const states = agentActivityStatesFromShell({ + environmentId: ENVIRONMENT_ID, + snapshot: snapshot([ + thread({ + latestTurn: { + turnId: "turn-completed" as TurnId, + state: "completed", + requestedAt: NOW, + startedAt: NOW, + completedAt: NOW, + assistantMessageId: null, + }, + }), + thread({ + id: "thread-2" as ThreadId, + title: "Running one", + session: { + threadId: "thread-2" as ThreadId, + status: "running", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: "turn-2" as TurnId, + lastError: null, + updatedAt: NOW, + }, + }), + ]), + }); + + const status = buildAndroidAgentActivityServiceStatus({ + enabled: true, + environmentCount: 1, + states, + nowMs: NOW_MS, + }); + + expect(status.fallbackChipText).toHaveLength(4); + expect(status.fallbackChipText?.length).toBeLessThan(7); + for (const notification of status.notifications ?? []) { + expect(notification.chipText.length).toBeLessThan(7); + } + }); + + it("keeps recently completed threads as clearable status notifications", () => { + const [completed] = agentActivityStatesFromShell({ + environmentId: ENVIRONMENT_ID, + snapshot: snapshot([ + thread({ + latestTurn: { + turnId: "turn-1" as TurnId, + state: "completed", + requestedAt: NOW, + startedAt: NOW, + completedAt: NOW, + assistantMessageId: null, + }, + }), + ]), + }); + + const status = buildAndroidAgentActivityServiceStatus({ + enabled: true, + environmentCount: 1, + states: completed ? [completed] : [], + nowMs: NOW_MS, + }); + + expect(status).toMatchObject({ + type: "running", + fallbackChipText: "Done", + notifications: [ + { + key: "env-1:thread-1", + acknowledgementKey: `env-1:thread-1:completed:${NOW}`, + title: "Fix Android notifications", + body: "Completed in 1ms - Review the completed task.", + chipText: "Done", + phase: "completed", + ongoing: false, + updatedAt: NOW, + }, + ], + }); + }); + + it("keeps stale completed threads visible until native click or dismissal acknowledgement", () => { + const completedAt = "2026-06-28T09:45:00.000Z"; + const [completed] = agentActivityStatesFromShell({ + environmentId: ENVIRONMENT_ID, + snapshot: snapshot([ + thread({ + updatedAt: completedAt, + latestTurn: { + turnId: "turn-1" as TurnId, + state: "completed", + requestedAt: completedAt, + startedAt: completedAt, + completedAt, + assistantMessageId: null, + }, + }), + ]), + }); + + const status = buildAndroidAgentActivityServiceStatus({ + enabled: true, + environmentCount: 1, + states: completed ? [completed] : [], + nowMs: NOW_MS, + }); + + expect(status).toMatchObject({ + type: "running", + notifications: [ + { + key: "env-1:thread-1", + acknowledgementKey: `env-1:thread-1:completed:${completedAt}`, + chipText: "Done", + ongoing: false, + updatedAt: completedAt, + }, + ], + }); + }); + + it("emits background alerts when a running thread completes", () => { + const [running] = agentActivityStatesFromShell({ + environmentId: ENVIRONMENT_ID, + snapshot: snapshot([ + thread({ + session: { + threadId: THREAD_ID, + status: "running", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: "turn-1" as TurnId, + lastError: null, + updatedAt: NOW, + }, + }), + ]), + }); + const seeded = reconcileAndroidAgentActivityAlerts({ + previous: new Map(), + states: running ? [running] : [], + canNotify: false, + nowMs: NOW_MS, + }); + const [completed] = agentActivityStatesFromShell({ + environmentId: ENVIRONMENT_ID, + snapshot: snapshot([ + thread({ + latestTurn: { + turnId: "turn-1" as TurnId, + state: "completed", + requestedAt: NOW, + startedAt: NOW, + completedAt: NOW, + assistantMessageId: null, + }, + }), + ]), + }); + + const next = reconcileAndroidAgentActivityAlerts({ + previous: seeded.next, + states: completed ? [completed] : [], + canNotify: true, + nowMs: NOW_MS, + }); + + expect(next.alerts).toHaveLength(1); + expect(next.alerts[0]).toMatchObject({ + title: "Agent finished", + data: { + deepLink: "/threads/env-1/thread-1", + source: "android-local-agent-activity", + }, + }); + }); + + it("does not emit stale completed alerts on first observation", () => { + const [completed] = agentActivityStatesFromShell({ + environmentId: ENVIRONMENT_ID, + snapshot: snapshot([ + thread({ + latestTurn: { + turnId: "turn-1" as TurnId, + state: "completed", + requestedAt: NOW, + startedAt: NOW, + completedAt: NOW, + assistantMessageId: null, + }, + }), + ]), + }); + + const result = reconcileAndroidAgentActivityAlerts({ + previous: new Map(), + states: completed ? [completed] : [], + canNotify: true, + nowMs: NOW_MS, + }); + + expect(result.alerts).toEqual([]); + }); +}); diff --git a/apps/mobile/src/features/agent-awareness/localAgentActivity.ts b/apps/mobile/src/features/agent-awareness/localAgentActivity.ts new file mode 100644 index 00000000000..4aa58714e0d --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/localAgentActivity.ts @@ -0,0 +1,493 @@ +import type { + EnvironmentId, + OrchestrationShellSnapshot, + OrchestrationThread, +} from "@t3tools/contracts"; +import { + type AgentAwarenessPhase, + type AgentAwarenessState, + isTerminalAgentAwarenessPhase, + projectThreadAwareness, +} from "@t3tools/shared/agentAwareness"; +import { formatDuration } from "@t3tools/shared/orchestrationTiming"; + +import { + buildThreadFeed, + type ThreadFeedActivity, + type ThreadFeedEntry, +} from "../../lib/threadActivity"; + +export const ANDROID_AGENT_ACTIVITY_TERMINAL_ALERT_RETENTION_MS = 10 * 60 * 1000; +const ANDROID_LIVE_UPDATE_CHIP_TEXT_MAX_LENGTH = 6; + +export interface AndroidAgentActivityServiceStatus { + readonly type: "running" | "stopped"; + readonly fallbackTitle?: string; + readonly fallbackBody?: string; + readonly fallbackChipText?: string; + readonly notifications?: ReadonlyArray; +} + +export interface AndroidAgentActivityServiceNotification { + readonly key: string; + readonly acknowledgementKey: string; + readonly title: string; + readonly body: string; + readonly chipText: string; + readonly phase: AgentAwarenessPhase; + readonly deepLink: string; + readonly subText: string; + readonly ongoing: boolean; + readonly updatedAt: string; +} + +export interface AndroidAgentActivityObservedState { + readonly phase: AgentAwarenessPhase; + readonly updatedAt: string; +} + +export interface AndroidAgentActivityAlert { + readonly identifier: string; + readonly title: string; + readonly body: string; + readonly data: { + readonly deepLink: string; + readonly environmentId: string; + readonly threadId: string; + readonly source: "android-local-agent-activity"; + }; +} + +export function agentActivityStatesFromShell(input: { + readonly environmentId: EnvironmentId; + readonly snapshot: OrchestrationShellSnapshot; +}): ReadonlyArray { + const projectsById = new Map(input.snapshot.projects.map((project) => [project.id, project])); + const states: AgentAwarenessState[] = []; + + for (const thread of input.snapshot.threads) { + if (thread.archivedAt !== null) { + continue; + } + const project = projectsById.get(thread.projectId); + if (!project) { + continue; + } + const state = projectThreadAwareness({ + environmentId: input.environmentId, + project, + thread, + }); + if (state) { + states.push(state); + } + } + + return states.sort(compareAgentActivityStates); +} + +export function buildAndroidAgentActivityServiceStatus(input: { + readonly enabled: boolean; + readonly environmentCount: number; + readonly states: ReadonlyArray; + readonly threadDetails?: ReadonlyMap; + readonly nowMs: number; +}): AndroidAgentActivityServiceStatus { + if (!input.enabled || input.environmentCount === 0) { + return { type: "stopped" }; + } + + const visibleStates = visibleAndroidAgentActivityStates({ states: input.states }); + if (visibleStates.length > 0) { + return { + type: "running", + fallbackTitle: "T3 Code", + fallbackBody: + input.environmentCount === 1 + ? "Agent updates are connected to 1 environment." + : `Agent updates are connected to ${input.environmentCount} environments.`, + fallbackChipText: serviceAggregateChipTextForStates(visibleStates), + notifications: visibleStates.map((state) => + notificationForState({ + state, + threadDetail: input.threadDetails?.get(agentActivityStateKey(state)) ?? null, + nowMs: input.nowMs, + }), + ), + }; + } + + return { + type: "running", + fallbackTitle: "T3 Code", + fallbackChipText: "T3Code", + fallbackBody: + input.environmentCount === 1 + ? "Agent updates are connected to 1 environment." + : `Agent updates are connected to ${input.environmentCount} environments.`, + }; +} + +function serviceAggregateChipTextForStates(states: ReadonlyArray): string { + const total = states.length; + if (total === 0) { + return "T3Code"; + } + + const completedCount = countStatesByPhase(states, "completed"); + if (completedCount > 0) { + return total === 1 ? "Done" : aggregatePhaseChipText("D", completedCount, total); + } + + const failedCount = countStatesByPhase(states, "failed"); + if (failedCount > 0) { + return total === 1 ? "Failed" : aggregatePhaseChipText("F", failedCount, total); + } + + const approvalCount = countStatesByPhase(states, "waiting_for_approval"); + if (approvalCount > 0) { + return total === 1 ? "Review" : aggregatePhaseChipText("R", approvalCount, total); + } + + const inputCount = countStatesByPhase(states, "waiting_for_input"); + if (inputCount > 0) { + return total === 1 ? "Input" : aggregatePhaseChipText("I", inputCount, total); + } + + const runningCount = countStatesByPhase(states, "running"); + if (runningCount > 0) { + return total === 1 ? "Work" : aggregatePhaseChipText("W", runningCount, total); + } + + const startingCount = countStatesByPhase(states, "starting"); + if (startingCount > 0) { + return total === 1 ? "Start" : aggregatePhaseChipText("S", startingCount, total); + } + + return "T3Code"; +} + +function aggregatePhaseChipText(prefix: string, count: number, total: number): string { + const fractionText = `${prefix}${count}/${total}`; + if (fractionText.length <= ANDROID_LIVE_UPDATE_CHIP_TEXT_MAX_LENGTH) { + return fractionText; + } + return `${prefix}${compactChipCount(count)}`; +} + +function compactChipCount(count: number): string { + return count > 99 ? "99+" : String(count); +} + +function countStatesByPhase( + states: ReadonlyArray, + phase: AgentAwarenessPhase, +): number { + return states.reduce((count, state) => (state.phase === phase ? count + 1 : count), 0); +} + +export function reconcileAndroidAgentActivityAlerts(input: { + readonly previous: ReadonlyMap; + readonly states: ReadonlyArray; + readonly canNotify: boolean; + readonly nowMs: number; +}): { + readonly next: ReadonlyMap; + readonly alerts: ReadonlyArray; +} { + const next = new Map(); + const alerts: AndroidAgentActivityAlert[] = []; + + for (const state of input.states) { + const key = agentActivityStateKey(state); + const observedState: AndroidAgentActivityObservedState = { + phase: state.phase, + updatedAt: state.updatedAt, + }; + next.set(key, observedState); + + if ( + shouldEmitAndroidAgentActivityAlert({ + previous: input.previous.get(key) ?? null, + state, + canNotify: input.canNotify, + nowMs: input.nowMs, + }) + ) { + alerts.push(alertForState(state)); + } + } + + return { next, alerts }; +} + +export function visibleAndroidAgentActivityStates(input: { + readonly states: ReadonlyArray; +}): ReadonlyArray { + return input.states; +} + +function shouldEmitAndroidAgentActivityAlert(input: { + readonly previous: AndroidAgentActivityObservedState | null; + readonly state: AgentAwarenessState; + readonly canNotify: boolean; + readonly nowMs: number; +}): boolean { + if (!input.canNotify) { + return false; + } + + const previous = input.previous; + if (previous?.phase === input.state.phase && previous.updatedAt === input.state.updatedAt) { + return false; + } + + if (input.state.phase === "waiting_for_approval" || input.state.phase === "waiting_for_input") { + return previous?.phase !== input.state.phase; + } + + if (input.state.phase === "failed" || input.state.phase === "completed") { + if (!previous || isTerminalAgentAwarenessPhase(previous.phase)) { + return false; + } + const updatedAtMs = timestampMs(input.state.updatedAt); + return ( + updatedAtMs !== null && + input.nowMs - updatedAtMs <= ANDROID_AGENT_ACTIVITY_TERMINAL_ALERT_RETENTION_MS + ); + } + + return false; +} + +function alertForState(state: AgentAwarenessState): AndroidAgentActivityAlert { + return { + identifier: `android-agent-activity:${agentActivityStateKey(state)}:${state.phase}:${state.updatedAt}`, + title: state.headline, + body: alertBodyForState(state), + data: { + deepLink: state.deepLink, + environmentId: state.environmentId, + threadId: state.threadId, + source: "android-local-agent-activity", + }, + }; +} + +function alertBodyForState(state: AgentAwarenessState): string { + if (state.detail) { + return `${state.threadTitle}: ${state.detail}`; + } + return `${state.threadTitle} - ${state.projectTitle}`; +} + +function notificationForState(input: { + readonly state: AgentAwarenessState; + readonly threadDetail: OrchestrationThread | null; + readonly nowMs: number; +}): AndroidAgentActivityServiceNotification { + const { state } = input; + return { + key: agentActivityStateKey(state), + acknowledgementKey: agentActivityStateAcknowledgementKey(state), + title: state.threadTitle, + body: notificationBodyForState(input), + chipText: serviceChipTextForState(state), + phase: state.phase, + deepLink: state.deepLink, + subText: state.projectTitle, + ongoing: !isTerminalAgentAwarenessPhase(state.phase), + updatedAt: state.updatedAt, + }; +} + +function notificationBodyForState(input: { + readonly state: AgentAwarenessState; + readonly threadDetail: OrchestrationThread | null; + readonly nowMs: number; +}): string { + const summary = notificationSummaryForState(input.state, input.nowMs); + const detail = deriveAndroidAgentActivityDetailHint(input.threadDetail); + if (detail) { + return `${summary} - ${detail}`; + } + if ( + input.state.detail && + (input.state.phase === "completed" || + input.state.phase === "failed" || + input.state.phase === "waiting_for_approval" || + input.state.phase === "waiting_for_input") + ) { + return `${summary} - ${input.state.detail}`; + } + return summary; +} + +function notificationSummaryForState(state: AgentAwarenessState, nowMs: number): string { + const elapsed = workElapsedForState(state, nowMs); + switch (state.phase) { + case "waiting_for_approval": + return elapsed ? `Waiting for approval for ${elapsed}` : "Waiting for approval"; + case "waiting_for_input": + return elapsed ? `Waiting for input for ${elapsed}` : "Waiting for input"; + case "running": + return elapsed ? `Working for ${elapsed}` : "Working"; + case "starting": + return elapsed ? `Starting for ${elapsed}` : "Starting"; + case "failed": + return elapsed ? `Failed after ${elapsed}` : "Failed"; + case "completed": + return elapsed ? `Completed in ${elapsed}` : "Completed"; + case "stale": + return elapsed ? `Update delayed for ${elapsed}` : "Update delayed"; + } +} + +function workElapsedForState(state: AgentAwarenessState, nowMs: number): string | null { + if (!state.workStartedAt) { + return null; + } + const startedAtMs = timestampMs(state.workStartedAt); + if (startedAtMs === null) { + return null; + } + const endedAtMs = state.workEndedAt ? timestampMs(state.workEndedAt) : nowMs; + if (endedAtMs === null || endedAtMs < startedAtMs) { + return null; + } + return formatDuration(endedAtMs - startedAtMs); +} + +function deriveAndroidAgentActivityDetailHint( + threadDetail: OrchestrationThread | null, +): string | null { + if (!threadDetail) { + return null; + } + + const latestTurnId = threadDetail.latestTurn?.turnId ?? null; + const feed = buildThreadFeed(threadDetail); + for (const entry of feed.toReversed()) { + const hint = hintForThreadFeedEntry(entry, latestTurnId); + if (hint) { + return hint; + } + } + + return null; +} + +function hintForThreadFeedEntry( + entry: ThreadFeedEntry, + latestTurnId: string | null, +): string | null { + if (entry.type === "activity-group") { + for (const activity of entry.activities.toReversed()) { + if (latestTurnId !== null && activity.turnId !== latestTurnId) { + continue; + } + const hint = hintForThreadFeedActivity(activity); + if (hint) { + return hint; + } + } + return null; + } + + if (entry.type !== "message") { + return null; + } + if (entry.message.role !== "assistant") { + return null; + } + if (latestTurnId !== null && entry.message.turnId !== latestTurnId) { + return null; + } + return compactNotificationText(entry.message.text); +} + +function hintForThreadFeedActivity(activity: ThreadFeedActivity): string | null { + const summary = compactNotificationText(activity.summary); + const detail = compactNotificationText(activity.detail ?? undefined); + if (!summary) { + return detail; + } + if (!detail || summary.toLowerCase() === detail.toLowerCase()) { + return summary; + } + return compactNotificationText(`${summary}: ${detail}`); +} + +function compactNotificationText(value: string | null | undefined): string | null { + const trimmed = value + ?.replace(/[`*_>#]/g, "") + .replace(/\s+/g, " ") + .trim(); + if (!trimmed) { + return null; + } + return trimmed.length > 96 ? `${trimmed.slice(0, 95).trimEnd()}...` : trimmed; +} + +function serviceChipTextForState(state: AgentAwarenessState): string { + switch (state.phase) { + case "waiting_for_approval": + return "Review"; + case "waiting_for_input": + return "Input"; + case "running": + return "Work"; + case "starting": + return "Start"; + case "failed": + return "Failed"; + case "completed": + return "Done"; + case "stale": + return "Stale"; + } +} + +function compareAgentActivityStates(left: AgentAwarenessState, right: AgentAwarenessState): number { + const phaseDelta = phasePriority(left.phase) - phasePriority(right.phase); + if (phaseDelta !== 0) { + return phaseDelta; + } + const updatedAtDelta = (timestampMs(right.updatedAt) ?? 0) - (timestampMs(left.updatedAt) ?? 0); + if (updatedAtDelta !== 0) { + return updatedAtDelta; + } + return left.threadTitle.localeCompare(right.threadTitle); +} + +function phasePriority(phase: AgentAwarenessPhase): number { + switch (phase) { + case "waiting_for_approval": + return 0; + case "waiting_for_input": + return 1; + case "failed": + return 2; + case "running": + return 3; + case "starting": + return 4; + case "completed": + return 5; + case "stale": + return 6; + } +} + +function agentActivityStateKey(state: AgentAwarenessState): string { + return `${state.environmentId}:${state.threadId}`; +} + +function agentActivityStateAcknowledgementKey(state: AgentAwarenessState): string { + return `${agentActivityStateKey(state)}:${state.phase}:${state.updatedAt}`; +} + +function timestampMs(value: string): number | null { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} diff --git a/apps/mobile/src/features/agent-awareness/notificationPermissions.ts b/apps/mobile/src/features/agent-awareness/notificationPermissions.ts index dc275774a50..d0b1a2f4038 100644 --- a/apps/mobile/src/features/agent-awareness/notificationPermissions.ts +++ b/apps/mobile/src/features/agent-awareness/notificationPermissions.ts @@ -3,6 +3,8 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { Platform } from "react-native"; +import { ensureAndroidAgentActivityNotificationChannel } from "./androidNotifications"; + export type NotificationPermissionResult = | { readonly type: "unsupported" } | { readonly type: "granted" } @@ -15,7 +17,7 @@ export class NotificationPermissionReadError extends Schema.TaggedErrorClass = Effect.gen(function* () { - if (Platform.OS !== "ios") { + if (Platform.OS !== "ios" && Platform.OS !== "android") { return { type: "unsupported" }; } + if (Platform.OS === "android") { + yield* Effect.tryPromise({ + try: () => ensureAndroidAgentActivityNotificationChannel(), + catch: (cause) => new NotificationPermissionRequestError({ cause }), + }); + } + const existing = yield* Effect.tryPromise({ try: () => Notifications.getPermissionsAsync(), catch: (cause) => new NotificationPermissionReadError({ cause }), @@ -53,6 +62,7 @@ export const requestAgentNotificationPermission: Effect.Effect< const requested = yield* Effect.tryPromise({ try: () => Notifications.requestPermissionsAsync({ + android: {}, ios: { allowAlert: true, allowBadge: true, diff --git a/apps/mobile/src/features/agent-awareness/useAndroidLocalAgentActivityUpdates.ts b/apps/mobile/src/features/agent-awareness/useAndroidLocalAgentActivityUpdates.ts new file mode 100644 index 00000000000..5ab7687a865 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/useAndroidLocalAgentActivityUpdates.ts @@ -0,0 +1,272 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentThread } from "@t3tools/client-runtime/state/shell"; +import type { AgentAwarenessState } from "@t3tools/shared/agentAwareness"; +import * as Notifications from "expo-notifications"; +import * as Option from "effect/Option"; +import { Atom } from "effect/unstable/reactivity"; +import { useEffect, useRef, useState } from "react"; +import { AppState, Platform, type AppStateStatus } from "react-native"; + +import { + startAndroidAgentActivityForegroundService, + stopAndroidAgentActivityForegroundService, + updateAndroidAgentActivityForegroundService, +} from "../../native/T3AgentActivityForegroundService"; +import { environmentCatalog } from "../../connection/catalog"; +import { loadPreferences } from "../../lib/storage"; +import { environmentShell } from "../../state/shell"; +import { environmentThreadDetails } from "../../state/threads"; +import { scheduleAndroidAgentActivityAlert } from "./androidNotifications"; +import { + type AndroidAgentActivityObservedState, + agentActivityStatesFromShell, + buildAndroidAgentActivityServiceStatus, + reconcileAndroidAgentActivityAlerts, +} from "./localAgentActivity"; +import { subscribeLiveActivityPreferenceChanges } from "./liveActivityPreferences"; + +interface AndroidLocalAgentActivitySnapshot { + readonly environmentCount: number; + readonly states: ReadonlyArray; + readonly threadDetails: ReadonlyMap; +} + +const EMPTY_ANDROID_LOCAL_AGENT_ACTIVITY_SNAPSHOT: AndroidLocalAgentActivitySnapshot = + Object.freeze({ + environmentCount: 0, + states: [], + threadDetails: new Map(), + }); + +const androidLocalAgentActivitySnapshotAtom = Atom.make((get) => { + const catalog = get(environmentCatalog.catalogValueAtom); + if (catalog.entries.size === 0) { + return EMPTY_ANDROID_LOCAL_AGENT_ACTIVITY_SNAPSHOT; + } + + const states: AgentAwarenessState[] = []; + for (const environmentId of catalog.entries.keys()) { + const shellState = get(environmentShell.stateValueAtom(environmentId)); + const snapshot = Option.getOrNull(shellState.snapshot); + if (!snapshot) { + continue; + } + states.push(...agentActivityStatesFromShell({ environmentId, snapshot })); + } + + const threadDetails = new Map(); + for (const state of states) { + const detail = get( + environmentThreadDetails.detailAtom({ + environmentId: state.environmentId, + threadId: state.threadId, + }), + ); + if (detail) { + threadDetails.set(androidAgentActivityThreadKey(state), detail); + } + } + + return { + environmentCount: catalog.entries.size, + states, + threadDetails, + }; +}).pipe(Atom.withLabel("android-local-agent-activity-snapshot")); + +const androidLocalAgentActivityPlatformSnapshotAtom = + Platform.OS === "android" + ? androidLocalAgentActivitySnapshotAtom + : Atom.make(EMPTY_ANDROID_LOCAL_AGENT_ACTIVITY_SNAPSHOT).pipe( + Atom.withLabel("android-local-agent-activity-snapshot:noop"), + ); + +export function useAndroidLocalAgentActivityUpdates(): void { + const snapshot = useAtomValue(androidLocalAgentActivityPlatformSnapshotAtom); + const appState = useAppStateStatus(); + const [enabled, setEnabled] = useState(null); + const [notificationsGranted, setNotificationsGranted] = useState(null); + const nowMs = useAndroidAgentActivityClock( + Platform.OS === "android" && + enabled === true && + notificationsGranted === true && + snapshot.states.length > 0, + ); + const serviceSignatureRef = useRef(null); + const observedStateRef = useRef>( + new Map(), + ); + + useEffect(() => { + if (Platform.OS !== "android") { + return; + } + + let cancelled = false; + const refresh = async () => { + try { + const [preferences, permissions] = await Promise.all([ + loadPreferences(), + Notifications.getPermissionsAsync(), + ]); + if (cancelled) { + return; + } + setEnabled(preferences.liveActivitiesEnabled !== false); + setNotificationsGranted(permissions.granted); + } catch (error) { + logAndroidAgentActivityError("Could not refresh Android agent activity settings.", error); + if (!cancelled) { + setEnabled(false); + setNotificationsGranted(false); + } + } + }; + + void refresh(); + const unsubscribe = subscribeLiveActivityPreferenceChanges((nextEnabled) => { + setEnabled(nextEnabled); + void refreshAndroidNotificationPermission(setNotificationsGranted); + }); + + return () => { + cancelled = true; + unsubscribe(); + }; + }, []); + + useEffect(() => { + if (Platform.OS !== "android" || appState !== "active") { + return; + } + void refreshAndroidNotificationPermission(setNotificationsGranted); + }, [appState]); + + useEffect(() => { + if (Platform.OS !== "android") { + return; + } + + const canUseNotifications = enabled === true && notificationsGranted === true; + const reconciliation = reconcileAndroidAgentActivityAlerts({ + previous: observedStateRef.current, + states: snapshot.states, + canNotify: canUseNotifications && appState !== "active", + nowMs, + }); + observedStateRef.current = reconciliation.next; + + const status = buildAndroidAgentActivityServiceStatus({ + enabled: canUseNotifications, + environmentCount: snapshot.environmentCount, + states: snapshot.states, + threadDetails: snapshot.threadDetails, + nowMs, + }); + + void (async () => { + try { + if ( + status.type === "stopped" || + !status.fallbackTitle || + !status.fallbackBody || + !status.fallbackChipText + ) { + if (serviceSignatureRef.current !== null) { + serviceSignatureRef.current = null; + await stopAndroidAgentActivityForegroundService(); + } + } else { + const notifications = status.notifications ?? []; + const signature = JSON.stringify({ + fallbackTitle: status.fallbackTitle, + fallbackBody: status.fallbackBody, + fallbackChipText: status.fallbackChipText, + notifications, + }); + if (serviceSignatureRef.current === null) { + await startAndroidAgentActivityForegroundService({ + fallbackTitle: status.fallbackTitle, + fallbackBody: status.fallbackBody, + fallbackChipText: status.fallbackChipText, + notifications, + }); + } else if (serviceSignatureRef.current !== signature) { + await updateAndroidAgentActivityForegroundService({ + fallbackTitle: status.fallbackTitle, + fallbackBody: status.fallbackBody, + fallbackChipText: status.fallbackChipText, + notifications, + }); + } + serviceSignatureRef.current = signature; + } + } catch (error) { + logAndroidAgentActivityError("Could not synchronize Android foreground service.", error); + } + + for (const alert of reconciliation.alerts) { + try { + await scheduleAndroidAgentActivityAlert(alert); + } catch (error) { + logAndroidAgentActivityError("Could not schedule Android agent activity alert.", error); + } + } + })(); + }, [appState, enabled, notificationsGranted, nowMs, snapshot]); +} + +function useAppStateStatus(): AppStateStatus { + const [appState, setAppState] = useState(AppState.currentState); + + useEffect(() => { + const subscription = AppState.addEventListener("change", setAppState); + return () => { + subscription.remove(); + }; + }, []); + + return appState; +} + +function useAndroidAgentActivityClock(active: boolean): number { + const [nowMs, setNowMs] = useState(() => Date.now()); + + useEffect(() => { + if (!active) { + return; + } + setNowMs(Date.now()); + const interval = setInterval(() => { + setNowMs(Date.now()); + }, 20_000); + return () => { + clearInterval(interval); + }; + }, [active]); + + return nowMs; +} + +function androidAgentActivityThreadKey(state: AgentAwarenessState): string { + return `${state.environmentId}:${state.threadId}`; +} + +async function refreshAndroidNotificationPermission( + setNotificationsGranted: (granted: boolean) => void, +): Promise { + try { + const permissions = await Notifications.getPermissionsAsync(); + setNotificationsGranted(permissions.granted); + } catch (error) { + logAndroidAgentActivityError("Could not refresh Android notification permissions.", error); + setNotificationsGranted(false); + } +} + +function logAndroidAgentActivityError(context: string, error: unknown): void { + if (!__DEV__) { + return; + } + console.warn(`[agent-awareness] ${context}`, error); +} diff --git a/apps/mobile/src/native/T3AgentActivityForegroundService.ts b/apps/mobile/src/native/T3AgentActivityForegroundService.ts new file mode 100644 index 00000000000..7de08f69e3c --- /dev/null +++ b/apps/mobile/src/native/T3AgentActivityForegroundService.ts @@ -0,0 +1,107 @@ +import { requireOptionalNativeModule } from "expo"; +import * as Linking from "expo-linking"; +import { Platform } from "react-native"; + +import type { AndroidAgentActivityServiceNotification } from "../features/agent-awareness/localAgentActivity"; + +interface T3NativeControlsModule { + readonly startAgentActivityForegroundServiceAsync: ( + fallbackTitle: string, + fallbackBody: string, + fallbackChipText: string, + notificationsJson: string, + ) => Promise; + readonly updateAgentActivityForegroundServiceAsync: ( + fallbackTitle: string, + fallbackBody: string, + fallbackChipText: string, + notificationsJson: string, + ) => Promise; + readonly stopAgentActivityForegroundServiceAsync: () => Promise; +} + +const nativeModule = + Platform.OS === "android" + ? requireOptionalNativeModule("T3NativeControls") + : null; + +const ANDROID_LIVE_UPDATE_CHIP_TEXT_MAX_LENGTH = 6; + +function normalizeNotificationText(value: string): string { + const trimmed = value.replace(/\s+/g, " ").trim(); + return trimmed.length > 0 ? trimmed : "T3 Code"; +} + +function normalizeNotificationChipText(value: string): string { + const trimmed = value.replace(/\s+/g, " ").trim(); + return (trimmed.length > 0 ? trimmed : "T3Code").slice( + 0, + ANDROID_LIVE_UPDATE_CHIP_TEXT_MAX_LENGTH, + ); +} + +export async function startAndroidAgentActivityForegroundService(input: { + readonly fallbackTitle: string; + readonly fallbackBody: string; + readonly fallbackChipText: string; + readonly notifications: ReadonlyArray; +}): Promise { + if (Platform.OS !== "android" || !nativeModule) { + return; + } + await nativeModule.startAgentActivityForegroundServiceAsync( + normalizeNotificationText(input.fallbackTitle), + normalizeNotificationText(input.fallbackBody), + normalizeNotificationChipText(input.fallbackChipText), + serializeNotifications(input.notifications), + ); +} + +export async function updateAndroidAgentActivityForegroundService(input: { + readonly fallbackTitle: string; + readonly fallbackBody: string; + readonly fallbackChipText: string; + readonly notifications: ReadonlyArray; +}): Promise { + if (Platform.OS !== "android" || !nativeModule) { + return; + } + await nativeModule.updateAgentActivityForegroundServiceAsync( + normalizeNotificationText(input.fallbackTitle), + normalizeNotificationText(input.fallbackBody), + normalizeNotificationChipText(input.fallbackChipText), + serializeNotifications(input.notifications), + ); +} + +export async function stopAndroidAgentActivityForegroundService(): Promise { + if (Platform.OS !== "android" || !nativeModule) { + return; + } + await nativeModule.stopAgentActivityForegroundServiceAsync(); +} + +function serializeNotifications( + notifications: ReadonlyArray, +): string { + return JSON.stringify( + notifications.map((notification) => ({ + key: normalizeNotificationText(notification.key), + acknowledgementKey: normalizeNotificationText(notification.acknowledgementKey), + title: normalizeNotificationText(notification.title), + body: normalizeNotificationText(notification.body), + chipText: normalizeNotificationChipText(notification.chipText), + phase: normalizeNotificationText(notification.phase), + deepLinkUrl: Linking.createURL(notification.deepLink), + subText: normalizeNotificationText(notification.subText), + ongoing: notification.ongoing, + updatedAt: notification.updatedAt, + updatedAtMillis: timestampMillis(notification.updatedAt), + })), + ); +} + +function timestampMillis(value: string): number | null { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} diff --git a/packages/shared/src/agentAwareness.ts b/packages/shared/src/agentAwareness.ts index 6831e8ba301..e62d907a3f1 100644 --- a/packages/shared/src/agentAwareness.ts +++ b/packages/shared/src/agentAwareness.ts @@ -23,6 +23,8 @@ export interface AgentAwarenessState { readonly headline: string; readonly detail?: string; readonly modelTitle: string; + readonly workStartedAt?: string; + readonly workEndedAt?: string; readonly updatedAt: string; readonly deepLink: string; } @@ -77,6 +79,7 @@ export function projectThreadAwareness( headline: headlineForPhase(phase), ...(detail === undefined ? {} : { detail }), modelTitle: thread.modelSelection.model, + ...workTimingForPhase(phase, thread), updatedAt: thread.updatedAt, deepLink: buildAgentAwarenessDeepLink({ environmentId, threadId: thread.id }), }; @@ -140,3 +143,29 @@ function detailForPhase( } return undefined; } + +function workTimingForPhase( + phase: AgentAwarenessPhase, + thread: ProjectThreadAwarenessInput["thread"], +): Pick { + const startedAt = thread.latestTurn?.startedAt ?? thread.session?.updatedAt ?? undefined; + if (!startedAt) { + return {}; + } + + if (phase === "completed") { + return { + workStartedAt: startedAt, + workEndedAt: thread.latestTurn?.completedAt ?? thread.updatedAt, + }; + } + + if (phase === "failed") { + return { + workStartedAt: startedAt, + workEndedAt: thread.latestTurn?.completedAt ?? thread.updatedAt, + }; + } + + return { workStartedAt: startedAt }; +}