1import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay";
2import {
3 activityPhasePriority,
4 TERMINAL_AGENT_ACTIVITY_DISPLAY_TTL_MS,
5} from "./agentActivityAggregate.ts";
6import { agentActivityExpiresAt } from "./agentActivityPayloads.ts";
7
8/** Android focuses the priority thread while retaining rows for older clients. */
9export 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. */
54export 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}