diff --git a/eslint.config.js b/eslint.config.js index c30ff0f15..cc1167afc 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,6 +15,7 @@ export default tseslint.config( "packages/web/postcss.config.mjs", "test-clipboard*.mjs", "test-clipboard*.sh", + "packages/mobile/**", ], }, diff --git a/packages/mobile/src/notifications/backgroundTask.ts b/packages/mobile/src/notifications/backgroundTask.ts index f2ff96312..9f52b75a6 100644 --- a/packages/mobile/src/notifications/backgroundTask.ts +++ b/packages/mobile/src/notifications/backgroundTask.ts @@ -47,10 +47,10 @@ TaskManager.defineTask(TASK_ID, async () => { const cooldownExpired = now - lastNotified > COOLDOWN_MS; if (level === "respond" && (prev !== "respond" || cooldownExpired)) { - scheduleNotification(session, "respond"); + await scheduleNotification(session, "respond"); timestamps[session.id] = now; } else if (level === "merge" && (prev !== "merge" || cooldownExpired)) { - scheduleNotification(session, "merge"); + await scheduleNotification(session, "merge"); timestamps[session.id] = now; } } diff --git a/packages/mobile/src/notifications/index.ts b/packages/mobile/src/notifications/index.ts index 85feee713..ebfce9901 100644 --- a/packages/mobile/src/notifications/index.ts +++ b/packages/mobile/src/notifications/index.ts @@ -39,10 +39,10 @@ export async function setupNotifications(): Promise { } /** Fire an immediate local notification for a session attention transition. */ -export function scheduleNotification( +export async function scheduleNotification( session: DashboardSession, level: "respond" | "merge", -): void { +): Promise { const sessionLabel = session.issueLabel ?? session.id; @@ -70,6 +70,9 @@ export function scheduleNotification( ...(Platform.OS === "android" && { channelId: ANDROID_CHANNEL_ID }), }; - // Fire immediately (trigger: null = instant) - void Notifications.scheduleNotificationAsync({ content, trigger: null }); + // Use { seconds: 1 } instead of null — trigger: null fails silently on Android in background tasks + await Notifications.scheduleNotificationAsync({ + content, + trigger: { seconds: 1, channelId: Platform.OS === "android" ? ANDROID_CHANNEL_ID : undefined }, + }); } diff --git a/packages/mobile/src/screens/SessionDetailScreen.tsx b/packages/mobile/src/screens/SessionDetailScreen.tsx index c946e664c..9dff12bb4 100644 --- a/packages/mobile/src/screens/SessionDetailScreen.tsx +++ b/packages/mobile/src/screens/SessionDetailScreen.tsx @@ -22,6 +22,7 @@ import { relativeTime, isRestorable, isTerminal, + isPRRateLimited, ATTENTION_COLORS, type DashboardCICheck, type DashboardUnresolvedComment, @@ -155,7 +156,7 @@ export default function SessionDetailScreen({ route, navigation }: Props) { const canRestore = isRestorable(session); const isDone = isTerminal(session); const pr = session.pr; - const isReadyToMerge = pr?.mergeability.mergeable && pr.state === "open"; + const isReadyToMerge = pr?.mergeability.mergeable && pr.state === "open" && !isPRRateLimited(pr); const failedChecks = pr?.ciChecks.filter((c) => c.status === "failed") ?? []; const unresolvedComments = pr?.unresolvedComments ?? []; diff --git a/packages/mobile/src/types/index.ts b/packages/mobile/src/types/index.ts index 0a941bbdc..43938bf66 100644 --- a/packages/mobile/src/types/index.ts +++ b/packages/mobile/src/types/index.ts @@ -119,26 +119,27 @@ export const ATTENTION_COLORS: Record = { done: "#8b949e", }; -/** Terminal states that cannot be restored */ -const TERMINAL_STATUSES: SessionStatus[] = ["merged", "killed", "cleanup", "done", "terminated"]; +/** Statuses that indicate the session is in a terminal (dead) state. + * Must stay in sync with packages/core/src/types.ts TERMINAL_STATUSES. */ +const TERMINAL_STATUSES: SessionStatus[] = ["killed", "terminated", "done", "cleanup", "errored", "merged"]; const TERMINAL_ACTIVITIES: ActivityState[] = ["exited"]; -const NON_RESTORABLE_STATUSES: SessionStatus[] = ["merged", "done", "terminated"]; + +/** Statuses that must never be restored (e.g. already merged). + * Must stay in sync with packages/core/src/types.ts NON_RESTORABLE_STATUSES. */ +const NON_RESTORABLE_STATUSES: SessionStatus[] = ["merged"]; export function isTerminal(session: DashboardSession): boolean { return ( TERMINAL_STATUSES.includes(session.status) || - TERMINAL_ACTIVITIES.includes(session.activity as ActivityState) + (session.activity !== null && TERMINAL_ACTIVITIES.includes(session.activity)) ); } export function isRestorable(session: DashboardSession): boolean { - return ( - session.activity === "exited" && - !NON_RESTORABLE_STATUSES.includes(session.status) - ); + return isTerminal(session) && !NON_RESTORABLE_STATUSES.includes(session.status); } -function isPRRateLimited(pr: DashboardPR): boolean { +export function isPRRateLimited(pr: DashboardPR): boolean { return pr.mergeability.blockers.includes("API rate limited or unavailable"); }