1import {
2 worktreeSetupStageLabel,
3 type WorktreeSetupSnapshot,
4 type WorktreeSetupStage,
5} from "@t3tools/contracts";
6import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
7import { formatDuration } from "@t3tools/shared/orchestrationTiming";
8import { useEffect, useState } from "react";
9import { AppState, Platform, Pressable, ScrollView, View } from "react-native";
10import { useSafeAreaInsets } from "react-native-safe-area-context";
11import { AppActivityIndicator } from "../../components/AppActivityIndicator";
12import { AppText as Text } from "../../components/AppText";
13import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
14import { WorktreeSetupSheet } from "./worktree-setup-sheet";
15import { ShimmeringWorkContent } from "./thread-work-log";
16
17export interface WorktreeSetupCardProps {
18 snapshot: WorktreeSetupSnapshot;
19 turnStarted: boolean;
20 turnStartedAt: string | null;
21 working: boolean;
22 onCancel: () => void;
23 onWorkLocally: (() => void) | null;
24}
25
26function elapsed(start: string | null, end: string | null, now: number) {
27 const duration = (end ? Date.parse(end) : now) - (start ? Date.parse(start) : NaN);
28 return Number.isFinite(duration) ? formatDuration(Math.max(0, duration)) : null;
29}
30
31const icons: Record<WorktreeSetupStage["status"], AppSymbolName> = {
32 pending: "circle",
33 running: "clock",
34 done: "checkmark",
35 skipped: "minus",
36 failed: "xmark",
37 warning: "exclamationmark.triangle",
38};
39
40/** Setup stages collapse into the working header once the agent's turn is live. */
41export function WorktreeSetupCard(props: WorktreeSetupCardProps) {
42 const { snapshot, turnStarted, turnStartedAt, working } = props;
43 const handedOff = turnStarted && worktreeSetupAgentStarted(snapshot);
44 const running = snapshot.phase === "running";
45 const backgroundSetup = handedOff && running;
46 const scriptName = snapshot.setupScript?.name ?? "Setup script";
47 const [detailsOpen, setDetailsOpen] = useState(false);
48 const now = useSetupClock(running || working);
49 const failed =
50 snapshot.phase === "failed" || snapshot.stages.some((stage) => stage.status === "failed");
51 const label =
52 handedOff && working
53 ? `Working for ${elapsed(turnStartedAt, null, now) ?? "0s"}`
54 : running
55 ? handedOff
56 ? "Setup continues…"
57 : "Setting up worktree…"
58 : snapshot.phase === "cancelled"
59 ? "Worktree setup cancelled"
60 : snapshot.phase === "failed"
61 ? "Worktree setup failed"
62 : failed
63 ? "Setup script failed"
64 : "Worktree ready";
65
66 return (
67 <View accessibilityLabel="Worktree setup" className="py-1">
68 <View className="min-h-11 flex-row items-center gap-2 border-b border-border px-1">
69 <HeaderLabel
70 label={label}
71 active={(running || working) && !detailsOpen}
72 failed={failed && !working}
73 />
74 {!handedOff ? (
75 <Text
76 className="text-2xs text-foreground-secondary"
77 style={{ fontVariant: ["tabular-nums"] }}
78 >
79 {elapsed(snapshot.startedAt, snapshot.endedAt, now)}
80 </Text>
81 ) : null}
82 <Pressable
83 accessibilityRole="button"
84 accessibilityLabel={
85 backgroundSetup
86 ? `${scriptName} is still running. Show setup progress.`
87 : "Worktree setup details"
88 }
89 accessibilityState={{ expanded: detailsOpen }}
90 onPress={() => setDetailsOpen(true)}
91 className="min-h-11 max-w-[55%] justify-center px-2"
92 >
93 <View
94 className={
95 backgroundSetup
96 ? "min-w-0 flex-row items-center gap-1 rounded-full border border-border px-2 py-1"
97 : "flex-row items-center gap-1"
98 }
99 >
100 {backgroundSetup ? (
101 <AppActivityIndicator
102 size={Platform.OS === "android" ? 12 : "small"}
103 colorClassName="accent-icon-muted"
104 style={{
105 width: 12,
106 height: 12,
107 transform: Platform.OS === "android" ? undefined : [{ scale: 0.65 }],
108 }}
109 />
110 ) : failed ? (
111 <SymbolView
112 name="exclamationmark.circle"
113 size={12}
114 tintColorClassName="accent-danger-foreground"
115 />
116 ) : null}
117 <Text numberOfLines={1} className="shrink text-2xs text-foreground-secondary">
118 {backgroundSetup ? scriptName : "Details"}
119 </Text>
120 {!backgroundSetup ? (
121 <SymbolView name="chevron.right" size={10} tintColorClassName="accent-icon-muted" />
122 ) : null}
123 </View>
124 </Pressable>
125 </View>
126 {!handedOff ? (
127 <View className="pt-1 pb-2">
128 {snapshot.stages
129 .filter((stage) => stage.id !== "agent")
130 .map((stage) => (
131 <StageRow
132 key={stage.id}
133 stage={stage}
134 scriptName={snapshot.setupScript?.name ?? null}
135 now={now}
136 compact
137 animate={!detailsOpen}
138 />
139 ))}
140 </View>
141 ) : null}
142 {detailsOpen ? (
143 <SetupDetailsSheet {...props} now={now} onClose={() => setDetailsOpen(false)} />
144 ) : null}
145 </View>
146 );
147}
148
149export function WorktreeWorkingHeader({ startedAt }: { startedAt: string }) {
150 const now = useSetupClock(true);
151 return (
152 <View className="py-1">
153 <View className="min-h-11 flex-row items-center border-b border-border px-1">
154 <HeaderLabel label={`Working for ${elapsed(startedAt, null, now) ?? "0s"}`} active />
155 </View>
156 </View>
157 );
158}
159
160function HeaderLabel({
161 label,
162 active,
163 failed = false,
164}: {
165 label: string;
166 active: boolean;
167 failed?: boolean;
168}) {
169 return active ? (
170 <ShimmeringWorkContent
171 icon="clock"
172 iconSubtleColor="transparent"
173 label={label}
174 showIcon={false}
175 />
176 ) : (
177 <Text
178 numberOfLines={1}
179 className={
180 failed
181 ? "flex-1 text-sm text-danger-foreground"
182 : "flex-1 text-sm text-foreground-secondary"
183 }
184 style={{ fontVariant: ["tabular-nums"] }}
185 >
186 {label}
187 </Text>
188 );
189}
190
191function useSetupClock(active: boolean) {
192 const [now, setNow] = useState(Date.now);
193 const [appState, setAppState] = useState(AppState.currentState);
194 useEffect(() => {
195 const subscription = AppState.addEventListener("change", setAppState);
196 return () => subscription.remove();
197 }, []);
198 useEffect(() => {
199 if (!active || appState !== "active") return;
200 const timer = setInterval(() => setNow(Date.now()), 1000);
201 return () => clearInterval(timer);
202 }, [active, appState]);
203 return now;
204}
205
206function SetupDetailsSheet({
207 snapshot,
208 turnStarted,
209 onCancel,
210 onWorkLocally,
211 onClose,
212 now,
213}: WorktreeSetupCardProps & { onClose: () => void; now: number }) {
214 const insets = useSafeAreaInsets();
215 const [bodyHeight, setBodyHeight] = useState(0);
216 const canCancel = snapshot.phase === "running" && !turnStarted;
217 return (
218 <WorktreeSetupSheet height={bodyHeight} onClose={onClose}>
219 <ScrollView
220 contentInsetAdjustmentBehavior="automatic"
221 bounces={false}
222 onContentSizeChange={(_width, height) => setBodyHeight(height)}
223 contentContainerStyle={{
224 paddingHorizontal: 20,
225 paddingTop: 12,
226 paddingBottom: Math.max(20, insets.bottom),
227 }}
228 >
229 {/* Agent startup is the header handoff, not a fifth setup step. */}
230 {snapshot.stages
231 .filter((stage) => stage.id !== "agent")
232 .map((stage) => (
233 <View key={stage.id}>
234 <StageRow stage={stage} scriptName={snapshot.setupScript?.name ?? null} now={now} />
235 {stage.id === "setup-script" &&
236 (stage.status === "running" || stage.status === "failed" || stage.tail.length > 0) ? (
237 <OutputTail lines={stage.tail} failed={stage.status === "failed"} />
238 ) : null}
239 </View>
240 ))}
241 {snapshot.phase === "failed" && snapshot.error ? (
242 <Text selectable className="ml-8 mt-2 text-xs text-danger-foreground">
243 {snapshot.error}
244 </Text>
245 ) : null}
246 {canCancel ? (
247 <View className="mt-3 flex-row items-center justify-end gap-4 border-t border-border pt-1">
248 <Pressable
249 accessibilityRole="button"
250 accessibilityLabel="Cancel worktree setup"
251 onPress={() => {
252 onClose();
253 onCancel();
254 }}
255 className="min-h-11 justify-center px-2"
256 >
257 <Text className="text-sm text-danger-foreground">Cancel setup</Text>
258 </Pressable>
259 {onWorkLocally ? (
260 <Pressable
261 accessibilityRole="button"
262 onPress={() => {
263 onClose();
264 onWorkLocally();
265 }}
266 className="min-h-11 justify-center px-2"
267 >
268 <Text className="text-sm text-foreground">Work locally</Text>
269 </Pressable>
270 ) : null}
271 </View>
272 ) : null}
273 </ScrollView>
274 </WorktreeSetupSheet>
275 );
276}
277
278function StageRow({
279 stage,
280 scriptName,
281 now,
282 compact = false,
283 animate = true,
284}: {
285 stage: WorktreeSetupStage;
286 scriptName: string | null;
287 now: number;
288 compact?: boolean;
289 animate?: boolean;
290}) {
291 const label =
292 stage.id === "setup-script"
293 ? (scriptName ?? worktreeSetupStageLabel(stage.id))
294 : worktreeSetupStageLabel(stage.id);
295 const detail =
296 stage.status === "pending"
297 ? null
298 : stage.status === "skipped"
299 ? (stage.detail ?? "skipped")
300 : stage.id === "checkout" && stage.status === "running" && stage.percent !== null
301 ? `${stage.percent}%`
302 : stage.detail;
303 return (
304 <View
305 accessibilityLabel={`${label}, ${stage.status}`}
306 className={compact ? "min-h-8 flex-row items-center" : "min-h-11 flex-row items-center"}
307 style={{ columnGap: 8, opacity: stage.status === "pending" ? 0.4 : 1 }}
308 >
309 <View className="w-6 items-center">
310 {stage.status === "running" && animate ? (
311 <AppActivityIndicator
312 size="small"
313 colorClassName="accent-icon-muted"
314 style={{ transform: [{ scale: 0.75 }] }}
315 />
316 ) : (
317 <SymbolView
318 name={icons[stage.status]}
319 size={14}
320 tintColorClassName={
321 stage.status === "failed"
322 ? "accent-danger-foreground"
323 : stage.status === "warning"
324 ? "accent-warning-foreground"
325 : "accent-icon-muted"
326 }
327 />
328 )}
329 </View>
330 {stage.status === "running" && animate ? (
331 <ShimmeringWorkContent
332 icon="clock"
333 iconSubtleColor="transparent"
334 label={label}
335 showIcon={false}
336 />
337 ) : (
338 <Text
339 numberOfLines={1}
340 className={
341 stage.status === "failed"
342 ? "flex-1 text-sm text-danger-foreground"
343 : "flex-1 text-sm text-foreground-secondary"
344 }
345 >
346 {label}
347 </Text>
348 )}
349 {detail ? (
350 <Text numberOfLines={1} className="max-w-[25%] shrink text-2xs text-foreground-secondary">
351 {detail}
352 </Text>
353 ) : null}
354 {stage.status !== "pending" && stage.status !== "skipped" ? (
355 <Text
356 className="text-2xs text-foreground-secondary"
357 style={{ fontVariant: ["tabular-nums"] }}
358 >
359 {elapsed(stage.startedAt, stage.endedAt, now)}
360 </Text>
361 ) : null}
362 </View>
363 );
364}
365
366const OUTPUT_TAIL_SLOTS = [0, 1, 2, 3] as const;
367
368/** Fixed four-line output window, shown only in Details. */
369function OutputTail({ lines, failed }: { lines: ReadonlyArray<string>; failed: boolean }) {
370 return (
371 <View
372 accessibilityLabel="Setup script output"
373 className={
374 failed
375 ? "mb-2 ml-8 rounded-md border border-danger-border bg-danger px-3 py-2"
376 : "mb-2 ml-8 rounded-md border border-border bg-card-alt px-3 py-2"
377 }
378 >
379 {OUTPUT_TAIL_SLOTS.map((slot) => (
380 <Text
381 key={slot}
382 selectable
383 numberOfLines={1}
384 className={
385 failed
386 ? "text-2xs leading-5 text-danger-foreground ios:font-[family-name:Menlo] android:font-mono"
387 : "text-2xs leading-5 text-foreground-secondary ios:font-[family-name:Menlo] android:font-mono"
388 }
389 >
390 {lines[lines.length - OUTPUT_TAIL_SLOTS.length + slot] || "\u00a0"}
391 </Text>
392 ))}
393 </View>
394 );
395}