1import { HStack, Image, Spacer, Text, VStack, ZStack } from "@expo/ui/swift-ui";
2import type { ComponentProps } from "react";
3import {
4 activityBackgroundTint,
5 font,
6 foregroundStyle,
7 frame,
8 layoutPriority,
9 lineLimit,
10 padding,
11 resizable,
12 widgetURL,
13} from "@expo/ui/swift-ui/modifiers";
14import {
15 createLiveActivity,
16 type LiveActivityComponent,
17 type LiveActivityLayout,
18} from "expo-widgets";
19
20type LiveActivityEnvironment = Parameters<LiveActivityComponent<AgentActivityProps>>[1];
21
22export type AgentActivityPhase =
23 | "starting"
24 | "running"
25 | "waiting_for_approval"
26 | "waiting_for_input"
27 | "completed"
28 | "failed"
29 | "stale";
30
31export interface AgentActivityRowProps {
32 readonly environmentId: string;
33 readonly threadId: string;
34 readonly projectTitle: string;
35 readonly threadTitle: string;
36 readonly modelTitle: string;
37 readonly phase: AgentActivityPhase;
38 readonly status: string;
39 readonly updatedAt: string;
40 readonly deepLink: string;
41}
42
43export interface AgentActivityProps {
44 readonly title: string;
45 readonly subtitle: string;
46 readonly activeCount: number;
47 readonly updatedAt: string;
48 readonly activities: ReadonlyArray<AgentActivityRowProps>;
49}
50
51// This function is serialized into the widget extension's JS bundle, so it
52// must stay self-contained: no references to module-scope helpers, only the
53// imported view/modifier factories.
54export function AgentActivity(
55 props: AgentActivityProps,
56 environment: LiveActivityEnvironment,
57): LiveActivityLayout {
58 "widget";
59
60 // Hierarchical styles inherit the system's foreground treatment, including
61 // tinted and vibrant presentations, rather than resolving to a label color.
62 type Foreground = Parameters<typeof foregroundStyle>[0];
63 const primaryForeground = { type: "hierarchical", style: "primary" } as const;
64 const secondaryForeground = { type: "hierarchical", style: "secondary" } as const;
65 const monochrome =
66 environment.widgetRenderingMode === "accented" || environment.widgetRenderingMode === "vibrant";
67
68 // Status tints mirror the web sidebar's pills
69 // (apps/web/src/components/Sidebar.logic.ts resolveThreadStatusPill): amber
70 // for approval, indigo for input, sky for working, emerald for completed.
71 // On iPhone the LA sits on a dark material, but macOS (iPhone Mirroring /
72 // Mac notification center) renders it on a light one — so pick the web
73 // palette's light (-600) or dark (-300) variant off the color scheme.
74 const isLightScheme = environment.colorScheme === "light";
75 const phaseTint = (phase: AgentActivityPhase | undefined): Foreground => {
76 if (environment.isLuminanceReduced) {
77 return secondaryForeground;
78 }
79 if (monochrome) {
80 return primaryForeground;
81 }
82 switch (phase) {
83 case "waiting_for_approval":
84 return isLightScheme ? "#d97706" : "#fcd34d"; // amber-600 / amber-300
85 case "waiting_for_input":
86 return isLightScheme ? "#4f46e5" : "#a5b4fc"; // indigo-600 / indigo-300
87 case "failed":
88 return isLightScheme ? "#dc2626" : "#fca5a5"; // red-600 / red-300
89 case "completed":
90 return isLightScheme ? "#059669" : "#6ee7b7"; // emerald-600 / emerald-300
91 case "stale":
92 return secondaryForeground;
93 case "starting":
94 case "running":
95 default:
96 return isLightScheme ? "#0284c7" : "#7dd3fc"; // sky-600 / sky-300
97 }
98 };
99
100 // Order attention-first so whatever needs the user floats to the top of every
101 // presentation, then failures, then in-flight work, then finished/stale.
102 const phasePriority = (phase: AgentActivityPhase): number => {
103 if (phase === "waiting_for_approval" || phase === "waiting_for_input") return 0;
104 if (phase === "failed") return 1;
105 if (phase === "running" || phase === "starting") return 2;
106 return 3;
107 };
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));
119 const row0 = ordered[0];
120 const row1 = ordered[1];
121 const row2 = ordered[2];
122 const row3 = ordered[3];
123 const row4 = ordered[4];
124
125 const attentionRows = activities.filter(
126 (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input",
127 );
128 const attentionRow = attentionRows[0];
129 const failedRow = activities.find((row) => row.phase === "failed");
130 const heroRow = attentionRow ?? failedRow ?? row0;
131 const tint = phaseTint(heroRow?.phase);
132 // Headline count leans on the accent when a human is actually blocked.
133 const headerTint = attentionRow
134 ? phaseTint(attentionRow.phase)
135 : failedRow
136 ? phaseTint(failedRow.phase)
137 : tint;
138
139 // With nothing active the aggregate only carries recently finished work, so
140 // "0 active agents" (and a lone "0" in the expanded island) read as broken.
141 // Lead with the outcome instead. The outcome is derived here from the rows
142 // rather than taken from the server subtitle (which keys off the newest
143 // terminal row): every presentation — header text, tint, count slots,
144 // minimal glyph — must agree, and a failure anywhere should dominate a
145 // newer success.
146 const allDone = props.activeCount === 0;
147 const doneLabel = failedRow ? "Failed" : "Done";
148 const outcomeLabel = failedRow ? "Agent work failed" : "Agent work completed";
149
150 // Header copy: "5 active agents" + (", 1 needs attention"). The banner renders
151 // the two parts in-line so the attention half can carry the accent color;
152 // `summary` is the short form for tight spots (expanded center, watch card).
153 const agentWord = props.activeCount === 1 ? "agent" : "agents";
154 const agentsLabel = allDone
155 ? outcomeLabel
156 : environment.isStale
157 ? "Agent status out of date"
158 : `${props.activeCount} active ${agentWord}`;
159 const attentionSuffix =
160 attentionRows.length > 0
161 ? `${attentionRows.length} need${attentionRows.length === 1 ? "s" : ""} attention`
162 : "";
163 const activeLabel = allDone
164 ? doneLabel
165 : environment.isStale
166 ? "Out of date"
167 : `${props.activeCount} active`;
168 const summary = attentionSuffix || activeLabel;
169
170 // Any registered scheme variant routes back to this app; taps are delivered
171 // to the widget's containing app, so the prod scheme is safe for all builds.
172 const deepLinkRow = attentionRow ?? row0;
173 const deepLink =
174 deepLinkRow && deepLinkRow.deepLink.startsWith("/") && !deepLinkRow.deepLink.startsWith("//")
175 ? `t3code://${deepLinkRow.deepLink.slice(1)}`
176 : null;
177
178 // A scannable status glyph per phase — reads faster than colored words and
179 // ties the compact / expanded / banner / watch presentations together.
180 type SFName = NonNullable<ComponentProps<typeof Image>["systemName"]>;
181 const phaseSymbol = (phase: AgentActivityPhase): SFName => {
182 switch (phase) {
183 case "waiting_for_approval":
184 return "exclamationmark.circle.fill";
185 case "waiting_for_input":
186 return "questionmark.circle.fill";
187 case "failed":
188 return "xmark.octagon.fill";
189 case "completed":
190 return "checkmark.circle.fill";
191 case "starting":
192 return "circle.dotted";
193 case "stale":
194 return "clock.arrow.circlepath";
195 case "running":
196 default:
197 return "arrow.triangle.2.circlepath";
198 }
199 };
200
201 // SF Symbols, like the logo, ignore frame/foregroundStyle applied directly to
202 // the image; size + tint them through a container the resizable symbol fills.
203 const renderGlyph = (systemName: SFName, size: number, color: Foreground) => (
204 <HStack modifiers={[frame({ width: size, height: size }), foregroundStyle(color)]}>
205 <Image systemName={systemName} modifiers={[resizable()]} />
206 </HStack>
207 );
208
209 // Single-line row used by every presentation: glyph, title, inline project,
210 // status. The project and status carry layoutPriority(1) so when space runs
211 // out it's the title that truncates, never the (short) project name or the
212 // status label. Single-line keeps rows inside the expanded island's hard
213 // height budget (~160pt) and lets the banner fit more agents.
214 const renderCompactRow = (row: AgentActivityRowProps) => (
215 <HStack spacing={7} alignment="center">
216 <Text
217 modifiers={[
218 font({ weight: "semibold", size: 13 }),
219 foregroundStyle(primaryForeground),
220 lineLimit(1),
221 ]}
222 >
223 {row.threadTitle}
224 </Text>
225 {/* No layoutPriority and no frame on the project: two bare texts take
226 their ideal width when it fits and shrink proportionally only when it
227 doesn't — so short rows never truncate, and long title + long project
228 truncate together. (A maxWidth frame is greedy and reserved its full
229 width even for short names; layoutPriority let the project starve the
230 title.) */}
231 <Text modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]}>
232 {row.projectTitle}
233 </Text>
234 <Spacer minLength={8} />
235 <Text
236 modifiers={[
237 font({ weight: "semibold", size: 11 }),
238 foregroundStyle(phaseTint(row.phase)),
239 layoutPriority(1),
240 ]}
241 >
242 {row.status}
243 </Text>
244 </HStack>
245 );
246
247 const renderLogo = (height: number, color: Foreground) => (
248 <HStack modifiers={[frame({ width: height, height }), foregroundStyle(color)]}>
249 <Image assetName="KnifeMark" modifiers={[resizable()]} />
250 </HStack>
251 );
252
253 return {
254 banner: (
255 <VStack
256 alignment="leading"
257 spacing={6}
258 modifiers={[
259 padding({ all: 14 }),
260 // A clear tint reveals iOS 26's glass material; older hosts keep the standard surface.
261 activityBackgroundTint(environment.isLiquidGlassAvailable ? "clear" : null),
262 ...(deepLink ? [widgetURL(deepLink)] : []),
263 ]}
264 >
265 {/* Logo pinned to the leading edge; the status texts centered across the
266 full width (ZStack so the logo doesn't skew the centering). No footer —
267 overflow beyond the visible rows is inferable from the count. */}
268 <ZStack>
269 <HStack spacing={0} alignment="center">
270 {renderLogo(13, primaryForeground)}
271 <Spacer minLength={0} />
272 </HStack>
273 <HStack spacing={6} alignment="center">
274 <Spacer minLength={0} />
275 <Text
276 modifiers={[
277 font({ weight: "semibold", size: 13 }),
278 // The all-done header carries the outcome tint (emerald /
279 // red) the way the Done/Failed status labels do.
280 foregroundStyle(allDone ? headerTint : primaryForeground),
281 lineLimit(1),
282 ]}
283 >
284 {agentsLabel}
285 </Text>
286 {attentionSuffix ? (
287 <Text modifiers={[font({ size: 13 }), foregroundStyle(secondaryForeground)]}>·</Text>
288 ) : null}
289 {attentionSuffix ? (
290 <Text
291 modifiers={[
292 font({ weight: "semibold", size: 13 }),
293 foregroundStyle(headerTint),
294 lineLimit(1),
295 ]}
296 >
297 {attentionSuffix}
298 </Text>
299 ) : null}
300 <Spacer minLength={0} />
301 </HStack>
302 </ZStack>
303 {row0 ? renderCompactRow(row0) : null}
304 {row1 ? renderCompactRow(row1) : null}
305 {row2 ? renderCompactRow(row2) : null}
306 {row3 ? renderCompactRow(row3) : null}
307 {row4 ? renderCompactRow(row4) : null}
308 </VStack>
309 ),
310 // Compact card for the watchOS Smart Stack + CarPlay (the `.small` family):
311 // brand + count, then the single most important agent with its status glyph.
312 bannerSmall: (
313 <VStack alignment="leading" spacing={5} modifiers={[padding({ all: 10 })]}>
314 <HStack spacing={7} alignment="center">
315 {renderLogo(14, primaryForeground)}
316 <Text
317 modifiers={[
318 font({ weight: "bold", size: 13 }),
319 foregroundStyle(headerTint),
320 lineLimit(1),
321 ]}
322 >
323 {attentionRows.length > 0 ? summary : activeLabel}
324 </Text>
325 <Spacer minLength={6} />
326 </HStack>
327 {row0 ? (
328 <HStack spacing={7} alignment="center">
329 <Text
330 modifiers={[
331 font({ weight: "semibold", size: 12 }),
332 foregroundStyle(primaryForeground),
333 lineLimit(1),
334 ]}
335 >
336 {row0.threadTitle}
337 </Text>
338 <Spacer minLength={6} />
339 <Text modifiers={[font({ size: 11 }), foregroundStyle(phaseTint(row0.phase))]}>
340 {row0.status}
341 </Text>
342 </HStack>
343 ) : null}
344 </VStack>
345 ),
346 compactLeading: renderLogo(14, tint),
347 compactTrailing: (
348 <Text modifiers={[font({ weight: "semibold", size: 11 }), foregroundStyle(tint)]}>
349 {attentionRow
350 ? attentionRow.phase === "waiting_for_approval"
351 ? "Approval"
352 : "Input"
353 : activeLabel}
354 </Text>
355 ),
356 // The shared/minimal form is a ~22pt circle — a single signal reads there,
357 // the wordmark does not. Show the blocking/outcome phase glyph, else the
358 // mark (all-done shows the hero row's checkmark/cross).
359 minimal:
360 (attentionRow || failedRow || allDone) && heroRow
361 ? renderGlyph(phaseSymbol(heroRow.phase), 13, phaseTint(heroRow.phase))
362 : renderLogo(11, tint),
363 expandedLeading: (
364 <HStack spacing={5} alignment="center" modifiers={[padding({ leading: 4, vertical: 4 })]}>
365 {renderLogo(15, tint)}
366 <Text modifiers={[font({ weight: "bold", size: 13 }), foregroundStyle(tint)]}>
367 {allDone ? doneLabel : `${props.activeCount}`}
368 </Text>
369 </HStack>
370 ),
371 // No center content: the phase glyphs + statuses in expandedBottom already
372 // carry the attention signal, and the expanded island's height budget is
373 // tight enough that a summary line there pushed the third row off.
374 expandedCenter: null,
375 // No trailing content: a timestamp is glanceable-lock-screen info, not
376 // useful in a view the user is actively holding open — and the trailing
377 // region hugs the island's corner radius, which clipped it anyway.
378 expandedTrailing: null,
379 expandedBottom: (
380 // Vertical padding only: the expanded region provides its own horizontal
381 // content margins, so `all` padding double-indented the rows.
382 // Horizontal padding keeps both edges clear of the island's corner
383 // curvature (right edge clipped status labels; titles hugged the left).
384 <VStack
385 alignment="leading"
386 spacing={5}
387 modifiers={
388 deepLink
389 ? [padding({ vertical: 2, horizontal: 8 }), widgetURL(deepLink)]
390 : [padding({ vertical: 2, horizontal: 8 })]
391 }
392 >
393 {row0 ? renderCompactRow(row0) : null}
394 {row1 ? renderCompactRow(row1) : null}
395 {row2 ? renderCompactRow(row2) : null}
396 </VStack>
397 ),
398 };
399}
400
401export default createLiveActivity<AgentActivityProps>("AgentActivity", AgentActivity);