| 1 | import { |
| 2 | worktreeSetupStageLabel, |
| 3 | type WorktreeSetupSnapshot, |
| 4 | type WorktreeSetupStage, |
| 5 | } from "@t3tools/contracts"; |
| 6 | import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; |
| 7 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; |
| 8 | import { useEffect, useState } from "react"; |
| 9 | import { AppState, Platform, Pressable, ScrollView, View } from "react-native"; |
| 10 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 11 | import { AppActivityIndicator } from "../../components/AppActivityIndicator"; |
| 12 | import { AppText as Text } from "../../components/AppText"; |
| 13 | import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; |
| 14 | import { WorktreeSetupSheet } from "./worktree-setup-sheet"; |
| 15 | import { ShimmeringWorkContent } from "./thread-work-log"; |
| 16 | |
| 17 | export interface WorktreeSetupCardProps { |
| 18 | snapshot: WorktreeSetupSnapshot; |
| 19 | turnStarted: boolean; |
| 20 | turnStartedAt: string | null; |
| 21 | working: boolean; |
| 22 | onCancel: () => void; |
| 23 | onWorkLocally: (() => void) | null; |
| 24 | } |
| 25 | |
| 26 | function 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 | |
| 31 | const 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. */ |
| 41 | export 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 | |
| 149 | export 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 | |
| 160 | function 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 | |
| 191 | function 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 | |
| 206 | function 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 | |
| 278 | function 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 | |
| 366 | const OUTPUT_TAIL_SLOTS = [0, 1, 2, 3] as const; |
| 367 | |
| 368 | /** Fixed four-line output window, shown only in Details. */ |
| 369 | function 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 | } |