1import { describe, expect, it, vi } from "vite-plus/test";
2
3vi.mock("@expo/ui/swift-ui", () => ({
4 HStack: "HStack",
5 Image: "Image",
6 Spacer: "Spacer",
7 Text: "Text",
8 VStack: "VStack",
9 ZStack: "ZStack",
10}));
11
12vi.mock("@expo/ui/swift-ui/modifiers", () => ({
13 activityBackgroundTint: (value: unknown) => value,
14 font: (value: unknown) => value,
15 foregroundStyle: (value: unknown) => value,
16 frame: (value: unknown) => value,
17 layoutPriority: (value: unknown) => value,
18 lineLimit: (value: unknown) => value,
19 padding: (value: unknown) => value,
20 resizable: (value: unknown) => value,
21 widgetURL: (value: unknown) => ({ widgetURL: value }),
22}));
23
24vi.mock("expo-widgets", () => ({
25 createLiveActivity: vi.fn((name: string, layout: unknown) => ({ layout, name })),
26}));
27
28import {
29 AgentActivity,
30 type AgentActivityProps,
31 type AgentActivityRowProps,
32} from "./AgentActivity";
33
34function makeRow(overrides: Partial<AgentActivityRowProps>): AgentActivityRowProps {
35 return {
36 environmentId: "env-1",
37 threadId: "thread-1",
38 projectTitle: "Project",
39 threadTitle: "Thread",
40 modelTitle: "gpt-5.4",
41 phase: "running",
42 status: "Working",
43 updatedAt: "2026-05-25T13:07:00.000Z",
44 deepLink: "/threads/env-1/thread-1",
45 ...overrides,
46 };
47}
48
49const props = {
50 title: "T3 Code",
51 subtitle: "Agent work in progress",
52 activeCount: 1,
53 updatedAt: "2026-05-25T13:07:00.000Z",
54 activities: [],
55} satisfies AgentActivityProps;
56
57const environment = {
58 colorScheme: "dark",
59 isLuminanceReduced: false,
60} as const;
61
62const lightEnvironment = {
63 colorScheme: "light",
64 isLuminanceReduced: false,
65} as const;
66
67describe("AgentActivity widget layout", () => {
68 it("tints each row by its own phase using the web sidebar's dark palette", () => {
69 const layout = AgentActivity(
70 {
71 ...props,
72 activeCount: 2,
73 activities: [
74 makeRow({}),
75 makeRow({ threadId: "thread-2", phase: "waiting_for_approval", status: "Approval" }),
76 ],
77 },
78 environment as never,
79 );
80 const banner = JSON.stringify(layout.banner);
81 expect(banner).toContain("#7dd3fc"); // sky-300: running
82 expect(banner).toContain("#fcd34d"); // amber-300: waiting_for_approval
83 });
84
85 it("degrades in-flight rows once the system marks the activity stale", () => {
86 const layout = AgentActivity(
87 {
88 ...props,
89 activeCount: 2,
90 activities: [
91 makeRow({}),
92 makeRow({ threadId: "thread-2", phase: "completed", status: "Done" }),
93 ],
94 },
95 { ...environment, isStale: true } as never,
96 );
97 const banner = JSON.stringify(layout.banner);
98 expect(banner).toContain("Agent status out of date");
99 expect(banner).toContain("Out of date");
100 expect(banner).not.toContain("#7dd3fc"); // sky-300: running
101 expect(banner).toContain("Done");
102 expect(JSON.stringify(layout.minimal)).not.toContain("2");
103 });
104
105 it("switches to the web sidebar's light palette when the scheme is light", () => {
106 // macOS (iPhone Mirroring / Mac notification center) renders the activity
107 // on a light background; the dark-material palette is illegible there.
108 const layout = AgentActivity(
109 {
110 ...props,
111 activeCount: 2,
112 activities: [
113 makeRow({}),
114 makeRow({ threadId: "thread-2", phase: "waiting_for_approval", status: "Approval" }),
115 ],
116 },
117 lightEnvironment as never,
118 );
119 const banner = JSON.stringify(layout.banner);
120 expect(banner).toContain("#0284c7"); // sky-600: running
121 expect(banner).toContain("#d97706"); // amber-600: waiting_for_approval
122 expect(banner).not.toContain("#7dd3fc");
123 expect(banner).not.toContain("#fcd34d");
124 });
125
126 it("orders rows attention-first in the banner", () => {
127 const layout = AgentActivity(
128 {
129 ...props,
130 activeCount: 2,
131 activities: [
132 makeRow({ threadTitle: "Working thread" }),
133 makeRow({
134 threadId: "thread-2",
135 threadTitle: "Blocked thread",
136 phase: "waiting_for_approval",
137 status: "Approval",
138 }),
139 ],
140 },
141 environment as never,
142 );
143 const banner = JSON.stringify(layout.banner);
144 expect(banner.indexOf("Blocked thread")).toBeGreaterThan(-1);
145 expect(banner.indexOf("Blocked thread")).toBeLessThan(banner.indexOf("Working thread"));
146 });
147
148 it("summarizes the attention count in the banner header", () => {
149 const layout = AgentActivity(
150 {
151 ...props,
152 activeCount: 3,
153 activities: [
154 makeRow({}),
155 makeRow({ threadId: "thread-2", phase: "waiting_for_input", status: "Input" }),
156 ],
157 },
158 environment as never,
159 );
160 const banner = JSON.stringify(layout.banner);
161 expect(banner).toContain("3 active agents");
162 expect(banner).toContain("1 needs attention");
163 });
164
165 it("uses the attention tint for the compact presentations when a row needs input", () => {
166 const layout = AgentActivity(
167 {
168 ...props,
169 activeCount: 2,
170 activities: [
171 makeRow({}),
172 makeRow({ threadId: "thread-2", phase: "waiting_for_input", status: "Input" }),
173 ],
174 },
175 environment as never,
176 );
177 expect(JSON.stringify(layout.compactLeading)).toContain("#a5b4fc"); // indigo-300
178 expect(JSON.stringify(layout.compactTrailing)).toContain("Input");
179 expect(JSON.stringify(layout.minimal)).toContain("#a5b4fc");
180 });
181
182 it("deep links the banner to the row that needs attention", () => {
183 const layout = AgentActivity(
184 {
185 ...props,
186 activeCount: 2,
187 activities: [
188 makeRow({}),
189 makeRow({
190 threadId: "thread-2",
191 phase: "waiting_for_approval",
192 status: "Approval",
193 deepLink: "/threads/env-1/thread-2",
194 }),
195 ],
196 },
197 environment as never,
198 );
199 expect(JSON.stringify(layout.banner)).toContain(
200 '"widgetURL":"t3code://threads/env-1/thread-2"',
201 );
202 });
203
204 it("deep links the banner to the first row when nothing needs attention", () => {
205 const layout = AgentActivity({ ...props, activities: [makeRow({})] }, environment as never);
206 expect(JSON.stringify(layout.banner)).toContain(
207 '"widgetURL":"t3code://threads/env-1/thread-1"',
208 );
209 });
210
211 it("omits the deep link for unsafe paths and empty aggregates", () => {
212 expect(JSON.stringify(AgentActivity(props, environment as never))).not.toContain("widgetURL");
213 expect(
214 JSON.stringify(
215 AgentActivity(
216 { ...props, activities: [makeRow({ deepLink: "//evil.example" })] },
217 environment as never,
218 ),
219 ),
220 ).not.toContain("widgetURL");
221 });
222
223 it("leads with the outcome instead of a zero count when nothing is active", () => {
224 const layout = AgentActivity(
225 {
226 ...props,
227 subtitle: "Agent work completed",
228 activeCount: 0,
229 activities: [makeRow({ phase: "completed", status: "Done" })],
230 },
231 environment as never,
232 );
233 const banner = JSON.stringify(layout.banner);
234 expect(banner).toContain("Agent work completed");
235 expect(banner).not.toContain("0 active");
236 expect(banner).toContain("#6ee7b7"); // emerald-300 header tint
237 expect(JSON.stringify(layout.compactTrailing)).toContain("Done");
238 expect(JSON.stringify(layout.compactTrailing)).not.toContain("0 active");
239 expect(JSON.stringify(layout.expandedLeading)).toContain("Done");
240 expect(JSON.stringify(layout.minimal)).toContain("checkmark.circle.fill");
241 expect(JSON.stringify(layout.bannerSmall)).toContain("Done");
242 });
243
244 it("reads Failed when the finished work ended in failure", () => {
245 const layout = AgentActivity(
246 {
247 ...props,
248 subtitle: "Agent work failed",
249 activeCount: 0,
250 activities: [makeRow({ phase: "failed", status: "Failed" })],
251 },
252 environment as never,
253 );
254 const banner = JSON.stringify(layout.banner);
255 expect(banner).toContain("Agent work failed");
256 expect(banner).toContain("#fca5a5"); // red-300 header tint
257 expect(JSON.stringify(layout.compactTrailing)).toContain("Failed");
258 expect(JSON.stringify(layout.expandedLeading)).toContain("Failed");
259 expect(JSON.stringify(layout.minimal)).toContain("xmark.octagon.fill");
260 });
261
262 it("lets a failure dominate mixed finished outcomes across every presentation", () => {
263 const layout = AgentActivity(
264 {
265 ...props,
266 // The server subtitle keys off the newest terminal row (completed
267 // here); the layout must still read Failed everywhere so the header
268 // text never disagrees with the tint, count slots, or minimal glyph.
269 subtitle: "Agent work completed",
270 activeCount: 0,
271 activities: [
272 makeRow({ phase: "completed", status: "Done" }),
273 makeRow({ threadId: "thread-2", phase: "failed", status: "Failed" }),
274 ],
275 },
276 environment as never,
277 );
278 const banner = JSON.stringify(layout.banner);
279 expect(banner).toContain("Agent work failed");
280 expect(banner).not.toContain("Agent work completed");
281 expect(banner).toContain("#fca5a5"); // red-300 header tint
282 expect(JSON.stringify(layout.compactTrailing)).toContain("Failed");
283 expect(JSON.stringify(layout.expandedLeading)).toContain("Failed");
284 expect(JSON.stringify(layout.minimal)).toContain("xmark.octagon.fill");
285 });
286
287 it("renders up to five rows in the banner", () => {
288 const layout = AgentActivity(
289 {
290 ...props,
291 activeCount: 6,
292 activities: [1, 2, 3, 4, 5, 6].map((n) =>
293 makeRow({ threadId: `t${n}`, threadTitle: `Thread ${n}` }),
294 ),
295 },
296 environment as never,
297 );
298 const banner = JSON.stringify(layout.banner);
299 for (const visible of [1, 2, 3, 4, 5]) {
300 expect(banner).toContain(`Thread ${visible}`);
301 }
302 expect(banner).not.toContain("Thread 6");
303 });
304});