| 1 | import type { |
| 2 | EnvironmentProject, |
| 3 | EnvironmentThreadShell, |
| 4 | } from "@t3tools/client-runtime/state/shell"; |
| 5 | |
| 6 | import type { |
| 7 | AgentActivityPhase, |
| 8 | AgentActivityProps, |
| 9 | AgentActivityRowProps, |
| 10 | } from "../../widgets/AgentActivity"; |
| 11 | |
| 12 | /** |
| 13 | * Agent work shown by the agent-activity scene. The rows point at |
| 14 | * seeded showcase threads so the copy matches the thread list, but the phases |
| 15 | * are staged: the relay that normally derives them is not part of the capture. |
| 16 | * Rows follow the relay's order: attention first, then running, then finished. |
| 17 | */ |
| 18 | export const SHOWCASE_AGENT_ACTIVITY_ROWS = [ |
| 19 | { threadId: "pocket-command-center", phase: "waiting_for_approval", minutesAgo: 1 }, |
| 20 | { threadId: "beautiful-boot", phase: "waiting_for_input", minutesAgo: 4 }, |
| 21 | { threadId: "buttery-suspense", phase: "running", minutesAgo: 2 }, |
| 22 | { threadId: "remote-command-center", phase: "completed", minutesAgo: 3 }, |
| 23 | ] as const satisfies ReadonlyArray<{ |
| 24 | readonly threadId: string; |
| 25 | readonly phase: AgentActivityPhase; |
| 26 | readonly minutesAgo: number; |
| 27 | }>; |
| 28 | |
| 29 | // Matches the relay's row wording (AgentActivityPublisher.statusForPhase). |
| 30 | const STATUS_BY_PHASE: Record<AgentActivityPhase, string> = { |
| 31 | starting: "Connecting", |
| 32 | running: "Working", |
| 33 | waiting_for_approval: "Approval", |
| 34 | waiting_for_input: "Input", |
| 35 | stale: "Waiting", |
| 36 | completed: "Done", |
| 37 | failed: "Failed", |
| 38 | }; |
| 39 | |
| 40 | const ACTIVE_PHASES: ReadonlySet<AgentActivityPhase> = new Set([ |
| 41 | "starting", |
| 42 | "running", |
| 43 | "waiting_for_approval", |
| 44 | "waiting_for_input", |
| 45 | ]); |
| 46 | |
| 47 | /** Returns null until every staged thread and its project have loaded. */ |
| 48 | export function buildShowcaseAgentActivity( |
| 49 | threads: ReadonlyArray<EnvironmentThreadShell>, |
| 50 | projects: ReadonlyArray<EnvironmentProject>, |
| 51 | now: number, |
| 52 | ): AgentActivityProps | null { |
| 53 | const rows: AgentActivityRowProps[] = []; |
| 54 | for (const definition of SHOWCASE_AGENT_ACTIVITY_ROWS) { |
| 55 | const thread = threads.find((candidate) => String(candidate.id) === definition.threadId); |
| 56 | const project = thread |
| 57 | ? projects.find( |
| 58 | (candidate) => |
| 59 | candidate.environmentId === thread.environmentId && candidate.id === thread.projectId, |
| 60 | ) |
| 61 | : undefined; |
| 62 | if (!thread || !project) return null; |
| 63 | const environmentId = String(thread.environmentId); |
| 64 | rows.push({ |
| 65 | environmentId, |
| 66 | threadId: definition.threadId, |
| 67 | projectTitle: project.title, |
| 68 | threadTitle: thread.title, |
| 69 | modelTitle: "", |
| 70 | phase: definition.phase, |
| 71 | status: STATUS_BY_PHASE[definition.phase], |
| 72 | updatedAt: new Date(now - definition.minutesAgo * 60_000).toISOString(), |
| 73 | deepLink: `/threads/${encodeURIComponent(environmentId)}/${encodeURIComponent(definition.threadId)}`, |
| 74 | }); |
| 75 | } |
| 76 | return { |
| 77 | title: "The Knife", |
| 78 | subtitle: "Agent work in progress", |
| 79 | activeCount: rows.filter((row) => ACTIVE_PHASES.has(row.phase)).length, |
| 80 | updatedAt: new Date(now).toISOString(), |
| 81 | activities: rows, |
| 82 | }; |
| 83 | } |
| 84 | |
| 85 | /** The alert the relay sends when the hero row starts waiting on the user. */ |
| 86 | export function showcaseAgentAlert(activity: AgentActivityProps) { |
| 87 | const row = activity.activities[0]; |
| 88 | if (!row) return null; |
| 89 | return { |
| 90 | title: row.threadTitle, |
| 91 | body: `${row.status}: ${row.projectTitle}`, |
| 92 | path: row.deepLink, |
| 93 | environmentId: row.environmentId, |
| 94 | threadId: row.threadId, |
| 95 | }; |
| 96 | } |
| 97 | |
| 98 | /** |
| 99 | * The FCM data map Android's native receiver renders, mirroring the relay's |
| 100 | * androidActivityData so the shade shows exactly what a real push produces. |
| 101 | */ |
| 102 | export function showcaseAndroidActivityData( |
| 103 | activity: AgentActivityProps, |
| 104 | now: number, |
| 105 | ): Record<string, string> { |
| 106 | const attentionCount = activity.activities.filter( |
| 107 | (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input", |
| 108 | ).length; |
| 109 | const hero = activity.activities[0]; |
| 110 | const alert = showcaseAgentAlert(activity); |
| 111 | return { |
| 112 | t3_kind: "agent_activity", |
| 113 | updated_at: String(now), |
| 114 | active: String(activity.activeCount > 0), |
| 115 | activity_chip: attentionCount > 0 ? "Review" : "Active", |
| 116 | activity_title: `${activity.activeCount} active agents · ${attentionCount} need attention`, |
| 117 | activity_phase: hero?.phase ?? "", |
| 118 | activity_active_count: String(activity.activeCount), |
| 119 | activity_attention_count: String(attentionCount), |
| 120 | activity_body: hero ? `${hero.status}: ${hero.threadTitle} · ${hero.projectTitle}` : "", |
| 121 | ...Object.fromEntries( |
| 122 | activity.activities.map((row, index) => [ |
| 123 | `activity_line_${index}`, |
| 124 | [row.status, row.threadTitle, row.projectTitle].join("\t"), |
| 125 | ]), |
| 126 | ), |
| 127 | activity_path: hero?.deepLink ?? "/", |
| 128 | activity_expires_at: String(now + 2 * 60 * 60_000), |
| 129 | ...(alert |
| 130 | ? { |
| 131 | alert_id: "showcase-alert", |
| 132 | alert_title: alert.title, |
| 133 | alert_body: alert.body, |
| 134 | alert_path: alert.path, |
| 135 | } |
| 136 | : {}), |
| 137 | }; |
| 138 | } |