| 1 | import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; |
| 2 | import { |
| 3 | activityPhasePriority, |
| 4 | TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS, |
| 5 | } from "./agentActivityAggregate.ts"; |
| 6 | import { agentActivityExpiresAt } from "./agentActivityPayloads.ts"; |
| 7 | |
| 8 | /** Android focuses the priority thread while retaining rows for older clients. */ |
| 9 | export function androidActivityData(aggregate: RelayAgentActivityAggregateState | null) { |
| 10 | const rows = [...(aggregate?.activities ?? [])].sort( |
| 11 | (a, b) => activityPhasePriority(a.phase) - activityPhasePriority(b.phase), |
| 12 | ); |
| 13 | const activeCount = aggregate?.activeCount ?? 0; |
| 14 | const attentionCount = rows.filter((row) => activityPhasePriority(row.phase) === 0).length; |
| 15 | const failed = rows.some((row) => row.phase === "failed"); |
| 16 | const clean = (value: string) => value.replace(/\s+/g, " ").trim(); |
| 17 | const lines = rows.map((row) => |
| 18 | [row.status, clean(row.threadTitle), clean(row.projectTitle)].join("\t"), |
| 19 | ); |
| 20 | const hero = rows[0]; |
| 21 | const title = |
| 22 | activeCount > 0 |
| 23 | ? `${activeCount} active agent${activeCount === 1 ? "" : "s"}${attentionCount ? ` · ${attentionCount} need${attentionCount === 1 ? "s" : ""} attention` : ""}` |
| 24 | : failed |
| 25 | ? "Agent work failed" |
| 26 | : "Agent work completed"; |
| 27 | const expiresAt = Math.max( |
| 28 | 0, |
| 29 | ...rows.map((row) => |
| 30 | row.phase === "completed" || row.phase === "failed" |
| 31 | ? Date.parse(row.updatedAt) + TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS |
| 32 | : agentActivityExpiresAt(row), |
| 33 | ), |
| 34 | ); |
| 35 | return { |
| 36 | active: String(activeCount > 0), |
| 37 | // Keep the status bar chip short enough to display alongside the app icon. |
| 38 | activity_chip: activeCount > 0 ? (attentionCount > 0 ? "Review" : "Active") : "", |
| 39 | activity_title: title, |
| 40 | activity_phase: hero?.phase ?? "", |
| 41 | activity_active_count: String(activeCount), |
| 42 | activity_attention_count: String(attentionCount), |
| 43 | activity_body: hero |
| 44 | ? `${hero.status}: ${clean(hero.threadTitle)} · ${clean(hero.projectTitle)}` |
| 45 | : "", |
| 46 | // Separate keys avoid double JSON encoding and preserve each expanded row when bounding the payload. |
| 47 | ...Object.fromEntries(lines.map((line, index) => [`activity_line_${index}`, line])), |
| 48 | activity_path: rows[0]?.deepLink ?? "/", |
| 49 | activity_expires_at: String(expiresAt), |
| 50 | }; |
| 51 | } |
| 52 | |
| 53 | /** Keep Unicode, escaping and grouped alerts within FCM's 4 KB data budget. */ |
| 54 | export function fitFcmData(input: Readonly<Record<string, string>>): Record<string, string> { |
| 55 | const data = { ...input }; |
| 56 | const encoder = new TextEncoder(); |
| 57 | const textKeys = Object.keys(data).filter( |
| 58 | (key) => key.endsWith("_body") || key.endsWith("_title") || key.startsWith("activity_line_"), |
| 59 | ); |
| 60 | while (encoder.encode(JSON.stringify(data)).length > 3800) { |
| 61 | const key = textKeys.sort( |
| 62 | (a, b) => encoder.encode(data[b]!).length - encoder.encode(data[a]!).length, |
| 63 | )[0]; |
| 64 | if (!key) break; |
| 65 | if (data[key]!.length <= 8) { |
| 66 | textKeys.splice(textKeys.indexOf(key), 1); |
| 67 | continue; |
| 68 | } |
| 69 | const parts = key.startsWith("activity_line_") ? data[key]!.split("\t") : [data[key]!]; |
| 70 | const part = parts.length === 3 ? (parts[1]!.length > parts[2]!.length ? 1 : 2) : 0; |
| 71 | const characters = Array.from(parts[part]!); |
| 72 | // Five characters would become four plus the ellipsis and never shrink. |
| 73 | if (characters.length <= 5) { |
| 74 | textKeys.splice(textKeys.indexOf(key), 1); |
| 75 | continue; |
| 76 | } |
| 77 | parts[part] = |
| 78 | characters |
| 79 | .slice(0, Math.floor(characters.length * 0.8)) |
| 80 | .join("") |
| 81 | .trimEnd() + "…"; |
| 82 | data[key] = parts.join("\t"); |
| 83 | } |
| 84 | return data; |
| 85 | } |