1import {
2 worktreeSetupStageLabel,
3 type WorktreeSetupSnapshot,
4 type WorktreeSetupStage,
5} from "@t3tools/contracts";
6import { formatDuration } from "@t3tools/shared/orchestrationTiming";
7import {
8 CheckIcon,
9 CircleAlertIcon,
10 CircleIcon,
11 LaptopIcon,
12 MinusIcon,
13 TerminalIcon,
14 XIcon,
15} from "lucide-react";
16import { ChevronDown, ChevronRight } from "lucide";
17import { useEffect, useState, type ReactNode } from "react";
18
19import { Button } from "~/components/ui/button";
20import { MorphIcon } from "~/components/MorphIcon";
21import { Spinner } from "~/components/ui/spinner";
22import { MiddleTruncate } from "../ui/middle-truncate";
23import { observeVisibleAnimation } from "~/lib/visibleAnimation";
24import { cn } from "~/lib/utils";
25import { WorkLogRow } from "./WorkLog";
26
27interface WorktreeSetupCardProps {
28 snapshot: WorktreeSetupSnapshot;
29 /** Interrupts the server-side bootstrap. Hidden once the setup has settled. */
30 onCancel: (() => void) | null;
31 /** Restarts the same message in the project checkout instead of a worktree. */
32 onWorkLocally: (() => void) | null;
33 /** Reveals the setup script terminal tab. Null when no script ran. */
34 onOpenTerminal: (() => void) | null;
35}
36
37function stageElapsedMs(stage: WorktreeSetupStage, nowMs: number): number | null {
38 if (!stage.startedAt) return null;
39 const start = Date.parse(stage.startedAt);
40 const end = stage.endedAt ? Date.parse(stage.endedAt) : nowMs;
41 if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
42 return Math.max(0, end - start);
43}
44
45/**
46 * Ticks once a second while any stage runs so elapsed labels stay live
47 * without pushing a React commit through the timeline for every second.
48 */
49function useNowWhile(active: boolean): number {
50 const [nowMs, setNowMs] = useState(() => Date.now());
51 useEffect(() => {
52 if (!active) return;
53 const id = setInterval(() => setNowMs(Date.now()), 1_000);
54 return () => clearInterval(id);
55 }, [active]);
56 return nowMs;
57}
58
59function StageIcon({ status }: { status: WorktreeSetupStage["status"] }) {
60 const className = "size-4 shrink-0 stroke-2";
61 switch (status) {
62 case "done":
63 return <CheckIcon aria-hidden className={className} />;
64 case "running":
65 return <Spinner size="md" className="shrink-0" />;
66 case "failed":
67 return <XIcon aria-hidden className={className} />;
68 case "warning":
69 return <CircleAlertIcon aria-hidden className={className} />;
70 case "skipped":
71 return <MinusIcon aria-hidden className={className} />;
72 case "pending":
73 return <CircleIcon aria-hidden className={className} />;
74 }
75}
76
77function stageRowClassName(status: WorktreeSetupStage["status"]): string {
78 switch (status) {
79 case "failed":
80 return "text-destructive-foreground";
81 case "warning":
82 return "text-warning-foreground";
83 case "pending":
84 return "text-secondary-label opacity-40";
85 case "running":
86 case "skipped":
87 case "done":
88 return "text-secondary-label";
89 }
90}
91
92/** Same shimmer treatment as the live tool rows in the timeline. */
93function ShimmerOverlay({ children }: { children: ReactNode }) {
94 return (
95 <span
96 aria-hidden
97 className="live-activity-focus pointer-events-none absolute inset-y-0 select-none"
98 >
99 <span className="live-activity-focus-counter block">
100 <span className="live-activity-focus-aligned block text-foreground">{children}</span>
101 </span>
102 </span>
103 );
104}
105
106function headerLabel(snapshot: WorktreeSetupSnapshot): string {
107 switch (snapshot.phase) {
108 case "running":
109 return "Setting up worktree…";
110 case "done":
111 return snapshot.stages.some((stage) => stage.status === "failed")
112 ? "Worktree ready, setup script failed"
113 : "Worktree ready";
114 case "failed":
115 return "Worktree setup failed";
116 case "cancelled":
117 return "Worktree setup cancelled";
118 }
119}
120
121/**
122 * Occupies the same slot, with the same metrics, as the "Working for" header
123 * so the handoff to the agent's turn only swaps the text.
124 */
125function SetupHeaderRow({
126 snapshot,
127 totalElapsed,
128}: {
129 snapshot: WorktreeSetupSnapshot;
130 totalElapsed: number | null;
131}) {
132 const running = snapshot.phase === "running";
133 const failed = snapshot.phase === "failed";
134 const finishedWithFailedStage =
135 snapshot.phase === "done" && snapshot.stages.some((stage) => stage.status === "failed");
136 const text = headerLabel(snapshot);
137 const tone = failed
138 ? "text-destructive-foreground"
139 : finishedWithFailedStage
140 ? "text-warning-foreground"
141 : "text-muted-foreground";
142 return (
143 <div className="border-b border-border/60 pb-2 pt-1">
144 <div
145 className={cn(
146 "flex h-6 min-w-0 items-baseline gap-2 px-1 text-sm leading-relaxed tabular-nums",
147 tone,
148 )}
149 >
150 <span
151 ref={running ? observeVisibleAnimation : undefined}
152 className="relative min-w-0 shrink overflow-hidden whitespace-nowrap"
153 >
154 <span className="block truncate">{text}</span>
155 {running ? <ShimmerOverlay>{text}</ShimmerOverlay> : null}
156 </span>
157 {totalElapsed !== null ? (
158 <span className="ml-auto shrink-0 text-xs text-muted-foreground">
159 {formatDuration(totalElapsed)}
160 </span>
161 ) : null}
162 </div>
163 </div>
164 );
165}
166
167/** One stage, rendered like a live work entry row. */
168function StageRow({
169 stage,
170 nowMs,
171 scriptName,
172}: {
173 stage: WorktreeSetupStage;
174 nowMs: number;
175 scriptName: string | null;
176}) {
177 const elapsed = stageElapsedMs(stage, nowMs);
178 const label =
179 stage.id === "setup-script" && scriptName ? scriptName : worktreeSetupStageLabel(stage.id);
180 const running = stage.status === "running";
181 const trailing =
182 stage.status === "pending"
183 ? null
184 : stage.status === "skipped"
185 ? (stage.detail ?? "skipped")
186 : stage.id === "checkout" && running && stage.percent !== null
187 ? `${stage.percent}%`
188 : stage.detail;
189 return (
190 <WorkLogRow
191 data-worktree-setup-stage={stage.id}
192 data-worktree-setup-status={stage.status}
193 icon={
194 <span className={cn("text-icon-muted", stage.status === "pending" && "opacity-40")}>
195 <StageIcon status={stage.status} />
196 </span>
197 }
198 label={
199 <span
200 ref={running ? observeVisibleAnimation : undefined}
201 className={cn("relative block truncate", stageRowClassName(stage.status))}
202 >
203 {label}
204 {running ? <ShimmerOverlay>{label}</ShimmerOverlay> : null}
205 </span>
206 }
207 trailing={
208 <>
209 {trailing ? (
210 <span className="min-w-0 truncate text-xs text-muted-foreground tabular-nums">
211 {trailing}
212 </span>
213 ) : null}
214 {elapsed !== null && stage.status !== "skipped" && stage.status !== "pending" ? (
215 <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
216 {formatDuration(elapsed)}
217 </span>
218 ) : null}
219 </>
220 }
221 />
222 );
223}
224
225/** The server keeps this many trailing lines; the box is sized for exactly that. */
226const OUTPUT_TAIL_LINES = 4;
227const OUTPUT_TAIL_SLOTS = Array.from({ length: OUTPUT_TAIL_LINES }, (_, slot) => slot);
228
229/**
230 * Fixed-height window onto the script's last lines. Rows never wrap and the
231 * box never grows or shrinks, so streaming output cannot push the timeline
232 * around while the script runs.
233 */
234function OutputTail({ lines, failed }: { lines: ReadonlyArray<string>; failed: boolean }) {
235 const rows = OUTPUT_TAIL_SLOTS.map((slot) => ({
236 slot,
237 line: lines[lines.length - OUTPUT_TAIL_LINES + slot] ?? "",
238 }));
239 return (
240 <pre
241 className={cn(
242 "mb-1 ml-8 overflow-hidden rounded-md border px-2.5 py-1.5 font-mono text-2xs leading-relaxed select-text",
243 failed
244 ? "border-destructive/20 bg-error-surface text-destructive-foreground"
245 : "border-border bg-code text-muted-foreground",
246 )}
247 >
248 {rows.map(({ slot, line }) => (
249 <div key={slot} className="truncate whitespace-pre">
250 {line.length === 0 ? "\u00a0" : line}
251 </div>
252 ))}
253 </pre>
254 );
255}
256
257function SetupDetails({ snapshot }: { snapshot: WorktreeSetupSnapshot }) {
258 return (
259 <dl className="mt-1 mb-1.5 ml-8 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
260 {snapshot.branch ? (
261 <>
262 <dt className="text-foreground/80">Branch</dt>
263 <dd className="min-w-0 font-mono">
264 <MiddleTruncate value={snapshot.branch} className="flex" />
265 </dd>
266 </>
267 ) : null}
268 {snapshot.baseRef ? (
269 <>
270 <dt className="text-foreground/80">Base</dt>
271 <dd className="min-w-0 font-mono">
272 <MiddleTruncate value={snapshot.baseRef} className="flex" />
273 </dd>
274 </>
275 ) : null}
276 {snapshot.worktreePath ? (
277 <>
278 <dt className="text-foreground/80">Path</dt>
279 <dd className="min-w-0 font-mono">
280 <MiddleTruncate value={snapshot.worktreePath} className="flex" />
281 </dd>
282 </>
283 ) : null}
284 {snapshot.setupScript ? (
285 <>
286 <dt className="text-foreground/80">Setup</dt>
287 <dd className="truncate font-mono">{snapshot.setupScript.command}</dd>
288 </>
289 ) : null}
290 </dl>
291 );
292}
293
294/**
295 * One-line summary of a settled setup under a live turn. A clean finish is
296 * removed from the timeline altogether, so this only renders the outcomes
297 * worth keeping: a failed script, a failed setup, or a cancelled one.
298 */
299function CollapsedSummaryRow({
300 snapshot,
301 totalElapsed,
302}: {
303 snapshot: WorktreeSetupSnapshot;
304 totalElapsed: number | null;
305}) {
306 const status: WorktreeSetupStage["status"] =
307 snapshot.phase === "failed" || snapshot.phase === "cancelled"
308 ? "failed"
309 : snapshot.stages.some((stage) => stage.id === "setup-script" && stage.status === "failed")
310 ? "failed"
311 : "done";
312 const label = headerLabel(snapshot);
313 return (
314 <WorkLogRow
315 data-worktree-setup-stage="summary"
316 data-worktree-setup-status={status}
317 icon={
318 <span className="text-icon-muted">
319 <StageIcon status={status} />
320 </span>
321 }
322 label={<span className={stageRowClassName(status)}>{label}</span>}
323 trailing={
324 totalElapsed !== null ? (
325 <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
326 {formatDuration(totalElapsed)}
327 </span>
328 ) : null
329 }
330 />
331 );
332}
333
334export function WorktreeSetupCard({
335 snapshot,
336 onCancel,
337 onWorkLocally,
338 onOpenTerminal,
339 embedded = false,
340}: WorktreeSetupCardProps & {
341 /**
342 * The agent's turn is live and owns the "Working for" header. The stage
343 * list stays exactly where it was so the handoff never moves anything; a
344 * failed script that outlives the handoff collapses to a single row.
345 */
346 embedded?: boolean;
347}) {
348 const running = snapshot.phase === "running";
349 const nowMs = useNowWhile(running);
350 const [detailsOpen, setDetailsOpen] = useState(false);
351 const totalElapsed = (() => {
352 const start = Date.parse(snapshot.startedAt);
353 const end = snapshot.endedAt ? Date.parse(snapshot.endedAt) : nowMs;
354 return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : null;
355 })();
356 const setupStage = snapshot.stages.find((stage) => stage.id === "setup-script");
357 const showTerminal = onOpenTerminal && setupStage && setupStage.status !== "pending";
358 const collapsed = embedded && !running;
359 // While running, and after a clean finish until the agent's turn is live,
360 // the timeline's working row above the card carries the label (and keeps
361 // that slot when the agent takes over). The card only brings its own
362 // header for a failed or cancelled setup that has no working row above it.
363 const showHeader = !embedded && !running && snapshot.phase !== "done";
364 // The tail box is part of the script row's footprint while the script runs
365 // (and after it failed, so the last lines explain the failure). It mounts
366 // with the first output line at its full fixed height, so the card grows
367 // once instead of with each line. A script that ends before printing
368 // anything never flashes an empty box.
369 const showTail =
370 setupStage !== undefined &&
371 (setupStage.status === "failed" ||
372 (setupStage.status === "running" && setupStage.tail.length > 0));
373
374 return (
375 <section aria-label="Worktree setup" data-worktree-setup-phase={snapshot.phase}>
376 {showHeader ? <SetupHeaderRow snapshot={snapshot} totalElapsed={totalElapsed} /> : null}
377 {collapsed ? (
378 <CollapsedSummaryRow snapshot={snapshot} totalElapsed={totalElapsed} />
379 ) : (
380 <div className={showHeader ? "pt-1.5" : undefined}>
381 {snapshot.stages.map((stage) => (
382 <div key={stage.id}>
383 <StageRow
384 stage={stage}
385 nowMs={nowMs}
386 scriptName={snapshot.setupScript?.name ?? null}
387 />
388 {stage.id === "setup-script" && showTail ? (
389 <OutputTail lines={stage.tail} failed={stage.status === "failed"} />
390 ) : null}
391 </div>
392 ))}
393 </div>
394 )}
395
396 {snapshot.phase === "failed" && snapshot.error ? (
397 <p className="mt-1 ml-8 text-xs text-muted-foreground">{snapshot.error}</p>
398 ) : null}
399
400 {detailsOpen ? <SetupDetails snapshot={snapshot} /> : null}
401
402 {/* Indented so the first label lines up with the stage labels: the icon
403 column, minus the xs button's own horizontal padding. */}
404 <div className="mt-0.5 ml-[calc(--spacing(6)+2px-(--spacing(2)-1px))] flex flex-wrap items-center gap-0.5">
405 <Button
406 type="button"
407 size="xs"
408 variant="ghost-muted"
409 aria-expanded={detailsOpen}
410 onClick={() => setDetailsOpen((open) => !open)}
411 >
412 <MorphIcon aria-hidden icon={detailsOpen ? ChevronDown : ChevronRight} />
413 Details
414 </Button>
415 {showTerminal ? (
416 <Button type="button" size="xs" variant="ghost-muted" onClick={onOpenTerminal}>
417 <TerminalIcon aria-hidden />
418 Open terminal
419 </Button>
420 ) : null}
421 {onWorkLocally ? (
422 <Button type="button" size="xs" variant="ghost-muted" onClick={onWorkLocally}>
423 <LaptopIcon aria-hidden />
424 Work locally
425 </Button>
426 ) : null}
427 {onCancel && running ? (
428 <Button type="button" size="xs" variant="ghost-muted" onClick={onCancel}>
429 <XIcon aria-hidden />
430 Cancel
431 </Button>
432 ) : null}
433 </div>
434 </section>
435 );
436}