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() {
44 return AgentActivity.getInstances();
55}
66
7export function startAgentLiveActivity(props: AgentActivityProps) {
8 return AgentActivity.start(props);
7export function startAgentLiveActivity(props: AgentActivityProps, staleDate?: Date) {
8 return AgentActivity.start(props, undefined, staleDate);
99}
apps/mobile/src/features/agent-awareness/agentLiveActivity.ts+1
......@@ -7,6 +7,7 @@ export function getAgentLiveActivities(): Array<LiveActivity<AgentActivityProps>
77
88export function startAgentLiveActivity(
99 _props: AgentActivityProps,
10 _staleDate?: Date,
1011): LiveActivity<AgentActivityProps> | null {
1112 return null;
1213}
apps/mobile/src/features/agent-awareness/remoteRegistration.ts+37-26
......@@ -83,6 +83,11 @@ const activityPushTokenListeners = new WeakSet<LiveActivity<AgentActivityProps>>
8383// foreground after real time away still triggers a replay. Cleared on
8484// sign-out/identity change alongside the device registration state.
8585const 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);
8691const registeredActivityPushTokens = new Map<string, number>();
8792let androidDeviceReplayedAt: number | null = null;
8893let pushTokenSubscription: { remove: () => void } | null = null;
......@@ -527,25 +532,28 @@ function armAgentAwarenessLiveActivityForLocalWorkNow(input: {
527532 return;
528533 }
529534 const nowIso = new Date(Date.now()).toISOString();
530 const activity = startAgentLiveActivity({
531 title: "T3 Code",
532 subtitle: "Agent work in progress",
533 activeCount: 1,
534 updatedAt: nowIso,
535 activities: [
536 {
537 environmentId: "",
538 threadId: "",
539 projectTitle: input.projectTitle,
540 threadTitle: input.threadTitle,
541 modelTitle: "",
542 phase: "starting",
543 status: "Connecting",
544 updatedAt: nowIso,
545 deepLink: "/",
546 },
547 ],
548 });
535 const activity = startAgentLiveActivity(
536 {
537 title: "T3 Code",
538 subtitle: "Agent work in progress",
539 activeCount: 1,
540 updatedAt: nowIso,
541 activities: [
542 {
543 environmentId: "",
544 threadId: "",
545 projectTitle: input.projectTitle,
546 threadTitle: input.threadTitle,
547 modelTitle: "",
548 phase: "starting",
549 status: "Connecting",
550 updatedAt: nowIso,
551 deepLink: "/",
552 },
553 ],
554 },
555 liveActivityStaleDate(),
556 );
549557 if (!activity) {
550558 return;
551559 }
......@@ -1131,13 +1139,16 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect<
11311139 const aggregate = snapshot.aggregate;
11321140 const primed = yield* Effect.try({
11331141 try: () =>
1134 startAgentLiveActivity({
1135 title: aggregate.title,
1136 subtitle: aggregate.subtitle,
1137 activeCount: aggregate.activeCount,
1138 updatedAt: aggregate.updatedAt,
1139 activities: aggregate.activities,
1140 }),
1142 startAgentLiveActivity(
1143 {
1144 title: aggregate.title,
1145 subtitle: aggregate.subtitle,
1146 activeCount: aggregate.activeCount,
1147 updatedAt: aggregate.updatedAt,
1148 activities: aggregate.activities,
1149 },
1150 liveActivityStaleDate(),
1151 ),
11411152 catch: (cause) =>
11421153 new AgentAwarenessOperationError({
11431154 operation: "prime-live-activity",
apps/mobile/src/widgets/AgentActivity.test.ts+20
......@@ -82,6 +82,26 @@ describe("AgentActivity widget layout", () => {
8282 expect(banner).toContain("#fcd34d"); // amber-300: waiting_for_approval
8383 });
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
85105 it("switches to the web sidebar's light palette when the scheme is light", () => {
86106 // macOS (iPhone Mirroring / Mac notification center) renders the activity
87107 // 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(
8888 return isLightScheme ? "#dc2626" : "#fca5a5"; // red-600 / red-300
8989 case "completed":
9090 return isLightScheme ? "#059669" : "#6ee7b7"; // emerald-600 / emerald-300
91 case "stale":
92 return secondaryForeground;
9193 case "starting":
9294 case "running":
9395 default:
......@@ -103,20 +105,28 @@ export function AgentActivity(
103105 if (phase === "running" || phase === "starting") return 2;
104106 return 3;
105107 };
106 const ordered = [...props.activities].sort(
107 (a, b) => phasePriority(a.phase) - phasePriority(b.phase),
108 );
108 // Past the stale date the system stops vouching for the content, so every
109 // in-flight row degrades to "stale" rather than claiming an agent is still
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));
109119 const row0 = ordered[0];
110120 const row1 = ordered[1];
111121 const row2 = ordered[2];
112122 const row3 = ordered[3];
113123 const row4 = ordered[4];
114124
115 const attentionRows = props.activities.filter(
125 const attentionRows = activities.filter(
116126 (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input",
117127 );
118128 const attentionRow = attentionRows[0];
119 const failedRow = props.activities.find((row) => row.phase === "failed");
129 const failedRow = activities.find((row) => row.phase === "failed");
120130 const heroRow = attentionRow ?? failedRow ?? row0;
121131 const tint = phaseTint(heroRow?.phase);
122132 // Headline count leans on the accent when a human is actually blocked.
......@@ -141,12 +151,20 @@ export function AgentActivity(
141151 // the two parts in-line so the attention half can carry the accent color;
142152 // `summary` is the short form for tight spots (expanded center, watch card).
143153 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}`;
145159 const attentionSuffix =
146160 attentionRows.length > 0
147161 ? `${attentionRows.length} need${attentionRows.length === 1 ? "s" : ""} attention`
148162 : "";
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`;
150168 const summary = attentionSuffix || activeLabel;
151169
152170 // Any registered scheme variant routes back to this app; taps are delivered