diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt index e04a781003..387aa820a0 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/AgentNotifications.kt @@ -1,5 +1,6 @@ package expo.modules.t3agentnotifications +import android.app.Activity import android.app.NotificationChannel import android.app.Notification import android.app.AlarmManager @@ -15,8 +16,6 @@ import android.text.TextPaint import android.text.TextUtils import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.ProcessLifecycleOwner import com.google.firebase.messaging.RemoteMessage import expo.modules.notifications.service.ExpoFirebaseMessagingService @@ -86,10 +85,40 @@ object AgentNotifications { cancelActivity(context) context.getSharedPreferences(STORE, Context.MODE_PRIVATE).edit().clear().apply() val manager = manager(context) - manager.activeNotifications.filter { it.tag == ACTIVITY_TAG || it.tag == ALERT_TAG } + manager.activeNotifications.filter { + it.tag == ACTIVITY_TAG || it.tag == ALERT_TAG || + it.tag?.startsWith("$ALERT_TAG-summary:") == true + } .forEach { manager.cancel(it.tag, it.id) } } + /** Records the thread route the app is showing, or null when none is open. */ + @Volatile private var threadOnScreen: String? = null + + // ProcessLifecycleOwner delays pause after Home/lock. Use Expo's immediate + // host pause/resume hooks, serialized with receive and alert deduplication. + private var resumedActivity: Activity? = null + + @Synchronized + fun onActivityResumed(activity: Activity) { + resumedActivity = activity + } + + @Synchronized + fun onActivityPaused(activity: Activity) { + if (resumedActivity === activity) resumedActivity = null + } + + @Synchronized + fun clearActivityVisibility() { + resumedActivity = null + } + + @Synchronized + fun setThreadOnScreen(path: String?) { + threadOnScreen = path + } + @Synchronized fun dismiss(context: Context) { context.getSharedPreferences( @@ -137,20 +166,14 @@ object AgentNotifications { val seen = prefs.getString("seenAlertsOrdered", null)?.split('\n') ?: prefs.getStringSet("seenAlerts", emptySet()).orEmpty().toList() if (alertId != null && alertId !in seen) { - // Match iOS foreground presentation. Consume suppressed alerts as well, - // so a delivery retry cannot surface them after the app backgrounds. - if (!ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { - val title = data["alert_title"].orEmpty().take(120) - // Grouped alerts list up to five 120-character thread titles. - val body = data["alert_body"].orEmpty().take(608) - val id = alertId.hashCode() - val notification = base(context, ALERT_CHANNEL) - .setContentTitle(title).setContentText(body) - .setStyle(NotificationCompat.BigTextStyle().bigText(body)) - .setAutoCancel(true) - .setContentIntent(contentIntent(context, scheme, data["alert_path"], id)) - .build() - manager(context).notify(ALERT_TAG, id, notification) + // Only consume a suppressed alert while the host Activity is actually + // resumed. A delivery after onPause must post and remain retry-safe even + // while ProcessLifecycleOwner still reports RESUMED. + val resumed = resumedActivity != null + val visibleThread = threadOnScreen + val onScreen = resumed && visibleThread != null && data["alert_path"] == visibleThread + if (!onScreen) { + postAlert(context, scheme, data, alertId) } prefs.edit().remove("seenAlerts").putString( "seenAlertsOrdered", @@ -159,6 +182,47 @@ object AgentNotifications { } } + private fun postAlert( + context: Context, + scheme: String, + data: Map, + alertId: String + ) { + val title = data["alert_title"].orEmpty().take(120) + // Grouped alerts list up to five 120-character thread titles. + val body = data["alert_body"].orEmpty().take(608) + val id = alertId.hashCode() + val group = data["alert_group"]?.takeIf { it.isNotBlank() } ?: ALERT_TAG + val notification = base(context, ALERT_CHANNEL) + .setContentTitle(title).setContentText(body) + .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setAutoCancel(true) + .setGroup(group) + .setContentIntent(contentIntent(context, scheme, data["alert_path"], id)) + .build() + manager(context).notify(ALERT_TAG, id, notification) + val children = manager(context).activeNotifications.filter { + it.tag == ALERT_TAG && it.notification.group == group + } + if (children.size > 1) { + val style = NotificationCompat.InboxStyle() + children.forEach { + style.addLine(it.notification.extras.getCharSequence(android.app.Notification.EXTRA_TEXT)) + } + val summary = base(context, ALERT_CHANNEL) + .setContentTitle(title) + .setContentText(body) + .setStyle(style) + .setGroup(group) + .setGroupSummary(true) + .setSilent(true) + .setAutoCancel(true) + .setContentIntent(contentIntent(context, scheme, data["alert_path"], 0)) + .build() + manager(context).notify("$ALERT_TAG-summary:$group", 0, summary) + } + } + private fun updateActivity( context: Context, prefs: SharedPreferences, diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt index d394db5611..37dfe9e91e 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/main/java/expo/modules/t3agentnotifications/T3AgentNotificationsModule.kt @@ -1,12 +1,64 @@ package expo.modules.t3agentnotifications +import android.app.Activity +import android.os.Handler +import android.os.Looper +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition class T3AgentNotificationsModule : Module() { + private val mainHandler = Handler(Looper.getMainLooper()) + + // Resolve at Expo lifecycle delivery, after React updates the host Activity. + internal var activityProvider: () -> Activity? = { appContext.currentActivity } + + @Volatile private var destroyed = false + override fun definition() = ModuleDefinition { Name("T3AgentNotifications") + OnCreate { + // Seed on the main thread if the module loads after the host resumed. + mainHandler.post { + if (destroyed) return@post + val activity = activityProvider() + if (activity != null && + (activity as? LifecycleOwner)?.lifecycle?.currentState?.isAtLeast( + Lifecycle.State.RESUMED + ) == true + ) { + AgentNotifications.onActivityResumed(activity) + } else { + AgentNotifications.clearActivityVisibility() + } + } + } + + OnActivityEntersForeground { + if (!destroyed) { + val activity = activityProvider() + if (activity != null) { + AgentNotifications.onActivityResumed(activity) + } else { + // No confirmed host means alerts must remain visible. + AgentNotifications.clearActivityVisibility() + } + } + } + + OnActivityEntersBackground { + // Expo dispatches this during host pause, without the process delay. + AgentNotifications.clearActivityVisibility() + } + + OnDestroy { + destroyed = true + AgentNotifications.clearActivityVisibility() + AgentNotifications.setThreadOnScreen(null) + } + Function("configure") { deviceId: String, userId: String, @@ -18,6 +70,10 @@ class T3AgentNotificationsModule : Module() { } } + Function("setThreadOnScreen") { path: String? -> + AgentNotifications.setThreadOnScreen(path) + } + Function("clear") { appContext.reactContext?.let { AgentNotifications.clear(it) } } diff --git a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt index b236fde081..a92c8b10f2 100644 --- a/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt +++ b/apps/mobile/modules/t3-agent-notifications/android/src/test/java/expo/modules/t3agentnotifications/AgentNotificationsTest.kt @@ -5,6 +5,7 @@ import android.app.AlarmManager import android.app.Application import android.app.Notification import android.app.NotificationManager +import android.content.Context import android.content.ComponentName import android.content.Intent import android.content.IntentFilter @@ -13,6 +14,8 @@ import androidx.core.app.NotificationCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.ProcessLifecycleOwner +import expo.modules.kotlin.events.BasicEventListener +import expo.modules.kotlin.events.EventName import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -30,6 +33,7 @@ class AgentNotificationsTest { private lateinit var context: Application private lateinit var manager: NotificationManager private lateinit var lifecycle: LifecycleRegistry + private lateinit var activity: Activity @Before fun setUp() { @@ -38,6 +42,8 @@ class AgentNotificationsTest { shadowOf(manager).setNotificationsEnabled(true) lifecycle = ProcessLifecycleOwner.get().lifecycle as LifecycleRegistry lifecycle.currentState = Lifecycle.State.CREATED + activity = Activity() + AgentNotifications.clearActivityVisibility() val launcher = ComponentName(context, Activity::class.java) shadowOf(context.packageManager).addActivityIfNotPresent(launcher) @@ -49,6 +55,7 @@ class AgentNotificationsTest { ) AgentNotifications.clear(context) AgentNotifications.configure(context, "device", "user", "pylon-code-dev", true) + AgentNotifications.setThreadOnScreen("/threads/environment/thread") } private fun update(alertId: String, active: Boolean) = mapOf( @@ -60,6 +67,7 @@ class AgentNotificationsTest { "activity_body" to "Test thread · Working", "activity_path" to "/threads/environment/thread", "alert_id" to alertId, + "alert_group" to "environment/thread", "alert_title" to "Test thread", "alert_body" to "Done: Test project", "alert_path" to "/threads/environment/thread", @@ -67,15 +75,21 @@ class AgentNotificationsTest { @Test fun alertHistoryEvictsOnlyTheOldestEntryAfterCapacity() { - lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.onActivityResumed(activity) for (id in 0..64) AgentNotifications.receive(context, update("alert-$id", false)) - lifecycle.currentState = Lifecycle.State.CREATED + AgentNotifications.onActivityPaused(activity) for (id in 1..64) AgentNotifications.receive(context, update("alert-$id", false)) assertTrue(manager.activeNotifications.isEmpty()) AgentNotifications.receive(context, update("alert-0", false)) assertEquals("alert-0".hashCode(), manager.activeNotifications.single().id) } + @Test + fun alertsStackByThreadGroup() { + AgentNotifications.receive(context, update("grouped", false)) + assertEquals("environment/thread", manager.activeNotifications.single().notification.group) + } + @Test fun missingLauncherDoesNotDiscardTheAlert() { shadowOf(context.packageManager).removeActivity(ComponentName(context, Activity::class.java)) @@ -86,9 +100,25 @@ class AgentNotificationsTest { ) } + @Test + fun foregroundAlertsForThreadsThatAreNotOnScreen() { + AgentNotifications.onActivityResumed(activity) + AgentNotifications.setThreadOnScreen("/threads/environment/other") + AgentNotifications.receive(context, update("elsewhere", false)) + assertEquals("t3-agent-alert", manager.activeNotifications.single().tag) + } + + @Test + fun foregroundAlertsWhenNoThreadIsOnScreen() { + AgentNotifications.onActivityResumed(activity) + AgentNotifications.setThreadOnScreen(null) + AgentNotifications.receive(context, update("overview", false)) + assertEquals("t3-agent-alert", manager.activeNotifications.single().tag) + } + @Test fun foregroundSuppressesAlertsWhileOngoingActivityStillUpdatesAndClears() { - lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.onActivityResumed(activity) AgentNotifications.receive(context, update("attention", true)) @@ -104,9 +134,9 @@ class AgentNotificationsTest { @Test fun backgroundCompletionAlertsAndClearsOngoingActivity() { - lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.onActivityResumed(activity) AgentNotifications.receive(context, update("running", true)) - lifecycle.currentState = Lifecycle.State.CREATED + AgentNotifications.onActivityPaused(activity) AgentNotifications.receive(context, update("completion", false)) @@ -118,10 +148,10 @@ class AgentNotificationsTest { @Test fun retryOfForegroundSuppressedAlertDoesNotAppearAfterBackgrounding() { - lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.onActivityResumed(activity) val suppressed = update("foreground-completion", false) AgentNotifications.receive(context, suppressed) - lifecycle.currentState = Lifecycle.State.CREATED + AgentNotifications.onActivityPaused(activity) AgentNotifications.receive(context, suppressed) @@ -132,16 +162,101 @@ class AgentNotificationsTest { assertEquals("later-background-completion".hashCode(), manager.activeNotifications.single().id) } + @Test + fun pausePostsAlertsImmediatelyWhileProcessLifecycleStillReportsResumed() { + lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.onActivityResumed(activity) + AgentNotifications.onActivityPaused(activity) + val message = update("home-transition", false) + + AgentNotifications.receive(context, message) + AgentNotifications.receive(context, message) + + assertEquals(Lifecycle.State.RESUMED, lifecycle.currentState) + assertEquals("home-transition".hashCode(), manager.activeNotifications.single().id) + assertTrue( + context.getSharedPreferences("t3-agent-notifications", Context.MODE_PRIVATE) + .getString("seenAlertsOrdered", "").orEmpty().contains("home-transition") + ) + } + + @Test + fun moduleForegroundHookTracksRecreatedActivityWithSurvivingRuntime() { + // Keep the same Expo module and listeners while React replaces its host. + var currentActivity: Activity? = activity + val module = T3AgentNotificationsModule() + module.activityProvider = { currentActivity } + val listeners = module.definition().eventListeners + val foreground = listeners.getValue(EventName.ACTIVITY_ENTERS_FOREGROUND) as BasicEventListener + val background = listeners.getValue(EventName.ACTIVITY_ENTERS_BACKGROUND) as BasicEventListener + lifecycle.currentState = Lifecycle.State.RESUMED + + foreground.call() + AgentNotifications.receive(context, update("original-visible", false)) + assertTrue(manager.activeNotifications.isEmpty()) + + background.call() + // React still points to the previous host during replacement super.onResume. + // No visibility is asserted until Expo's foreground hook arrives. + AgentNotifications.receive(context, update("recreate-gap", false)) + assertEquals("recreate-gap".hashCode(), manager.activeNotifications.single().id) + manager.cancelAll() + + currentActivity = Activity() + foreground.call() + AgentNotifications.receive(context, update("replacement-visible", false)) + assertTrue(manager.activeNotifications.isEmpty()) + + // Pause is immediate even while the process lifecycle still says RESUMED. + background.call() + val message = update("replacement-paused", false) + AgentNotifications.receive(context, message) + AgentNotifications.receive(context, message) + assertEquals(Lifecycle.State.RESUMED, lifecycle.currentState) + assertEquals("replacement-paused".hashCode(), manager.activeNotifications.single().id) + } + + @Test + fun moduleForegroundHookWithoutAHostFailsOpen() { + var currentActivity: Activity? = activity + val module = T3AgentNotificationsModule() + module.activityProvider = { currentActivity } + val listeners = module.definition().eventListeners + val foreground = listeners.getValue(EventName.ACTIVITY_ENTERS_FOREGROUND) as BasicEventListener + + foreground.call() + currentActivity = null + foreground.call() + AgentNotifications.receive(context, update("unknown-host", false)) + assertEquals("unknown-host".hashCode(), manager.activeNotifications.single().id) + } + @Test fun returningToForegroundSuppressesNewAlertsWithoutRemovingPreviousOnes() { AgentNotifications.receive(context, update("background-completion", false)) - lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.onActivityResumed(activity) AgentNotifications.receive(context, update("foreground-completion", false)) assertEquals("background-completion".hashCode(), manager.activeNotifications.single().id) } + @Test + fun repeatedThreadAlertsHaveOneSilentSummaryAndClearTogether() { + AgentNotifications.receive(context, update("first", false) + ("alert_group" to "thread-group")) + AgentNotifications.receive(context, update("second", false) + ("alert_group" to "thread-group")) + + val summary = manager.activeNotifications.single { + it.notification.flags and Notification.FLAG_GROUP_SUMMARY != 0 + } + assertEquals("thread-group", summary.notification.group) + assertEquals(3, manager.activeNotifications.size) + assertEquals(null, summary.notification.sound) + + AgentNotifications.clear(context) + assertTrue(manager.activeNotifications.isEmpty()) + } + @Test fun groupedAlertDisplaysEveryThreadAndRetriesStaySilent() { val titles = (1..5).map { "Thread $it " + "x".repeat(111) }.joinToString(", ") @@ -167,18 +282,21 @@ class AgentNotificationsTest { } @Test - fun foregroundSuppressedGroupCannotAppearOnBackgroundRetry() { + fun foregroundGroupForOtherThreadsAlertsOnceAcrossBackgroundRetry() { val grouped = update("group-attention", true) + mapOf( "alert_title" to "2 agents need attention", "alert_body" to "First thread, Second thread", "alert_path" to "/", ) - lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.onActivityResumed(activity) AgentNotifications.receive(context, grouped) - lifecycle.currentState = Lifecycle.State.CREATED + assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-alert" }) + AgentNotifications.onActivityPaused(activity) AgentNotifications.receive(context, grouped) - assertEquals("t3-agent-activity", manager.activeNotifications.single().tag) + assertEquals(2, manager.activeNotifications.size) + assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" }) + assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-alert" }) } @Test @@ -209,7 +327,7 @@ class AgentNotificationsTest { @Test fun expandedActivityShowsFiveRowsAndUsesThePriorityThreadRoute() { - lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.onActivityResumed(activity) val lines = listOf( "Approval: First · Project", @@ -233,7 +351,7 @@ class AgentNotificationsTest { @Test fun quietWorkUsesAbsoluteRelayLifetimeInsteadOfTenMinuteRemoval() { - lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.onActivityResumed(activity) val expiresAt = System.currentTimeMillis() + 2 * 60 * 60 * 1000L AgentNotifications.receive( context, @@ -245,7 +363,7 @@ class AgentNotificationsTest { @Test fun finishedCardIsRetainedSilentlyWithoutOngoingFlagAndExpiresAtTheOriginalDeadline() { - lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.onActivityResumed(activity) val expiresAt = System.currentTimeMillis() + 15 * 60 * 1000L val finished = update("finished", false) + mapOf( "activity_title" to "Agent work failed", @@ -266,7 +384,7 @@ class AgentNotificationsTest { @Test fun dismissalIncludesFinishedReplaysAndANewRunRearmsTheCard() { - lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.onActivityResumed(activity) AgentNotifications.receive(context, update("work", true)) AgentNotifications.dismiss(context) val finished = @@ -289,16 +407,16 @@ class AgentNotificationsTest { context, update("older-alert", false) + ("updated_at" to (now - 1000).toString()) ) - assertEquals(3, manager.activeNotifications.size) + assertEquals(4, manager.activeNotifications.size) assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" }) shadowOf(manager).setNotificationsEnabled(false) AgentNotifications.receive(context, update("revoked-permission", true)) - assertEquals(3, manager.activeNotifications.size) + assertEquals(4, manager.activeNotifications.size) } @Test fun longRowsKeepStatusAndBothTitlesWithinTheNotificationWidth() { - lifecycle.currentState = Lifecycle.State.RESUMED + AgentNotifications.onActivityResumed(activity) val raw = "Approval\t${"Long thread name ".repeat(10)}\t${"Project name ".repeat(10)}" AgentNotifications.receive( context, @@ -349,7 +467,8 @@ class AgentNotificationsTest { AgentNotifications.expire(context, expiresAt + 60_000) assertEquals(1, manager.activeNotifications.count { it.tag == "t3-agent-activity" }) AgentNotifications.expire(context, expiresAt + 2 * 60 * 60 * 1000L) - assertTrue(manager.activeNotifications.all { it.tag == "t3-agent-alert" }) + assertEquals(2, manager.activeNotifications.count { it.tag == "t3-agent-alert" }) + assertTrue(manager.activeNotifications.none { it.tag == "t3-agent-activity" }) assertTrue(alarms.scheduledAlarms.isEmpty()) } diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 16b38ca461..74fa5c9acb 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -468,6 +468,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro private var iconImages: [String: UIImage] = [:] private var pendingIconUris = Set() private var tokensNeedRebuild = false + private var chipsNeedMeasuredWidth = false let onComposerChange = EventDispatcher() let onComposerSelectionChange = EventDispatcher() @@ -603,6 +604,10 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro public override func layoutSubviews() { super.layoutSubviews() textView.frame = bounds + if chipsNeedMeasuredWidth, bounds.width > 0 { + chipsNeedMeasuredWidth = false + applyControlledDocument(force: true) + } let placeholderX = textView.textContainerInset.left + textView.textContainer.lineFragmentPadding let placeholderY = textView.textContainerInset.top let placeholderWidth = max( @@ -1010,7 +1015,10 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro // `maximumWidth` does, so the chip always fits the line it sits on. let availableWidth = textView.textContainer.size.width > 0 ? textView.textContainer.size.width - textView.textContainer.lineFragmentPadding * 2 - : UIScreen.main.bounds.width + : (textView.window?.bounds.width ?? bounds.width) + if availableWidth <= 0 { + chipsNeedMeasuredWidth = true + } let maximumLabelWidth = max(chipFontSize * 3, availableWidth - padding * 2 - iconWidth - iconGap) paragraph.lineBreakMode = .byTruncatingMiddle attributedLabel.addAttribute( diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift index 58886e1d9e..a63e22f6c0 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift @@ -931,6 +931,13 @@ private struct ReviewDiffScrollAnchor { } private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { + // Traits can have an unspecified scale before this view joins a scene. + // Prefer the scene's screen then; always keep renderer scales positive. + private var resolvedDisplayScale: CGFloat { + let traitScale = traitCollection.displayScale + return traitScale > 0 ? traitScale : max(window?.screen.scale ?? UIScreen.main.scale, 1) + } + var rows: [ReviewDiffNativeRow] = [] { didSet { stopHorizontalDeceleration() @@ -1923,7 +1930,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { theme.headerBackground.setFill() context.fill(cardRect) - let hairline = 1 / UIScreen.main.scale + let hairline = 1 / resolvedDisplayScale theme.border.setFill() context.fill(CGRect(x: cardRect.minX, y: cardRect.maxY - hairline, width: cardRect.width, height: hairline)) @@ -1982,7 +1989,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { theme.background.setFill() context.fill(rect) - let hairline = 1 / UIScreen.main.scale + let hairline = 1 / resolvedDisplayScale theme.border.withAlphaComponent(0.65).setFill() context.fill(CGRect(x: 0, y: rect.maxY - hairline, width: rect.width, height: hairline)) diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 9ae30968b2..0d47aeb6be 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -322,7 +322,8 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { applyTheme() clipsToBounds = true - contentScaleFactor = UIScreen.main.scale + // The real scale arrives with the window; layoutSubviews corrects it. + contentScaleFactor = resolvedDisplayScale terminalViewport.clipsToBounds = true terminalViewport.contentScaleFactor = contentScaleFactor @@ -687,8 +688,17 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { ]) } + // Traits can have an unspecified scale before this view joins a scene. + // Prefer the scene's screen then; always keep renderer scales positive. + private var resolvedDisplayScale: CGFloat { + let traitScale = traitCollection.displayScale + return traitScale > 0 ? traitScale : max(window?.screen.scale ?? UIScreen.main.scale, 1) + } + private func updateContentScale() { - let scale = window?.screen.scale ?? UIScreen.main.scale + // The trait collection follows the scene the view is in, which matters once + // iPhone apps run resizable and on external displays; UIScreen.main does not. + let scale = resolvedDisplayScale if contentScaleFactor != scale { contentScaleFactor = scale } diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index db7b7429d7..2eec8f0a39 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -551,7 +551,6 @@ function RootStackLayout(props: { const navigation = useNavigation(); const { pendingShare, dismissShare } = useIncomingShare(); const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE); - useAgentNotificationNavigation(); // Presents the Pylon Connect onboarding sheet after an in-session sign-in. useConnectOnboardingNavigation(); // Launcher app shortcuts: routes shortcut taps and tracks opened threads. @@ -589,6 +588,7 @@ function RootStackLayout(props: { // workspace layout only reacts to the underlying non-overlay route. const path = getPathFromState(props.state, navigationPathConfig); const pathname = path.startsWith("/") ? path : `/${path}`; + useAgentNotificationNavigation(pathname); const workspaceLocation = workspaceLocationFromState(props.state); return ( diff --git a/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts b/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts index 469fcc326c..77cdf7cfd7 100644 --- a/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts +++ b/apps/mobile/src/features/agent-awareness/androidNotifications.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ configure?: ReturnType; clear?: ReturnType; openLiveUpdateSettings?: ReturnType; + setThreadOnScreen?: ReturnType; } | null, config: { scheme: ["pylon-code-preview"], @@ -42,6 +43,20 @@ beforeEach(() => { }); describe("Android native notification capability", () => { + it("tracks the visible thread and clears it on leaving the route", async () => { + const setThreadOnScreen = vi.fn(); + mocks.native = { configure: vi.fn(), clear: vi.fn(), setThreadOnScreen }; + const { setAndroidThreadOnScreen } = await import("./androidNotifications"); + setAndroidThreadOnScreen("/threads/env/thread"); + setAndroidThreadOnScreen(null); + expect(setThreadOnScreen.mock.calls).toEqual([["/threads/env/thread"], [null]]); + }); + + it("supports older binaries without route tracking", async () => { + const { setAndroidThreadOnScreen } = await import("./androidNotifications"); + expect(() => setAndroidThreadOnScreen("/threads/env/thread")).not.toThrow(); + }); + it("uses the installed module and the build variant's deep-link scheme", async () => { const { configureAndroidAgentNotifications, clearAndroidAgentNotifications } = await import("./androidNotifications"); diff --git a/apps/mobile/src/features/agent-awareness/androidNotifications.ts b/apps/mobile/src/features/agent-awareness/androidNotifications.ts index 993728e645..9f95245fc3 100644 --- a/apps/mobile/src/features/agent-awareness/androidNotifications.ts +++ b/apps/mobile/src/features/agent-awareness/androidNotifications.ts @@ -6,6 +6,7 @@ interface AndroidAgentNotifications { configure(deviceId: string, userId: string, scheme: string, ongoingEnabled: boolean): void; clear(): void; openLiveUpdateSettings?(): boolean; + setThreadOnScreen?(path: string | null): void; } const native = @@ -61,3 +62,8 @@ export function supportsAndroidLiveUpdateSettings(): boolean { export async function openAndroidLiveUpdateSettings(): Promise { if (!native?.openLiveUpdateSettings?.()) await Linking.openSettings(); } + +/** Tells the FCM handler which thread route is on screen so its alerts stay quiet. */ +export function setAndroidThreadOnScreen(path: string | null): void { + native?.setThreadOnScreen?.(path); +} diff --git a/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts b/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts index 2dd3ca03de..beb4578e06 100644 --- a/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts +++ b/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts @@ -5,6 +5,7 @@ import { consumeLastAgentNotificationResponse } from "./notificationResponseCons import { extractAgentNotificationDeepLink, + threadDeepLinkOnScreen, routeAgentNotificationResponseOnce, } from "./notificationPayload"; @@ -177,3 +178,17 @@ describe("routeAgentNotificationResponseOnce", () => { expect(navigations).toEqual(["/threads/env/thread"]); }); }); + +describe("threadDeepLinkOnScreen", () => { + it.each([ + ["/threads/env/thread", "/threads/env/thread"], + ["/threads/env/thread/files/path", "/threads/env/thread"], + ["/threads/env/thread?focusComposer=true", "/threads/env/thread"], + ["/threads/env%2F1/thread%202/review", "/threads/env%2F1/thread%202"], + ["/", null], + ["/settings", null], + ["/threads/env", null], + ])("tracks the visible thread for %s", (pathname, expected) => { + expect(threadDeepLinkOnScreen(pathname)).toBe(expected); + }); +}); diff --git a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts index e9ca9246b9..ceca1c1dbb 100644 --- a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts +++ b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts @@ -1,12 +1,17 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useLayoutEffect, useRef } from "react"; import * as Notifications from "expo-notifications"; import { useLinkTo } from "@react-navigation/native"; -import { routeAgentNotificationResponseOnce } from "./notificationPayload"; +import { setAndroidThreadOnScreen } from "./androidNotifications"; +import { routeAgentNotificationResponseOnce, threadDeepLinkOnScreen } from "./notificationPayload"; import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer"; -export function useAgentNotificationNavigation(): void { +export function useAgentNotificationNavigation(pathname: string): void { const linkTo = useLinkTo(); + useLayoutEffect(() => { + setAndroidThreadOnScreen(threadDeepLinkOnScreen(pathname)); + return () => setAndroidThreadOnScreen(null); + }, [pathname]); const handledResponseIds = useRef(new Set()); useEffect(() => { diff --git a/apps/mobile/src/features/agent-awareness/notificationPayload.ts b/apps/mobile/src/features/agent-awareness/notificationPayload.ts index dc72e3d1bd..add11448f3 100644 --- a/apps/mobile/src/features/agent-awareness/notificationPayload.ts +++ b/apps/mobile/src/features/agent-awareness/notificationPayload.ts @@ -69,6 +69,19 @@ function normalizeThreadDeepLink(value: string): string | null { } } +/** + * The deep link a notification would have to carry to target the thread the + * given route is showing, including its nested screens such as the file + * inspector. Null when the route is not inside a thread. + */ +export function threadDeepLinkOnScreen(pathname: string): string | null { + const match = /^\/threads\/([^/?#]+)\/([^/?#]+)(?:\/|[?#]|$)/.exec(pathname); + if (!match?.[1] || !match[2]) { + return null; + } + return `/threads/${match[1]}/${match[2]}`; +} + export function extractAgentNotificationDeepLink(response: unknown): string | null { const data = dataFromNotificationResponse(response); const deepLink = data?.deepLink; diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index e992fea23e..d0d8f9bd0f 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -30,6 +30,7 @@ import { saveAgentAwarenessRegistrationRecord, } from "../../persistence/imperative"; import type { Preferences } from "../../persistence/mobile-preferences"; +import type { AgentActivityProps } from "../../widgets/AgentActivity"; import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; import { AgentAwarenessOperationError, @@ -65,7 +66,7 @@ vi.mock("./androidNotifications", () => ({ const secureStore = vi.hoisted(() => new Map()); const widgetMocks = vi.hoisted(() => ({ getInstances: vi.fn(() => []), - start: vi.fn(() => ({})), + start: vi.fn((_props: AgentActivityProps, _url?: string, _staleDate?: Date) => ({})), })); const environmentConfigsMock = vi.hoisted(() => ({ configs: new Map< @@ -967,6 +968,12 @@ describe("makeRelayDeviceRegistrationRequest", () => { }); await new Promise((resolve) => setTimeout(resolve, 0)); expect(widgetMocks.start).toHaveBeenCalledTimes(1); + const [seed, url, staleDate] = widgetMocks.start.mock.calls[0]!; + expect(url).toBeUndefined(); + expect(staleDate).toBeInstanceOf(Date); + if (!staleDate) throw new Error("Expected a stale date on the seeded activity"); + expect(staleDate.getTime() - Date.parse(seed.updatedAt)).toBeGreaterThanOrEqual(10 * 60_000); + expect(staleDate.getTime() - Date.parse(seed.updatedAt)).toBeLessThan(10 * 60_000 + 1_000); // An environment without the capability may run an older server that // still publishes; only an explicit false skips the seed. diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index a0c67fc978..76a3413cc8 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -82,6 +82,10 @@ const activityPushTokenListeners = new WeakSet> // foreground after real time away still triggers a replay. Cleared on // sign-out/identity change alongside the device registration state. const ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS = 60_000; +// Match the relay's stale window (STALE_AFTER_SECONDS in ApnsClient.ts), even +// when registration fails and the locally seeded card never receives a push. +const LIVE_ACTIVITY_STALE_AFTER_MS = 10 * 60_000; +const liveActivityStaleDate = () => new Date(Date.now() + LIVE_ACTIVITY_STALE_AFTER_MS); const registeredActivityPushTokens = new Map(); let androidDeviceReplayedAt: number | null = null; let pushTokenSubscription: { remove: () => void } | null = null; @@ -541,25 +545,29 @@ function armAgentAwarenessLiveActivityForLocalWorkNow(input: { return; } const nowIso = new Date(Date.now()).toISOString(); - const activity = AgentActivity.start({ - title: "Pylon", - subtitle: "Agent work in progress", - activeCount: 1, - updatedAt: nowIso, - activities: [ - { - environmentId: "", - threadId: "", - projectTitle: input.projectTitle, - threadTitle: input.threadTitle, - modelTitle: "", - phase: "starting", - status: "Connecting", - updatedAt: nowIso, - deepLink: "/", - }, - ], - }); + const activity = AgentActivity.start( + { + title: "Pylon", + subtitle: "Agent work in progress", + activeCount: 1, + updatedAt: nowIso, + activities: [ + { + environmentId: "", + threadId: "", + projectTitle: input.projectTitle, + threadTitle: input.threadTitle, + modelTitle: "", + phase: "starting", + status: "Connecting", + updatedAt: nowIso, + deepLink: "/", + }, + ], + }, + undefined, + liveActivityStaleDate(), + ); logRegistrationDebug("live activity card armed for local work", { threadTitle: input.threadTitle, }); @@ -1145,13 +1153,17 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< const aggregate = snapshot.aggregate; const primed = yield* Effect.try({ try: () => - AgentActivity.start({ - title: aggregate.title, - subtitle: aggregate.subtitle, - activeCount: aggregate.activeCount, - updatedAt: aggregate.updatedAt, - activities: aggregate.activities, - }), + AgentActivity.start( + { + title: aggregate.title, + subtitle: aggregate.subtitle, + activeCount: aggregate.activeCount, + updatedAt: aggregate.updatedAt, + activities: aggregate.activities, + }, + undefined, + liveActivityStaleDate(), + ), catch: (cause) => new AgentAwarenessOperationError({ operation: "prime-live-activity", diff --git a/apps/mobile/src/widgets/AgentActivity.test.ts b/apps/mobile/src/widgets/AgentActivity.test.ts index 680a6ddb04..2c83930ae8 100644 --- a/apps/mobile/src/widgets/AgentActivity.test.ts +++ b/apps/mobile/src/widgets/AgentActivity.test.ts @@ -105,6 +105,49 @@ describe("AgentActivity widget layout", () => { expect(banner).toContain("#fcd34d"); // amber-300: waiting_for_approval }); + it("degrades in-flight rows once the system marks the activity stale", () => { + const layout = AgentActivity( + { + ...props, + activeCount: 2, + activities: [ + makeRow({}), + makeRow({ threadId: "thread-2", phase: "completed", status: "Done" }), + ], + }, + { ...environment, isStale: true } as never, + ); + const banner = JSON.stringify(layout.banner); + expect(banner).toContain("Agent status out of date"); + expect(banner).toContain("Out of date"); + expect(banner).not.toContain("#7dd3fc"); // sky-300: running + expect(banner).toContain("Done"); + expect(JSON.stringify(layout.minimal)).toContain("clock.arrow.circlepath"); + expect(JSON.stringify(layout.expandedLeading)).toContain("Out of date"); + }); + + it("keeps finished outcomes when stale and restores attention when refreshed", () => { + const input = { + ...props, + activities: [makeRow({ phase: "waiting_for_input", status: "Input" })], + }; + const stale = AgentActivity(input, { ...environment, isStale: true } as never); + expect(JSON.stringify(stale)).not.toContain("needs attention"); + expect(JSON.stringify(stale.compactTrailing)).toContain("Out of date"); + const refreshed = AgentActivity(input, environment as never); + expect(JSON.stringify(refreshed.compactTrailing)).toContain("Input"); + const finished = AgentActivity( + { + ...props, + activeCount: 0, + activities: [makeRow({ phase: "failed", status: "Failed" })], + }, + { ...environment, isStale: true } as never, + ); + expect(JSON.stringify(finished.compactTrailing)).toContain("Failed"); + expect(JSON.stringify(finished.banner)).not.toContain("Out of date"); + }); + it("switches to the web sidebar's light palette when the scheme is light", () => { // macOS (iPhone Mirroring / Mac notification center) renders the activity // on a light background; the dark-material palette is illegible there. diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index 8ec5dae2fd..d23f1972f8 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -86,6 +86,8 @@ export function AgentActivity( return isLightScheme ? "#dc2626" : "#fca5a5"; // red-600 / red-300 case "completed": return isLightScheme ? "#059669" : "#6ee7b7"; // emerald-600 / emerald-300 + case "stale": + return secondaryForeground; case "starting": case "running": default: @@ -101,20 +103,28 @@ export function AgentActivity( if (phase === "running" || phase === "starting") return 2; return 3; }; - const ordered = [...props.activities].sort( - (a, b) => phasePriority(a.phase) - phasePriority(b.phase), - ); + // Past the stale date the system stops vouching for the content, so every + // in-flight row degrades to "stale" rather than claiming an agent is still + // working. Terminal phases keep their own state. + const activities: ReadonlyArray = environment.isStale + ? props.activities.map((row) => + row.phase === "completed" || row.phase === "failed" + ? row + : { ...row, phase: "stale", status: "Out of date" }, + ) + : props.activities; + const ordered = [...activities].sort((a, b) => phasePriority(a.phase) - phasePriority(b.phase)); const row0 = ordered[0]; const row1 = ordered[1]; const row2 = ordered[2]; const row3 = ordered[3]; const row4 = ordered[4]; - const attentionRows = props.activities.filter( + const attentionRows = activities.filter( (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input", ); const attentionRow = attentionRows[0]; - const failedRow = props.activities.find((row) => row.phase === "failed"); + const failedRow = activities.find((row) => row.phase === "failed"); const heroRow = attentionRow ?? failedRow ?? row0; const tint = phaseTint(heroRow?.phase); // Headline count leans on the accent when a human is actually blocked. @@ -139,12 +149,20 @@ export function AgentActivity( // the two parts in-line so the attention half can carry the accent color; // `summary` is the short form for tight spots (expanded center, watch card). const agentWord = props.activeCount === 1 ? "agent" : "agents"; - const agentsLabel = allDone ? outcomeLabel : `${props.activeCount} active ${agentWord}`; + const agentsLabel = allDone + ? outcomeLabel + : environment.isStale + ? "Agent status out of date" + : `${props.activeCount} active ${agentWord}`; const attentionSuffix = attentionRows.length > 0 ? `${attentionRows.length} need${attentionRows.length === 1 ? "s" : ""} attention` : ""; - const activeLabel = allDone ? doneLabel : `${props.activeCount} active`; + const activeLabel = allDone + ? doneLabel + : environment.isStale + ? "Out of date" + : `${props.activeCount} active`; const summary = attentionSuffix || activeLabel; // Any registered scheme variant routes back to this app; taps are delivered @@ -337,14 +355,14 @@ export function AgentActivity( // the wordmark does not. Show the blocking/outcome phase glyph, else the // mark (all-done shows the hero row's checkmark/cross). minimal: - (attentionRow || failedRow || allDone) && heroRow + (attentionRow || failedRow || allDone || environment.isStale) && heroRow ? renderGlyph(phaseSymbol(heroRow.phase), 13, phaseTint(heroRow.phase)) : renderLogo(11, tint), expandedLeading: ( {renderLogo(15, tint)} - {allDone ? doneLabel : `${props.activeCount}`} + {allDone ? doneLabel : environment.isStale ? "Out of date" : `${props.activeCount}`} ), diff --git a/docs/operations/android-notifications.md b/docs/operations/android-notifications.md index 262e8f3356..13dd53f9eb 100644 --- a/docs/operations/android-notifications.md +++ b/docs/operations/android-notifications.md @@ -135,17 +135,18 @@ background the app, and test a notification tap. Also test dismissal, disabling sign-out, token rotation, and delivery after the app process has exited. Android Settings **Force stop** intentionally prevents delivery until the app is opened again. -Android suppresses ordinary alerts while the app is foregrounded, matching iOS notification -presentation. Activity cards still update in the foreground and retain finished results silently. -Check that completion stays quiet with the app open, that a later completion alerts after -backgrounding, and that retrying a foreground-suppressed alert does not show it later. This uses the -app lifecycle on the receiving phone, not thread visibility on other clients. +With the app in the foreground, Android suppresses an alert only for the thread currently on +screen; alerts for other threads still show. Activity cards still update in the foreground and +retain finished results silently. Check that completion stays quiet while its thread is open, +alerts while another screen is open, alerts after backgrounding, and that retrying a suppressed +alert does not show it later. This uses the app lifecycle and route on the receiving phone, not +thread visibility on other clients. With ongoing activity enabled, verify two threads entering approval/input together produce one `2 agents need attention` alert, and two observed active threads completing/failing together produce one `2 agents finished` alert. The body lists their titles. The relay shares iOS transition selection and retains its delivered baseline when work finishes; publishing the same states again -must not produce another alert. Grouped alerts open the aggregate's priority thread; individual +must not produce another alert. Grouped alerts open the overview; individual alerts retain their thread link. Verify an expanded card with five threads, attention/failure priority, project names and statuses. diff --git a/infra/relay/src/agentActivity/ApnsClient.test.ts b/infra/relay/src/agentActivity/ApnsClient.test.ts index 03010ce48c..bcbf1f4f78 100644 --- a/infra/relay/src/agentActivity/ApnsClient.test.ts +++ b/infra/relay/src/agentActivity/ApnsClient.test.ts @@ -187,6 +187,7 @@ describe("ApnsClient", () => { body: "Input: Project", }, sound: "default", + "thread-id": '["env","thread"]', }, environmentId: "env", threadId: "thread", @@ -195,6 +196,27 @@ describe("ApnsClient", () => { }).pipe(Effect.provide(TestLayer)), ); + it.effect("keeps thread groups distinct when identifiers contain slashes", () => + Effect.gen(function* () { + const apns = yield* ApnsClient.ApnsClient; + const group = (environmentId: string, threadId: string) => + apns.makePushNotificationRequest({ + token: "push-token", + notification: { title: "Thread", body: "Done", environmentId, threadId, deepLink: "/" }, + }).payload; + expect(group("a/b", "c")).toMatchObject({ + aps: { "thread-id": '["a/b","c"]' }, + }); + expect(group("a", "b/c")).toMatchObject({ + aps: { "thread-id": '["a","b/c"]' }, + }); + expect(group("a/b", "c")).not.toMatchObject({ + aps: { "thread-id": '["a","b/c"]' }, + }); + expect(group("", "")).toMatchObject({ aps: { "thread-id": "pylon-agent-alerts" } }); + }).pipe(Effect.provide(TestLayer)), + ); + it.effect("preserves JWT signing context and the crypto cause", () => Effect.gen(function* () { const apns = yield* ApnsClient.ApnsClient; diff --git a/infra/relay/src/agentActivity/ApnsClient.ts b/infra/relay/src/agentActivity/ApnsClient.ts index 488f5919e1..c374739788 100644 --- a/infra/relay/src/agentActivity/ApnsClient.ts +++ b/infra/relay/src/agentActivity/ApnsClient.ts @@ -173,6 +173,12 @@ function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveA }; } +function notificationThreadId(notification: ApnsNotificationPayload): string { + return notification.threadId.length > 0 + ? JSON.stringify([notification.environmentId, notification.threadId]) + : "pylon-agent-alerts"; +} + function makePushNotificationRequest(input: { readonly token: string; readonly notification: ApnsNotificationPayload; @@ -187,6 +193,9 @@ function makePushNotificationRequest(input: { body: input.notification.body, }, sound: "default", + // Notification Center stacks alerts by thread so a chatty thread does + // not bury the others; a grouped alert for several threads stays alone. + "thread-id": notificationThreadId(input.notification), }, environmentId: input.notification.environmentId, threadId: input.notification.threadId, diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index 24c73331a3..dd72313fd4 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -221,28 +221,31 @@ describe("Android delivery routing", () => { ["waiting_for_approval", "waiting_for_input", "2 agents need attention", "true"], ["completed", "failed", "2 agents finished", "false"], ] as const) { - it.effect(`groups ${firstPhase} and ${secondPhase} once across their queued jobs`, () => { - const h = harness(); - h.current.otherStates = [secondState]; - return Effect.gen(function* () { - const delivery = yield* FcmDeliveries.FcmDeliveries; - yield* delivery.process(h.job); - h.current.state = { ...state, phase: firstPhase }; - h.current.otherStates = [{ ...secondState, phase: secondPhase }]; - yield* delivery.process({ ...h.job, state: h.current.state }); - yield* delivery.process({ ...h.job, state: h.current.otherStates[0] }); - yield* delivery.process({ ...h.job, state: h.current.state }); - const alerts = h.sent.filter((message) => message.alert); - expect(alerts).toHaveLength(1); - expect(alerts[0]?.data).toMatchObject({ - alert_title: title, - alert_body: "Fix notifications, Second thread", - alert_path: firstPhase === "completed" ? secondState.deepLink : state.deepLink, - active, - }); - expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2); - }).pipe(Effect.provide(h.layer)); - }); + it.effect( + `routes grouped ${firstPhase} and ${secondPhase} to the overview once across their queued jobs`, + () => { + const h = harness(); + h.current.otherStates = [secondState]; + return Effect.gen(function* () { + const delivery = yield* FcmDeliveries.FcmDeliveries; + yield* delivery.process(h.job); + h.current.state = { ...state, phase: firstPhase }; + h.current.otherStates = [{ ...secondState, phase: secondPhase }]; + yield* delivery.process({ ...h.job, state: h.current.state }); + yield* delivery.process({ ...h.job, state: h.current.otherStates[0] }); + yield* delivery.process({ ...h.job, state: h.current.state }); + const alerts = h.sent.filter((message) => message.alert); + expect(alerts).toHaveLength(1); + expect(alerts[0]?.data).toMatchObject({ + alert_title: title, + alert_body: "Fix notifications, Second thread", + alert_path: "/", + active, + }); + expect(h.marked.at(-1)?.aggregate?.activities).toHaveLength(2); + }).pipe(Effect.provide(h.layer)); + }, + ); } it.effect("filters disabled event types before counting a group", () => { @@ -450,9 +453,34 @@ describe("Android delivery routing", () => { ).toMatchObject({ alert_title: `${"T".repeat(117)}...`, alert_body: `Done: ${"P".repeat(111)}...`, + alert_group: JSON.stringify([state.environmentId, state.threadId]), }); }); + it("keeps notification groups distinct when identifiers contain slashes", () => { + const left = FcmDeliveries.androidAlertForState( + { + ...state, + environmentId: EnvironmentId.make("a/b"), + threadId: ThreadId.make("c"), + phase: "completed", + }, + preferences, + 0, + ); + const right = FcmDeliveries.androidAlertForState( + { + ...state, + environmentId: EnvironmentId.make("a"), + threadId: ThreadId.make("b/c"), + phase: "completed", + }, + preferences, + 0, + ); + expect(left?.alert_group).not.toBe(right?.alert_group); + }); + it.effect( "queues Android devices and sends the latest aggregate instead of a stale running state", () => { diff --git a/infra/relay/src/agentActivity/FcmDeliveries.ts b/infra/relay/src/agentActivity/FcmDeliveries.ts index 8f998d9d95..8f830a8af7 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.ts @@ -22,7 +22,7 @@ import * as AgentActivityRows from "./AgentActivityRows.ts"; import * as LiveActivities from "./LiveActivities.ts"; import * as FcmDeliveryQueueSender from "./FcmDeliveryQueueSender.ts"; import * as FcmClient from "./FcmClient.ts"; -import { androidActivityData, androidActivityHero, fitFcmData } from "./fcmPayloads.ts"; +import { androidActivityData, fitFcmData } from "./fcmPayloads.ts"; import { makeAggregateState, statusForPhase } from "./agentActivityAggregate.ts"; import { isExpiredAgentActivityState, notificationForActivity } from "./agentActivityPayloads.ts"; import { @@ -67,6 +67,7 @@ export function androidAlertForState( const notification = notificationForActivity({ ...state, status: statusForPhase(state.phase) }); return { alert_id: JSON.stringify([state.environmentId, state.threadId, state.phase, state.updatedAt]), + alert_group: JSON.stringify([state.environmentId, state.threadId]), alert_title: notification.title, alert_body: notification.body, alert_path: notification.deepLink, @@ -92,6 +93,7 @@ export function androidAlertForAggregate(input: { const notification = notificationForActivity(first); return { alert_id: JSON.stringify([first.environmentId, first.threadId, first.phase, first.updatedAt]), + alert_group: JSON.stringify([first.environmentId, first.threadId]), alert_title: notification.title, alert_body: notification.body, alert_path: notification.deepLink, @@ -107,7 +109,9 @@ export function androidAlertForAggregate(input: { ), alert_title: alert.title, alert_body: alert.body, - alert_path: androidActivityHero(input.nextAggregate)?.deepLink ?? "/", + // A multi-thread alert targets the overview so one visible thread cannot + // suppress notifications for the other threads in the group. + alert_path: "/", }; } @@ -210,7 +214,7 @@ export const make = Effect.gen(function* () { const previousAggregate = target.last_aggregate_json ? Option.getOrNull(decodePreviousActivity(target.last_aggregate_json)) : null; - let alert: ReturnType = null; + let alert: ReturnType = null; // Deletion jobs can observe another thread's newly completed state. They // update the card, but must leave that transition for its own alert job. // Registration replay deliberately establishes a silent baseline. @@ -291,7 +295,7 @@ export const make = Effect.gen(function* () { // A registration replay must clear an orphan even when the relay has // already forgotten its baseline. Finished cards are visible, but idle. if (!displayedAggregate && !alert && !previousAggregate && job.state !== null) return; - const data = { + const data: Record = { t3_kind: "agent_activity", device_id: job.deviceId, user_id: job.userId,