diff --git a/apps/mobile/src/features/agent-awareness/agentLiveActivity.ios.ts b/apps/mobile/src/features/agent-awareness/agentLiveActivity.ios.ts index ab078b29a665..1cb7369af559 100644 --- a/apps/mobile/src/features/agent-awareness/agentLiveActivity.ios.ts +++ b/apps/mobile/src/features/agent-awareness/agentLiveActivity.ios.ts @@ -4,6 +4,6 @@ export function getAgentLiveActivities() { return AgentActivity.getInstances(); } -export function startAgentLiveActivity(props: AgentActivityProps) { - return AgentActivity.start(props); +export function startAgentLiveActivity(props: AgentActivityProps, staleDate?: Date) { + return AgentActivity.start(props, undefined, staleDate); } diff --git a/apps/mobile/src/features/agent-awareness/agentLiveActivity.ts b/apps/mobile/src/features/agent-awareness/agentLiveActivity.ts index 44ac017984d9..18c3ffcf519b 100644 --- a/apps/mobile/src/features/agent-awareness/agentLiveActivity.ts +++ b/apps/mobile/src/features/agent-awareness/agentLiveActivity.ts @@ -7,6 +7,7 @@ export function getAgentLiveActivities(): Array export function startAgentLiveActivity( _props: AgentActivityProps, + _staleDate?: Date, ): LiveActivity | null { return null; } diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index a6c31594327a..7ed7ddc8bae1 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -83,6 +83,11 @@ 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; +// Locally started activities carry the same stale window the relay puts on +// every push (STALE_AFTER_SECONDS in ApnsClient.ts), so a card whose relay +// registration never lands still degrades instead of looking alive forever. +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; @@ -527,25 +532,28 @@ function armAgentAwarenessLiveActivityForLocalWorkNow(input: { return; } const nowIso = new Date(Date.now()).toISOString(); - const activity = startAgentLiveActivity({ - title: "T3 Code", - 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 = startAgentLiveActivity( + { + title: "T3 Code", + 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: "/", + }, + ], + }, + liveActivityStaleDate(), + ); if (!activity) { return; } @@ -1131,13 +1139,16 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< const aggregate = snapshot.aggregate; const primed = yield* Effect.try({ try: () => - startAgentLiveActivity({ - title: aggregate.title, - subtitle: aggregate.subtitle, - activeCount: aggregate.activeCount, - updatedAt: aggregate.updatedAt, - activities: aggregate.activities, - }), + startAgentLiveActivity( + { + title: aggregate.title, + subtitle: aggregate.subtitle, + activeCount: aggregate.activeCount, + updatedAt: aggregate.updatedAt, + activities: aggregate.activities, + }, + 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 467f290a1575..bf02bb138c2f 100644 --- a/apps/mobile/src/widgets/AgentActivity.test.ts +++ b/apps/mobile/src/widgets/AgentActivity.test.ts @@ -82,6 +82,26 @@ 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)).not.toContain("2"); + }); + 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 3e9b417c5aae..bc27e8b88113 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -88,6 +88,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: @@ -103,20 +105,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. @@ -141,12 +151,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