Skip to content
Source
Sign inSign up
Open History to see the last commit

worktree-setup-card.tsx

373 lines11.7 KB

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