| author | |
| committer | |
| log | 09388bf2da51b958fe5c11fb5bade155fe575e58 |
| tree | 5af6b211946b56ea362df63fd37d145c2397be71 |
| parent | e41755cf8a9b07a12fefee5134cadacb514d4ad9 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 4 | return AgentActivity.getInstances(); |
| 5 | 5 | } |
| 6 | 6 | |
| 7 | export function startAgentLiveActivity(props: AgentActivityProps) { | |
| 8 | return AgentActivity.start(props); | |
| 7 | export function startAgentLiveActivity(props: AgentActivityProps, staleDate?: Date) { | |
| 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 | 7 | |
| 8 | 8 | export function startAgentLiveActivity( |
| 9 | 9 | _props: AgentActivityProps, |
| 10 | _staleDate?: Date, | |
| 10 | 11 | ): LiveActivity<AgentActivityProps> | null { |
| 11 | 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 | 83 | // foreground after real time away still triggers a replay. Cleared on |
| 84 | 84 | // sign-out/identity change alongside the device registration state. |
| 85 | 85 | const 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. | |
| 89 | const LIVE_ACTIVITY_STALE_AFTER_MS = 10 * 60_000; | |
| 90 | const liveActivityStaleDate = () => new Date(Date.now() + LIVE_ACTIVITY_STALE_AFTER_MS); | |
| 86 | 91 | const registeredActivityPushTokens = new Map<string, number>(); |
| 87 | 92 | let androidDeviceReplayedAt: number | null = null; |
| 88 | 93 | let pushTokenSubscription: { remove: () => void } | null = null; |
| ... | ... | @@ -527,25 +532,28 @@ function armAgentAwarenessLiveActivityForLocalWorkNow(input: { |
| 527 | 532 | return; |
| 528 | 533 | } |
| 529 | 534 | 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 | ); | |
| 549 | 557 | if (!activity) { |
| 550 | 558 | return; |
| 551 | 559 | } |
| ... | ... | @@ -1131,13 +1139,16 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< |
| 1131 | 1139 | const aggregate = snapshot.aggregate; |
| 1132 | 1140 | const primed = yield* Effect.try({ |
| 1133 | 1141 | 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 | ), | |
| 1141 | 1152 | catch: (cause) => |
| 1142 | 1153 | new AgentAwarenessOperationError({ |
| 1143 | 1154 | operation: "prime-live-activity", |
apps/mobile/src/widgets/AgentActivity.test.ts+20| ... | ... | @@ -82,6 +82,26 @@ describe("AgentActivity widget layout", () => { |
| 82 | 82 | expect(banner).toContain("#fcd34d"); // amber-300: waiting_for_approval |
| 83 | 83 | }); |
| 84 | 84 | |
| 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 | 105 | it("switches to the web sidebar's light palette when the scheme is light", () => { |
| 86 | 106 | // macOS (iPhone Mirroring / Mac notification center) renders the activity |
| 87 | 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 | 88 | return isLightScheme ? "#dc2626" : "#fca5a5"; // red-600 / red-300 |
| 89 | 89 | case "completed": |
| 90 | 90 | return isLightScheme ? "#059669" : "#6ee7b7"; // emerald-600 / emerald-300 |
| 91 | case "stale": | |
| 92 | return secondaryForeground; | |
| 91 | 93 | case "starting": |
| 92 | 94 | case "running": |
| 93 | 95 | default: |
| ... | ... | @@ -103,20 +105,28 @@ export function AgentActivity( |
| 103 | 105 | if (phase === "running" || phase === "starting") return 2; |
| 104 | 106 | return 3; |
| 105 | 107 | }; |
| 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)); | |
| 109 | 119 | const row0 = ordered[0]; |
| 110 | 120 | const row1 = ordered[1]; |
| 111 | 121 | const row2 = ordered[2]; |
| 112 | 122 | const row3 = ordered[3]; |
| 113 | 123 | const row4 = ordered[4]; |
| 114 | 124 | |
| 115 | const attentionRows = props.activities.filter( | |
| 125 | const attentionRows = activities.filter( | |
| 116 | 126 | (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input", |
| 117 | 127 | ); |
| 118 | 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 | 130 | const heroRow = attentionRow ?? failedRow ?? row0; |
| 121 | 131 | const tint = phaseTint(heroRow?.phase); |
| 122 | 132 | // Headline count leans on the accent when a human is actually blocked. |
| ... | ... | @@ -141,12 +151,20 @@ export function AgentActivity( |
| 141 | 151 | // the two parts in-line so the attention half can carry the accent color; |
| 142 | 152 | // `summary` is the short form for tight spots (expanded center, watch card). |
| 143 | 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 | 159 | const attentionSuffix = |
| 146 | 160 | attentionRows.length > 0 |
| 147 | 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 | 168 | const summary = attentionSuffix || activeLabel; |
| 151 | 169 | |
| 152 | 170 | // Any registered scheme variant routes back to this app; taps are delivered |