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 };
+}