| 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,6 +4,6 @@ export function getAgentLiveActivities() { |
| 4 | return AgentActivity.getInstances(); | 4 | return AgentActivity.getInstances(); |
| 5 | } | 5 | } |
| 6 | 6 | ||
| 7 | export function startAgentLiveActivity(props: AgentActivityProps) { | 7 | export 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> |
| 7 | 7 | ||
| 8 | export function startAgentLiveActivity( | 8 | export 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 on | 83 | // 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. |
| 85 | const ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS = 60_000; | 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 | const registeredActivityPushTokens = new Map<string, number>(); | 91 | const registeredActivityPushTokens = new Map<string, number>(); |
| 87 | let androidDeviceReplayedAt: number | null = null; | 92 | let androidDeviceReplayedAt: number | null = null; |
| 88 | let pushTokenSubscription: { remove: () => void } | null = null; | 93 | let 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_approval | 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 | 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 activity | 106 | // 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-300 | 88 | return isLightScheme ? "#dc2626" : "#fca5a5"; // red-600 / red-300 |
| 89 | case "completed": | 89 | case "completed": |
| 90 | return isLightScheme ? "#059669" : "#6ee7b7"; // emerald-600 / emerald-300 | 90 | 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]; |
| 114 | 124 | ||
| 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 > 0 | 160 | 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; |
| 151 | 169 | ||
| 152 | // Any registered scheme variant routes back to this app; taps are delivered | 170 | // Any registered scheme variant routes back to this app; taps are delivered |