Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ const config: ExpoConfig = {
"expo-asset",
"expo-font",
"expo-secure-store",
["expo-notifications", { defaultChannel: "agent-activity" }],
["@clerk/expo", { theme: "./clerk-theme.json", appleSignIn: !isIosPersonalTeamBuild }],
"expo-web-browser",
[
Expand Down
38 changes: 25 additions & 13 deletions apps/mobile/src/app/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re
type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";

function activityUpdatesLabel(): string {
return Platform.OS === "android" ? "Agent Activity Updates" : "Live Activity Updates";
}

function activityUpdatesDescription(): string {
return Platform.OS === "android" ? "Agent Activity" : "Live Activity";
}

export default function SettingsRouteScreen() {
const router = useRouter();
const { layout } = useAdaptiveWorkspaceLayout();
Expand Down Expand Up @@ -112,7 +120,7 @@ function ConfiguredSettingsRouteScreen() {
}, [isLoaded, isSignedIn, user?.primaryEmailAddress?.emailAddress]);

const refreshNotifications = useCallback(async () => {
if (process.env.EXPO_OS !== "ios") {
if (Platform.OS !== "ios" && Platform.OS !== "android") {
setNotificationStatus("unsupported");
return;
}
Expand Down Expand Up @@ -173,15 +181,15 @@ function ConfiguredSettingsRouteScreen() {
setNotificationStatus("enabled");
Alert.alert(
"Notifications enabled",
"Live Activity notifications are enabled for this device.",
`${activityUpdatesDescription()} notifications are enabled for this device.`,
);
return;
}
if (result.value.type === "unsupported") {
setNotificationStatus("unsupported");
Alert.alert(
"Notifications unavailable",
"Live Activity notifications are only available on iOS.",
"Agent Activity notifications are only available on iOS and Android.",
);
return;
}
Expand All @@ -203,7 +211,7 @@ function ConfiguredSettingsRouteScreen() {
const promptSignIn = useCallback(() => {
Alert.alert(
"Request T3 Cloud access",
"Live Activity updates require approved T3 Cloud access so relay can deliver updates to this device.",
`${activityUpdatesDescription()} updates require approved T3 Cloud access so relay can deliver updates to this device.`,
[
{ text: "Cancel", style: "cancel" },
{ text: "Continue", onPress: () => push("/settings/waitlist") },
Expand All @@ -223,8 +231,10 @@ function ConfiguredSettingsRouteScreen() {
setLiveActivityStatus("disabled");
const error = squashAtomCommandFailure(tokenResult);
Alert.alert(
"Live Activities unavailable",
error instanceof Error ? error.message : "Could not enable Live Activity updates.",
`${activityUpdatesDescription()} updates unavailable`,
error instanceof Error
? error.message
: `Could not enable ${activityUpdatesLabel().toLowerCase()}.`,
);
return;
}
Expand All @@ -248,8 +258,10 @@ function ConfiguredSettingsRouteScreen() {
if (!isAtomCommandInterrupted(updateResult)) {
const error = squashAtomCommandFailure(updateResult);
Alert.alert(
"Live Activities unavailable",
error instanceof Error ? error.message : "Could not enable Live Activity updates.",
`${activityUpdatesDescription()} updates unavailable`,
error instanceof Error
? error.message
: `Could not enable ${activityUpdatesLabel().toLowerCase()}.`,
);
}
return;
Expand All @@ -258,10 +270,10 @@ function ConfiguredSettingsRouteScreen() {
refreshManagedRelayEnvironments();
setLiveActivityStatus("enabled");
Alert.alert(
"Live Activities enabled",
`${activityUpdatesDescription()} updates enabled`,
environmentCount > 0
? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.`
: "Live Activity updates are enabled. Add an environment to start receiving updates.",
? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for ${activityUpdatesLabel().toLowerCase()}.`
: `${activityUpdatesDescription()} updates are enabled. Add an environment to start receiving updates.`,
);
}, [connections, environmentCount, getToken, isSignedIn, promptSignIn]);

Expand All @@ -274,7 +286,7 @@ function ConfiguredSettingsRouteScreen() {

Alert.alert(
"Disable notifications",
"Notification permission is controlled by iOS. Open Settings to disable notifications for T3 Code.",
"Notification permission is controlled by the system. Open Settings to disable notifications for T3 Code.",
[
{ text: "Cancel", style: "cancel" },
{ text: "Open Settings", onPress: () => void Linking.openSettings() },
Expand Down Expand Up @@ -390,7 +402,7 @@ function ConfiguredSettingsRouteScreen() {
!isLoaded || liveActivityStatus === "checking" || liveActivityStatus === "linking"
}
icon="bolt.circle"
label="Live Activity Updates"
label={activityUpdatesLabel()}
value={liveActivityStatus === "enabled" || liveActivityStatus === "linking"}
onValueChange={handleLiveActivitiesChange}
/>
Expand Down
107 changes: 107 additions & 0 deletions apps/mobile/src/features/agent-awareness/androidNotifications.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
import * as Notifications from "expo-notifications";
import { Platform } from "react-native";

import {
ANDROID_AGENT_ACTIVITY_ALERT_NOTIFICATION_CHANNEL_ID,
ANDROID_AGENT_ACTIVITY_NOTIFICATION_CHANNEL_ID,
__resetAndroidAgentActivityNotificationsForTest,
agentNotificationBehaviorForData,
configureAndroidAgentActivityNotificationHandling,
ensureAndroidAgentActivityNotificationChannels,
} from "./androidNotifications";

vi.mock("react-native", () => ({
Platform: {
OS: "android",
},
}));

vi.mock("expo-notifications", () => ({
AndroidImportance: {
DEFAULT: 5,
HIGH: 6,
},
AndroidNotificationPriority: {
DEFAULT: "default",
HIGH: "high",
},
AndroidNotificationVisibility: {
PUBLIC: 1,
},
setNotificationChannelAsync: vi.fn(() => Promise.resolve(null)),
setNotificationHandler: vi.fn(),
}));

describe("Android agent activity notifications", () => {
beforeEach(() => {
Object.assign(Platform, { OS: "android" });
__resetAndroidAgentActivityNotificationsForTest();
vi.clearAllMocks();
});

it("configures ongoing and alert notification channels", async () => {
await ensureAndroidAgentActivityNotificationChannels();

expect(Notifications.setNotificationChannelAsync).toHaveBeenCalledWith(
ANDROID_AGENT_ACTIVITY_NOTIFICATION_CHANNEL_ID,
expect.objectContaining({
name: "Agent Activity",
importance: Notifications.AndroidImportance.DEFAULT,
showBadge: false,
}),
);
expect(Notifications.setNotificationChannelAsync).toHaveBeenCalledWith(
ANDROID_AGENT_ACTIVITY_ALERT_NOTIFICATION_CHANNEL_ID,
expect.objectContaining({
name: "Agent Activity Alerts",
importance: Notifications.AndroidImportance.HIGH,
showBadge: true,
}),
);
});

it("keeps running agent updates quiet in the foreground", () => {
expect(
agentNotificationBehaviorForData({
environmentId: "env-1",
threadId: "thread-1",
phase: "running",
}),
).toEqual({
shouldShowBanner: false,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
priority: Notifications.AndroidNotificationPriority.DEFAULT,
});
});

it("surfaces waiting agent updates prominently in the foreground", () => {
expect(
agentNotificationBehaviorForData({
deepLink: "/threads/env-1/thread-1",
phase: "waiting_for_input",
}),
).toEqual({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: false,
priority: Notifications.AndroidNotificationPriority.HIGH,
});
});

it("registers the foreground handler once on Android only", () => {
configureAndroidAgentActivityNotificationHandling();
configureAndroidAgentActivityNotificationHandling();

expect(Notifications.setNotificationHandler).toHaveBeenCalledTimes(1);

Object.assign(Platform, { OS: "ios" });
__resetAndroidAgentActivityNotificationsForTest();
configureAndroidAgentActivityNotificationHandling();

expect(Notifications.setNotificationHandler).toHaveBeenCalledTimes(1);
});
});
94 changes: 94 additions & 0 deletions apps/mobile/src/features/agent-awareness/androidNotifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import * as Notifications from "expo-notifications";
import { Platform } from "react-native";

import { extractAgentNotificationDataFromNotification } from "./notificationPayload";

export const ANDROID_AGENT_ACTIVITY_NOTIFICATION_CHANNEL_ID = "agent-activity";
export const ANDROID_AGENT_ACTIVITY_ALERT_NOTIFICATION_CHANNEL_ID = "agent-activity-alerts";

export interface AndroidAgentActivityNotificationChannels {
readonly notificationChannelId: string;
readonly alertNotificationChannelId: string;
}

const PROMINENT_AGENT_ACTIVITY_PHASES = new Set([
"waiting_for_approval",
"waiting_for_input",
"failed",
]);

let notificationHandlerConfigured = false;

export function androidAgentActivityNotificationChannels(): AndroidAgentActivityNotificationChannels | null {
if (Platform.OS !== "android") {
return null;
}
return {
notificationChannelId: ANDROID_AGENT_ACTIVITY_NOTIFICATION_CHANNEL_ID,
alertNotificationChannelId: ANDROID_AGENT_ACTIVITY_ALERT_NOTIFICATION_CHANNEL_ID,
};
}

export async function ensureAndroidAgentActivityNotificationChannels(): Promise<AndroidAgentActivityNotificationChannels | null> {
const channels = androidAgentActivityNotificationChannels();
if (!channels) {
return null;
}

await Notifications.setNotificationChannelAsync(channels.notificationChannelId, {
name: "Agent Activity",
description: "Ongoing T3 Code agent status updates.",
importance: Notifications.AndroidImportance.DEFAULT,
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PUBLIC,
showBadge: false,
sound: null,
});
await Notifications.setNotificationChannelAsync(channels.alertNotificationChannelId, {
name: "Agent Activity Alerts",
description: "Prominent T3 Code agent updates that need attention.",
importance: Notifications.AndroidImportance.HIGH,
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PUBLIC,
showBadge: true,
sound: "default",
enableVibrate: true,
});

return channels;
}

export function agentNotificationBehaviorForData(
data: Record<string, unknown> | null,
): Notifications.NotificationBehavior {
const phase = typeof data?.phase === "string" ? data.phase : null;
const isAgentActivityNotification =
typeof data?.deepLink === "string" ||
(typeof data?.environmentId === "string" && typeof data?.threadId === "string");
const isProminent =
isAgentActivityNotification && phase !== null && PROMINENT_AGENT_ACTIVITY_PHASES.has(phase);

return {
shouldShowBanner: isProminent || !isAgentActivityNotification,
shouldShowList: true,
shouldPlaySound: isProminent,
shouldSetBadge: false,
priority: isProminent
? Notifications.AndroidNotificationPriority.HIGH
: Notifications.AndroidNotificationPriority.DEFAULT,
};
}

export function configureAndroidAgentActivityNotificationHandling(): void {
if (notificationHandlerConfigured || Platform.OS !== "android") {
return;
}

notificationHandlerConfigured = true;
Notifications.setNotificationHandler({
handleNotification: async (notification) =>
agentNotificationBehaviorForData(extractAgentNotificationDataFromNotification(notification)),
});
}

export function __resetAndroidAgentActivityNotificationsForTest(): void {
notificationHandlerConfigured = false;
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,25 @@ function responseWithData(data: Record<string, unknown>, identifier = "notificat
};
}

function androidResponseWithRemoteData(
data: Record<string, string>,
identifier = "android-notification-1",
) {
return {
notification: {
request: {
identifier,
content: {},
trigger: {
remoteMessage: {
data,
},
},
},
},
};
}

afterEach(() => {
vi.restoreAllMocks();
});
Expand Down Expand Up @@ -125,6 +144,17 @@ describe("extractAgentNotificationDeepLink", () => {
).toBe("/threads/env%201/thread%2F2");
});

it("falls back to Android Firebase remote message data", () => {
expect(
extractAgentNotificationDeepLink(
androidResponseWithRemoteData({
environmentId: "env 1",
threadId: "thread/2",
}),
),
).toBe("/threads/env%201/thread%2F2");
});

it("falls back to ids when explicit deep link is not an agent thread route", () => {
expect(
extractAgentNotificationDeepLink(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
import * as Notifications from "expo-notifications";
import { useRouter } from "expo-router";

import { configureAndroidAgentActivityNotificationHandling } from "./androidNotifications";
import { routeAgentNotificationResponseOnce } from "./notificationPayload";
import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer";

Expand All @@ -10,6 +11,8 @@ export function useAgentNotificationNavigation(): void {
const handledResponseIds = useRef(new Set<string>());

useEffect(() => {
configureAndroidAgentActivityNotificationHandling();

const handleResponse = (response: Notifications.NotificationResponse): void => {
routeAgentNotificationResponseOnce({
handledResponseIds: handledResponseIds.current,
Expand Down
Loading
Loading