authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-01 10:51:23-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-01 10:51:23-07:00
log09388bf2da51b958fe5c11fb5bade155fe575e58
tree5af6b211946b56ea362df63fd37d145c2397be71
parente41755cf8a9b07a12fefee5134cadacb514d4ad9
signature Signed by PGP key B5690EEEBB952194

feat(mobile): degrade the agent Live Activity once its content goes stale (#12047)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

5 files changed, 85 insertions(+), 35 deletions(-)

apps/mobile/src/features/agent-awareness/agentLiveActivity.ios.ts+2-2
...@@ -4,6 +4,6 @@ export function getAgentLiveActivities() {...@@ -4,6 +4,6 @@ export function getAgentLiveActivities() {
4 return AgentActivity.getInstances();4 return AgentActivity.getInstances();
5}5}
66
7export function startAgentLiveActivity(props: AgentActivityProps) {7export function startAgentLiveActivity(props: AgentActivityProps, staleDate?: Date) {
8 return AgentActivity.start(props);8 return AgentActivity.start(props, undefined, staleDate);
9}9}
apps/mobile/src/features/agent-awareness/agentLiveActivity.ts+1
...@@ -7,6 +7,7 @@ export function getAgentLiveActivities(): Array<LiveActivity<AgentActivityProps>...@@ -7,6 +7,7 @@ export function getAgentLiveActivities(): Array<LiveActivity<AgentActivityProps>
77
8export function startAgentLiveActivity(8export function startAgentLiveActivity(
9 _props: AgentActivityProps,9 _props: AgentActivityProps,
10 _staleDate?: Date,
10): LiveActivity<AgentActivityProps> | null {11): LiveActivity<AgentActivityProps> | null {
11 return null;12 return null;
12}13}
apps/mobile/src/features/agent-awareness/remoteRegistration.ts+37-26
...@@ -83,6 +83,11 @@ const activityPushTokenListeners = new WeakSet<LiveActivity<AgentActivityProps>>...@@ -83,6 +83,11 @@ const activityPushTokenListeners = new WeakSet<LiveActivity<AgentActivityProps>>
83// foreground after real time away still triggers a replay. Cleared on83// foreground after real time away still triggers a replay. Cleared on
84// sign-out/identity change alongside the device registration state.84// sign-out/identity change alongside the device registration state.
85const ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS = 60_000;85const ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS = 60_000;
86// Locally started activities carry the same stale window the relay puts on
87// every push (STALE_AFTER_SECONDS in ApnsClient.ts), so a card whose relay
88// registration never lands still degrades instead of looking alive forever.
89const LIVE_ACTIVITY_STALE_AFTER_MS = 10 * 60_000;
90const liveActivityStaleDate = () => new Date(Date.now() + LIVE_ACTIVITY_STALE_AFTER_MS);
86const registeredActivityPushTokens = new Map<string, number>();91const registeredActivityPushTokens = new Map<string, number>();
87let androidDeviceReplayedAt: number | null = null;92let androidDeviceReplayedAt: number | null = null;
88let pushTokenSubscription: { remove: () => void } | null = null;93let pushTokenSubscription: { remove: () => void } | null = null;
...@@ -527,25 +532,28 @@ function armAgentAwarenessLiveActivityForLocalWorkNow(input: {...@@ -527,25 +532,28 @@ function armAgentAwarenessLiveActivityForLocalWorkNow(input: {
527 return;532 return;
528 }533 }
529 const nowIso = new Date(Date.now()).toISOString();534 const nowIso = new Date(Date.now()).toISOString();
530 const activity = startAgentLiveActivity({535 const activity = startAgentLiveActivity(
531 title: "T3 Code",536 {
532 subtitle: "Agent work in progress",537 title: "T3 Code",
533 activeCount: 1,538 subtitle: "Agent work in progress",
534 updatedAt: nowIso,539 activeCount: 1,
535 activities: [540 updatedAt: nowIso,
536 {541 activities: [
537 environmentId: "",542 {
538 threadId: "",543 environmentId: "",
539 projectTitle: input.projectTitle,544 threadId: "",
540 threadTitle: input.threadTitle,545 projectTitle: input.projectTitle,
541 modelTitle: "",546 threadTitle: input.threadTitle,
542 phase: "starting",547 modelTitle: "",
543 status: "Connecting",548 phase: "starting",
544 updatedAt: nowIso,549 status: "Connecting",
545 deepLink: "/",550 updatedAt: nowIso,
546 },551 deepLink: "/",
547 ],552 },
548 });553 ],
554 },
555 liveActivityStaleDate(),
556 );
549 if (!activity) {557 if (!activity) {
550 return;558 return;
551 }559 }
...@@ -1131,13 +1139,16 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect<...@@ -1131,13 +1139,16 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect<
1131 const aggregate = snapshot.aggregate;1139 const aggregate = snapshot.aggregate;
1132 const primed = yield* Effect.try({1140 const primed = yield* Effect.try({
1133 try: () =>1141 try: () =>
1134 startAgentLiveActivity({1142 startAgentLiveActivity(
1135 title: aggregate.title,1143 {
1136 subtitle: aggregate.subtitle,1144 title: aggregate.title,
1137 activeCount: aggregate.activeCount,1145 subtitle: aggregate.subtitle,
1138 updatedAt: aggregate.updatedAt,1146 activeCount: aggregate.activeCount,
1139 activities: aggregate.activities,1147 updatedAt: aggregate.updatedAt,
1140 }),1148 activities: aggregate.activities,
1149 },
1150 liveActivityStaleDate(),
1151 ),
1141 catch: (cause) =>1152 catch: (cause) =>
1142 new AgentAwarenessOperationError({1153 new AgentAwarenessOperationError({
1143 operation: "prime-live-activity",1154 operation: "prime-live-activity",
apps/mobile/src/widgets/AgentActivity.test.ts+20
...@@ -82,6 +82,26 @@ describe("AgentActivity widget layout", () => {...@@ -82,6 +82,26 @@ describe("AgentActivity widget layout", () => {
82 expect(banner).toContain("#fcd34d"); // amber-300: waiting_for_approval82 expect(banner).toContain("#fcd34d"); // amber-300: waiting_for_approval
83 });83 });
8484
85 it("degrades in-flight rows once the system marks the activity stale", () => {
86 const layout = AgentActivity(
87 {
88 ...props,
89 activeCount: 2,
90 activities: [
91 makeRow({}),
92 makeRow({ threadId: "thread-2", phase: "completed", status: "Done" }),
93 ],
94 },
95 { ...environment, isStale: true } as never,
96 );
97 const banner = JSON.stringify(layout.banner);
98 expect(banner).toContain("Agent status out of date");
99 expect(banner).toContain("Out of date");
100 expect(banner).not.toContain("#7dd3fc"); // sky-300: running
101 expect(banner).toContain("Done");
102 expect(JSON.stringify(layout.minimal)).not.toContain("2");
103 });
104
85 it("switches to the web sidebar's light palette when the scheme is light", () => {105 it("switches to the web sidebar's light palette when the scheme is light", () => {
86 // macOS (iPhone Mirroring / Mac notification center) renders the activity106 // macOS (iPhone Mirroring / Mac notification center) renders the activity
87 // on a light background; the dark-material palette is illegible there.107 // on a light background; the dark-material palette is illegible there.
apps/mobile/src/widgets/AgentActivity.tsx+25-7
...@@ -88,6 +88,8 @@ export function AgentActivity(...@@ -88,6 +88,8 @@ export function AgentActivity(
88 return isLightScheme ? "#dc2626" : "#fca5a5"; // red-600 / red-30088 return isLightScheme ? "#dc2626" : "#fca5a5"; // red-600 / red-300
89 case "completed":89 case "completed":
90 return isLightScheme ? "#059669" : "#6ee7b7"; // emerald-600 / emerald-30090 return isLightScheme ? "#059669" : "#6ee7b7"; // emerald-600 / emerald-300
91 case "stale":
92 return secondaryForeground;
91 case "starting":93 case "starting":
92 case "running":94 case "running":
93 default:95 default:
...@@ -103,20 +105,28 @@ export function AgentActivity(...@@ -103,20 +105,28 @@ export function AgentActivity(
103 if (phase === "running" || phase === "starting") return 2;105 if (phase === "running" || phase === "starting") return 2;
104 return 3;106 return 3;
105 };107 };
106 const ordered = [...props.activities].sort(108 // Past the stale date the system stops vouching for the content, so every
107 (a, b) => phasePriority(a.phase) - phasePriority(b.phase),109 // in-flight row degrades to "stale" rather than claiming an agent is still
108 );110 // working. Terminal phases keep their own state.
111 const activities: ReadonlyArray<AgentActivityRowProps> = environment.isStale
112 ? props.activities.map((row) =>
113 row.phase === "completed" || row.phase === "failed"
114 ? row
115 : { ...row, phase: "stale", status: "Out of date" },
116 )
117 : props.activities;
118 const ordered = [...activities].sort((a, b) => phasePriority(a.phase) - phasePriority(b.phase));
109 const row0 = ordered[0];119 const row0 = ordered[0];
110 const row1 = ordered[1];120 const row1 = ordered[1];
111 const row2 = ordered[2];121 const row2 = ordered[2];
112 const row3 = ordered[3];122 const row3 = ordered[3];
113 const row4 = ordered[4];123 const row4 = ordered[4];
114124
115 const attentionRows = props.activities.filter(125 const attentionRows = activities.filter(
116 (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input",126 (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input",
117 );127 );
118 const attentionRow = attentionRows[0];128 const attentionRow = attentionRows[0];
119 const failedRow = props.activities.find((row) => row.phase === "failed");129 const failedRow = activities.find((row) => row.phase === "failed");
120 const heroRow = attentionRow ?? failedRow ?? row0;130 const heroRow = attentionRow ?? failedRow ?? row0;
121 const tint = phaseTint(heroRow?.phase);131 const tint = phaseTint(heroRow?.phase);
122 // Headline count leans on the accent when a human is actually blocked.132 // Headline count leans on the accent when a human is actually blocked.
...@@ -141,12 +151,20 @@ export function AgentActivity(...@@ -141,12 +151,20 @@ export function AgentActivity(
141 // the two parts in-line so the attention half can carry the accent color;151 // the two parts in-line so the attention half can carry the accent color;
142 // `summary` is the short form for tight spots (expanded center, watch card).152 // `summary` is the short form for tight spots (expanded center, watch card).
143 const agentWord = props.activeCount === 1 ? "agent" : "agents";153 const agentWord = props.activeCount === 1 ? "agent" : "agents";
144 const agentsLabel = allDone ? outcomeLabel : `${props.activeCount} active ${agentWord}`;154 const agentsLabel = allDone
155 ? outcomeLabel
156 : environment.isStale
157 ? "Agent status out of date"
158 : `${props.activeCount} active ${agentWord}`;
145 const attentionSuffix =159 const attentionSuffix =
146 attentionRows.length > 0160 attentionRows.length > 0
147 ? `${attentionRows.length} need${attentionRows.length === 1 ? "s" : ""} attention`161 ? `${attentionRows.length} need${attentionRows.length === 1 ? "s" : ""} attention`
148 : "";162 : "";
149 const activeLabel = allDone ? doneLabel : `${props.activeCount} active`;163 const activeLabel = allDone
164 ? doneLabel
165 : environment.isStale
166 ? "Out of date"
167 : `${props.activeCount} active`;
150 const summary = attentionSuffix || activeLabel;168 const summary = attentionSuffix || activeLabel;
151169
152 // Any registered scheme variant routes back to this app; taps are delivered170 // Any registered scheme variant routes back to this app; taps are delivered