Skip to content
Source
Sign inSign up

AgentActivity.tsx

387 lines12.6 KB

1import { HStack, Image, Spacer, Text, VStack, ZStack } from "@expo/ui/swift-ui";
2import type { ComponentProps } from "react";
3import {
4 accessibilityElement,
5 accessibilityLabel,
6 activityBackgroundTint,
7 font,
8 foregroundStyle,
9 frame,
10 layoutPriority,
11 lineLimit,
12 minimumScaleFactor,
13 padding,
14 resizable,
15 widgetURL,
16} from "@expo/ui/swift-ui/modifiers";
17import {
18 createLiveActivity,
19 type LiveActivityComponent,
20 type LiveActivityLayout,
21} from "expo-widgets";
22​
23type LiveActivityEnvironment = Parameters<LiveActivityComponent<AgentActivityProps>>[1];
24​
25export type AgentActivityPhase =
26 | "starting"
27 | "running"
28 | "waiting_for_approval"
29 | "waiting_for_input"
30 | "completed"
31 | "failed"
32 | "stale";
33​
34export interface AgentActivityRowProps {
35 readonly environmentId: string;
36 readonly threadId: string;
37 readonly projectTitle: string;
38 readonly threadTitle: string;
39 readonly modelTitle: string;
40 readonly phase: AgentActivityPhase;
41 readonly status: string;
42 readonly updatedAt: string;
43 readonly deepLink: string;
45​
46export interface AgentActivityProps {
47 readonly title: string;
48 readonly subtitle: string;
49 readonly activeCount: number;
50 readonly updatedAt: string;
51 readonly activities: ReadonlyArray<AgentActivityRowProps>;
53​
54// Widget serialization excludes module helpers; use only imported view/modifier factories.
55function AgentActivity(
56 props: AgentActivityProps,
57 environment: LiveActivityEnvironment,
58): LiveActivityLayout {
59 "widget";
60​
61 // Hierarchical foregrounds inherit system tint and vibrancy.
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 // iPhone uses dark Live Activity material, macOS light; choose the web palette variant by color scheme.
69 const isLightScheme = environment.colorScheme === "light";
70 const phaseTint = (phase: AgentActivityPhase | undefined): Foreground => {
71 if (environment.isLuminanceReduced) {
72 return secondaryForeground;
73 }
74 if (monochrome) {
75 return primaryForeground;
76 }
77 switch (phase) {
78 case "waiting_for_approval":
79 return isLightScheme ? "#d97706" : "#fcd34d";
80 case "waiting_for_input":
81 return isLightScheme ? "#4f46e5" : "#a5b4fc";
82 case "failed":
83 return isLightScheme ? "#dc2626" : "#fca5a5";
84 case "completed":
85 return isLightScheme ? "#059669" : "#6ee7b7";
86 case "stale":
87 return secondaryForeground;
88 case "starting":
89 case "running":
90 default:
91 return isLightScheme ? "#0284c7" : "#7dd3fc";
92 }
93 };
94​
95 const phasePriority = (phase: AgentActivityPhase): number => {
96 if (phase === "waiting_for_approval" || phase === "waiting_for_input") return 0;
97 if (phase === "failed") return 1;
98 if (phase === "running" || phase === "starting") return 2;
99 return 3;
100 };
101 // Mark in-flight rows stale after expiry so they cannot claim agents still work.
102 const activities: ReadonlyArray<AgentActivityRowProps> = environment.isStale
103 ? props.activities.map((row) =>
104 row.phase === "completed" || row.phase === "failed"
105 ? row
106 : { ...row, phase: "stale", status: "Out of date" },
107 )
108 : props.activities;
109 const ordered = [...activities].sort((a, b) => phasePriority(a.phase) - phasePriority(b.phase));
110 const row0 = ordered[0];
111 const row1 = ordered[1];
112 const row2 = ordered[2];
113 const row3 = ordered[3];
114 const row4 = ordered[4];
115​
116 const attentionRows = activities.filter(
117 (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input",
118 );
119 const attentionRow = attentionRows[0];
120 const failedRow = activities.find((row) => row.phase === "failed");
121 const heroRow = attentionRow ?? failedRow ?? row0;
122 const tint = phaseTint(heroRow?.phase);
123 const headerTint = attentionRow
124 ? phaseTint(attentionRow.phase)
125 : failedRow
126 ? phaseTint(failedRow.phase)
127 : tint;
128​
129 const allDone = props.activeCount === 0;
130 const doneLabel = failedRow ? "Failed" : "Done";
131 const outcomeLabel = failedRow ? "Agent work failed" : "Agent work completed";
132​
133 const agentWord = props.activeCount === 1 ? "agent" : "agents";
134 const agentsLabel = allDone
135 ? outcomeLabel
136 : environment.isStale
137 ? "Agent status out of date"
138 : `${props.activeCount} active ${agentWord}`;
139 const attentionSuffix =
140 attentionRows.length > 0
141 ? `${attentionRows.length} need${attentionRows.length === 1 ? "s" : ""} attention`
142 : "";
143 const activeLabel = allDone
144 ? doneLabel
145 : environment.isStale
146 ? "Out of date"
147 : `${props.activeCount} active`;
148 const showCompactGlyph = !attentionRow && !failedRow && !allDone && !environment.isStale;
149 const summary = attentionSuffix || activeLabel;
150​
151 // Widget taps target the containing app, so its production scheme is safe in every build.
152 const deepLinkRow = attentionRow ?? row0;
153 const deepLink =
154 deepLinkRow && deepLinkRow.deepLink.startsWith("/") && !deepLinkRow.deepLink.startsWith("//")
155 ? `t3code://${deepLinkRow.deepLink.slice(1)}`
156 : null;
157 const linkModifiers = deepLink ? [widgetURL(deepLink)] : [];
158​
159 type SFName = NonNullable<ComponentProps<typeof Image>["systemName"]>;
160 const phaseSymbol = (phase: AgentActivityPhase): SFName => {
161 switch (phase) {
162 case "waiting_for_approval":
163 return "exclamationmark.circle.fill";
164 case "waiting_for_input":
165 return "questionmark.circle.fill";
166 case "failed":
167 return "xmark.octagon.fill";
168 case "completed":
169 return "checkmark.circle.fill";
170 case "starting":
171 return "circle.dotted";
172 case "stale":
173 return "clock.arrow.circlepath";
174 case "running":
175 default:
176 // Matches the thread list's Working indicator.
177 return "circle.dashed";
178 }
179 };
180​
181 // SF Symbols, like the logo, ignore frame/foregroundStyle applied directly to
182 // the image; size + tint them through a container the resizable symbol fills.
183 const renderGlyph = (
184 systemName: SFName,
185 size: number,
186 color: Foreground,
187 modifiers: typeof linkModifiers = [],
188 ) => (
189 <HStack
190 modifiers={[frame({ width: size, height: size }), foregroundStyle(color), ...modifiers]}
191 >
192 <Image systemName={systemName} modifiers={[resizable()]} />
193 </HStack>
194 );
195​
196 const renderCompactRow = (row: AgentActivityRowProps) => (
197 <HStack spacing={7} alignment="center">
198 <Text
199 modifiers={[
200 font({ weight: "semibold", size: 13 }),
201 foregroundStyle(primaryForeground),
202 lineLimit(1),
203 ]}
204 >
205 {row.threadTitle}
206 </Text>
207 {/* Bare texts share shrinkage proportionally; project frames or layoutPriority would prevent balanced truncation. */}
208 <Text modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]}>
209 {row.projectTitle}
210 </Text>
211 <Spacer minLength={8} />
212 <Text
213 modifiers={[
214 font({ weight: "semibold", size: 11 }),
215 foregroundStyle(phaseTint(row.phase)),
216 layoutPriority(1),
217 ]}
218 >
219 {row.status}
220 </Text>
221 </HStack>
222 );
223​
224 // Image views only honor `resizable` directly (frame/foregroundStyle are
225 // dropped), so we size it via a container frame the resizable image fills and
226 // tint it through the container's foreground style, which the template image
227 // inherits.
228 const renderLogo = (height: number, color: Foreground, modifiers: typeof linkModifiers = []) => (
229 <HStack
230 modifiers={[frame({ width: height * 1.5, height }), foregroundStyle(color), ...modifiers]}
231 >
232 <Image assetName="T3Mark" modifiers={[resizable()]} />
233 </HStack>
234 );
235​
236 return {
237 banner: (
238 <VStack
239 alignment="leading"
240 spacing={6}
241 modifiers={[
242 padding({ all: 14 }),
243 // A clear tint reveals iOS 26's glass material; older hosts keep the standard surface.
244 activityBackgroundTint(environment.isLiquidGlassAvailable ? "clear" : null),
245 ...(deepLink ? [widgetURL(deepLink)] : []),
246 ]}
247 >
248 <ZStack>
249 <HStack spacing={0} alignment="center">
250 {renderLogo(13, primaryForeground)}
251 <Spacer minLength={0} />
252 </HStack>
253 <HStack spacing={6} alignment="center">
254 <Spacer minLength={0} />
255 <Text
256 modifiers={[
257 font({ weight: "semibold", size: 13 }),
258 foregroundStyle(allDone ? headerTint : primaryForeground),
259 lineLimit(1),
260 ]}
261 >
262 {agentsLabel}
263 </Text>
264 {attentionSuffix ? (
265 <Text modifiers={[font({ size: 13 }), foregroundStyle(secondaryForeground)]}>·</Text>
266 ) : null}
267 {attentionSuffix ? (
268 <Text
269 modifiers={[
270 font({ weight: "semibold", size: 13 }),
271 foregroundStyle(headerTint),
272 lineLimit(1),
273 ]}
274 >
275 {attentionSuffix}
276 </Text>
277 ) : null}
278 <Spacer minLength={0} />
279 </HStack>
280 </ZStack>
281 {row0 ? renderCompactRow(row0) : null}
282 {row1 ? renderCompactRow(row1) : null}
283 {row2 ? renderCompactRow(row2) : null}
284 {row3 ? renderCompactRow(row3) : null}
285 {row4 ? renderCompactRow(row4) : null}
286 </VStack>
287 ),
288 bannerSmall: (
289 <VStack alignment="leading" spacing={5} modifiers={[padding({ all: 10 })]}>
290 <HStack spacing={7} alignment="center">
291 {renderLogo(14, primaryForeground)}
292 <Text
293 modifiers={[
294 font({ weight: "bold", size: 13 }),
295 foregroundStyle(headerTint),
296 lineLimit(1),
297 ]}
298 >
299 {attentionRows.length > 0 ? summary : activeLabel}
300 </Text>
301 <Spacer minLength={6} />
302 </HStack>
303 {row0 ? (
304 <HStack spacing={7} alignment="center">
305 <Text
306 modifiers={[
307 font({ weight: "semibold", size: 12 }),
308 foregroundStyle(primaryForeground),
309 lineLimit(1),
310 ]}
311 >
312 {row0.threadTitle}
313 </Text>
314 <Spacer minLength={6} />
315 <Text modifiers={[font({ size: 11 }), foregroundStyle(phaseTint(row0.phase))]}>
316 {row0.status}
317 </Text>
318 </HStack>
319 ) : null}
320 </VStack>
321 ),
322 compactLeading: renderLogo(14, tint, linkModifiers),
323 // While agents are only working, stay quiet: the thread list's Working
324 // glyph marks the activity without a count. Labels appear only for
325 // attention or outcomes.
326 compactTrailing: showCompactGlyph ? (
327 <HStack
328 modifiers={[
329 padding({ trailing: 2 }),
330 accessibilityElement("ignore"),
331 accessibilityLabel(agentsLabel),
332 ...linkModifiers,
333 ]}
334 >
335 {renderGlyph(phaseSymbol(heroRow?.phase ?? "running"), 14, tint)}
336 </HStack>
337 ) : (
338 <Text
339 modifiers={[
340 font({ weight: "semibold", size: 11 }),
341 foregroundStyle(tint),
342 lineLimit(1),
343 minimumScaleFactor(0.5),
344 ...linkModifiers,
345 ]}
346 >
347 {attentionRow
348 ? attentionRow.phase === "waiting_for_approval"
349 ? "Approval"
350 : "Input"
351 : failedRow
352 ? "Failed"
353 : activeLabel}
354 </Text>
355 ),
356 minimal: heroRow
357 ? renderGlyph(phaseSymbol(heroRow.phase), 13, phaseTint(heroRow.phase), linkModifiers)
358 : renderLogo(11, tint, linkModifiers),
359 expandedLeading: (
360 <HStack spacing={5} alignment="center" modifiers={[padding({ leading: 4, vertical: 4 })]}>
361 {renderLogo(15, tint)}
362 <Text modifiers={[font({ weight: "bold", size: 13 }), foregroundStyle(tint)]}>
363 {allDone ? doneLabel : `${props.activeCount}`}
364 </Text>
365 </HStack>
366 ),
367 expandedCenter: null,
368 expandedTrailing: null,
369 expandedBottom: (
370 <VStack
371 alignment="leading"
372 spacing={5}
373 modifiers={
374 deepLink
375 ? [padding({ vertical: 2, horizontal: 8 }), widgetURL(deepLink)]
376 : [padding({ vertical: 2, horizontal: 8 })]
377 }
378 >
379 {row0 ? renderCompactRow(row0) : null}
380 {row1 ? renderCompactRow(row1) : null}
381 {row2 ? renderCompactRow(row2) : null}
382 </VStack>
383 ),
384 };
386​
387export default createLiveActivity<AgentActivityProps>("AgentActivity", AgentActivity);