g1t/apps/web/app/routes/repo/pull.tsx

1,323 lines57,806 bytesCodeBlame
1import { env } from "cloudflare:workers";
2import {
3 Bot,
4 ChevronRight,
5 FileDiff,
6 GitBranch,
7 GitCommitHorizontal,
8 CircleCheck,
9 CircleSlash,
10 GitMerge,
11 Layers,
12 GitPullRequestArrow,
13 Hand,
14 Loader,
15 Radar,
16 Sparkles,
17 MessageSquare,
18 ArrowUpRight,
19 MessagesSquare,
20 Rocket,
21 Network,
22 StickyNote,
23 User,
24 Wrench,
25} from "lucide-react";
26import { useEffect } from "react";
27import { Form, Link, redirect, useNavigation, useRevalidator } from "react-router";
28
29import {
30 type Comparison,
31 type Deployment,
32 type Job,
33 type LiveApp,
34 type SessionEntry,
35 type Viewer,
36 pullComparison,
37} from "@g1t/contracts";
38
39import type { Route } from "./+types/pull";
40import { excerpt, page } from "../../lib/meta";
41import { DiffView } from "../../components/diff-view";
42import { LifecyclePanel } from "../../components/lifecycle";
43import { AgentPanel } from "../../components/agents";
44import { Markdown } from "../../components/markdown";
45import {
46 Avatar,
47 Button,
48 ButtonLink,
49 CopyLine,
50 EmptyState,
51 ErrorText,
52 Textarea,
53 TimeAgo,
54} from "../../components/ui";
55import { CheckboxOption } from "../../components/ui/checkbox";
56import { WorkflowStatuses } from "../../components/actions";
57import { ChecksPanel } from "../../components/checks";
58import {
59 CommentForm,
60 CommentList,
61 IssueIcon,
62 PeoplePicker,
63 PersonLink,
64 PullState,
65 TimelineItem,
66 verdicts,
67} from "../../components/work";
68import { CatchUpProgress, ChecksSection, ConflictsSection, MergeabilityRow, runIdOf } from "../../components/merge-box";
69import { CATCH_UP_TIMEOUT_MS } from "../../lib/catch-up";
70import { notFound } from "../../lib/not-found.server";
71import { actions, deployments, identity, projects, repos, work } from "../../lib/services.server";
72import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
73
74const REFRESH_MS = 4000;
75const EMPTY_COMPARISON: Comparison = { base: null, head: "", files: [], truncated: false };
76const TABS = ["conversation", "session", "changes"] as const;
77type Tab = (typeof TABS)[number];
78
79export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
80 const pull = loaderData?.pull;
81 const title = pull ? `${pull.title} · Pull request #${pull.number} · ` : "";
82 const state = { draft: "Draft", open: "Open", merged: "Merged", closed: "Closed" }[pull?.status ?? "open"];
83 const body = excerpt(pull?.body);
84 return page(args, {
85 title: `${title}${params.owner}/${params.repo} · g1t`,
86 description: pull
87 ? `${state} pull request #${pull.number} on ${params.owner}/${params.repo} by ${pull.author.username}.${body ? ` ${body}` : ""}`
88 : null,
89 // The card shows the title and the state.
90 version: pull ? [pull.title, pull.status] : undefined,
91 type: "article",
92 });
93}
94
95export async function loader({ params, context, request }: Route.LoaderArgs) {
96 const viewer = getViewer(context);
97 const path = { namespace: params.owner, name: params.repo };
98 const number = Number(params.number);
99 const asked = new URL(request.url).searchParams.get("tab");
100 const tab: Tab = TABS.find((name) => name === asked) ?? "conversation";
101
102 const member = (viewer?.workspaces ?? []).some(
103 (membership) => membership.slug === params.owner,
104 );
105 // At once: none of these depends on another.
106 const ref = { workspace: params.owner, slug: params.repo };
107 const [found, repo, settings, agentsEnabled, members, deps, deployed] = await Promise.all([
108 work.getPull(path, number, viewer),
109 repos.get(path, viewer),
110 work.getSettings(path, viewer),
111 env.RUNNER.enabled(viewer, path),
112 // A member picks reviewers and assignees from the workspace's people.
113 member ? identity.listMembers(params.owner, viewer) : null,
114 // The projects that use this one: what a change here can affect.
115 projects.dependencies(params.owner, params.repo, viewer),
116 member ? deployments.list(ref, viewer) : null,
117 ]);
118 if (!found.ok) {
119 // Issues and pull requests share numbers; this one may be an issue.
120 const issue = await work.getIssue(path, number, viewer);
121 if (issue.ok) throw redirect(`/${params.owner}/${params.repo}/issues/${number}`);
122 throw notFound("pull");
123 }
124 const { pull } = found.value;
125 const range = pullComparison(pull);
126 // The jobs of each workflow run on its head, to list checks job by job.
127 // Read as the viewer: a run they cannot see is listed by its status alone.
128 const runIds = [...new Set((found.value.statuses ?? []).map(runIdOf).filter((id) => id != null))].slice(0, 10);
129 const [session, comparison, runs] = await Promise.all([
130 tab === "session" ? work.readSession(path, number, viewer) : null,
131 tab === "changes"
132 ? repos.compare(range.repoId, viewer, range.base, range.head)
133 : null,
134 tab === "conversation" && pull.status === "open"
135 ? Promise.all(runIds.map((id) => actions.run(path, viewer, id).catch(() => null)))
136 : [],
137 ]);
138 const workflowJobs: Record<string, Job[]> = {};
139 for (const run of runs) if (run?.ok) workflowJobs[run.value.run.id] = run.value.jobs;
140 return {
141 ...found.value,
142 workflowJobs,
143 tab,
144 session: session?.ok ? session.value : [],
145 // An empty comparison if it could not be made.
146 comparison: comparison && (comparison.ok ? comparison.value : EMPTY_COMPARISON),
147 viewer,
148 // Members of the repository's workspace can merge.
149 canMerge: member,
150 canManage: member || viewer?.id === pull.author.id,
151 // A catch-up is pushed as the viewer: a fork takes pushes only from
152 // whoever opened it, a branch from any member.
153 canUpdate: pull.fork ? viewer?.id === pull.author.id : member,
154 agentsEnabled,
155 members: members?.ok ? members.value.map((person) => person.username) : [],
156 requireUpToDate: settings.ok && settings.value.requireUpToDate,
157 mergeQueue: settings.ok && settings.value.mergeQueue,
158 requiredApprovals: settings.ok ? settings.value.requiredApprovals : 0,
159 canIgnoreChecks: !settings.ok || settings.value.allowIgnoringChecks,
160 defaultBranch: repo.ok ? repo.value.defaultBranch : "main",
161 affects: deps.ok ? deps.value.usedBy : [],
162 ...(await deploymentOf(deployed?.ok ? deployed.value : null, number, params.owner, deps.ok ? deps.value.usedBy : [], viewer)),
163 };
164}
165
166/**
167 * Where this pull request is live: its preview (or its latest build, while
168 * one is going or after one failed), and the previews of the projects
169 * that use this one, built against it.
170 */
171async function deploymentOf(
172 list: { deployments: Deployment[]; live: LiveApp[] } | null,
173 number: number,
174 owner: string,
175 affects: { slug: string; name: string }[],
176 viewer: Viewer,
177) {
178 if (!list) return { preview: null, build: null, stacked: [] };
179 const preview = list.live.find((app) => app.kind === "preview" && app.number === number) ?? null;
180 const build = list.deployments.find((d) => d.kind === "preview" && d.number === number) ?? null;
181 const branch = preview?.branch ?? build?.branch ?? null;
182 const stacked = branch
183 ? (
184 await Promise.all(
185 affects.slice(0, 5).map(async (project) => {
186 const theirs = await deployments.list({ workspace: owner, slug: project.slug }, viewer);
187 const app = theirs.ok ? theirs.value.live.find((a) => a.kind === "preview" && a.branch === branch) : undefined;
188 return app ? { name: project.name, slug: project.slug, url: app.url } : null;
189 }),
190 )
191 ).filter((entry) => entry != null)
192 : [];
193 return { preview, build, stacked };
194}
195
196export async function action({ request, params, context }: Route.ActionArgs) {
197 assertSameOrigin(request);
198 const user = requireUser(context, request);
199 const form = await request.formData();
200 const path = { namespace: params.owner, name: params.repo };
201 const number = Number(params.number);
202 const action = form.get("action");
203 const verdict = form.get("verdict");
204 const line = Number(form.get("line"));
205 /** Names ticked in a people picker, plus those typed beside it. */
206 const picked = (field: string) => [
207 ...form.getAll(field).map(String),
208 ...String(form.get("others") ?? "").split(/[\s,]+/),
209 ];
210 // The projects that use this one, built against this pull request's preview.
211 if (action === "stack") {
212 const built = await deployments.stack(user, { workspace: params.owner, slug: params.repo }, String(form.get("branch") ?? ""));
213 return built.ok
214 ? { action, notice: `Building ${built.value.join(", ")} against this preview. They appear on their Deployments pages.` }
215 : { action, error: built.error.message };
216 }
217 // Asking a g1t agent for its review records the request, then starts it.
218 if (action === "agent-review") {
219 const asked = await work.updatePull(user, path, number, {
220 reviewers: [...form.getAll("reviewer").map(String), "g1t-agent"],
221 });
222 if (!asked.ok) return { error: asked.error.message, action };
223 }
224 // Catching up: merged and pushed in seconds when the two sides changed
225 // different files; otherwise handed to a sandbox, which takes a minute.
226 if (action === "update") {
227 const caught = await work.catchUpPull(user, path, number);
228 if (!caught.ok) return { action, error: caught.error.message };
229 const update = caught.value;
230 if (update.outcome !== "needs_agent") {
231 return {
232 action,
233 updated: { commit: update.commit, already: update.outcome === "up_to_date", at: Date.now() },
234 };
235 }
236 const started = await env.RUNNER.update(user, path, number);
237 if (!started.ok) return { action, error: started.error.message };
238 return { action, agent: { update, startedAt: Date.now() } };
239 }
240 // A workflow run's failed jobs, run again. Who may is the actions service's call.
241 if (action === "rerun-workflow") {
242 const rerun = await actions.rerun(user, path, String(form.get("run") ?? ""), true);
243 return rerun.ok ? null : { error: rerun.error.message, action };
244 }
245 const result =
246 action === "merge"
247 ? await work.mergePull(user, path, number, {
248 keepIssueOpen: form.get("keepIssueOpen") === "on",
249 ignoreChecks: form.get("ignoreChecks") === "on",
250 })
251 : action === "unqueue"
252 ? await work.removeFromQueue(user, path, number)
253 : action === "message"
254 ? await work.messageAgent(user, path, number, String(form.get("body") ?? ""))
255 : action === "close"
256 ? await work.closePull(user, path, number)
257 : action === "recheck"
258 ? await env.RUNNER.recheck(user, path, number)
259 : action === "agent-review"
260 ? await env.RUNNER.review(user, path, number)
261 : action === "reviewers"
262 ? await work.updatePull(user, path, number, { reviewers: picked("reviewer") })
263 : action === "assign"
264 ? await work.updatePull(user, path, number, { assignees: picked("assignee") })
265 : action === "comment"
266 ? await work.addComment(user, path, number, {
267 body: String(form.get("body") ?? ""),
268 path: String(form.get("path") ?? "") || undefined,
269 line: line > 0 ? line : undefined,
270 verdict:
271 verdict === "approve" || verdict === "request_changes" ? verdict : undefined,
272 })
273 : await work.readyPull(user, path, number, String(form.get("summary") ?? ""));
274 return result.ok ? null : { error: result.error.message, action };
275}
276
277/** How many, beside a tab's name, when there are any. */
278function TabCount({ n }: { n: number }) {
279 if (n <= 0) return null;
280 return (
281 <span className="rounded-full bg-raised px-1.5 py-px text-xs tabular-nums text-muted ring-1 ring-line">
282 {n.toLocaleString("en-US")}
283 </span>
284 );
285}
286
287function TabLink({
288 to,
289 active,
290 children,
291}: {
292 to: string;
293 active: boolean;
294 children: React.ReactNode;
295}) {
296 return (
297 <Link
298 to={to}
299 preventScrollReset
300 className={
301 "-mb-px flex items-center gap-2 border-b-2 px-1 pb-2.5 text-sm transition-colors " +
302 (active
303 ? "border-accent font-medium text-fg"
304 : "border-transparent text-muted hover:text-fg")
305 }
306 >
307 {children}
308 </Link>
309 );
310}
311
312/** One step of the session, on the timeline's rail. */
313function Entry({ entry, agent }: { entry: SessionEntry; agent: string }) {
314 const isTool = entry.kind === "tool_call" || entry.kind === "tool_result";
315 const Icon =
316 entry.kind === "prompt"
317 ? User
318 : entry.kind === "note"
319 ? StickyNote
320 : isTool
321 ? Wrench
322 : Bot;
323 return (
324 <li className="relative pl-10">
325 <span
326 className={`absolute top-0.5 left-0 flex size-7 items-center justify-center rounded-full border bg-bg ${
327 entry.kind === "prompt"
328 ? "border-accent/50 text-accent"
329 : "border-line text-faint"
330 }`}
331 >
332 <Icon size={14} />
333 </span>
334 {isTool ? (
335 <details className="group rounded-lg border border-line bg-surface">
336 <summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-1.5 text-sm">
337 <ChevronRight
338 size={14}
339 className="text-faint transition-transform group-open:rotate-90"
340 />
341 <span className="font-mono text-xs text-accent">
342 {entry.tool ?? "tool"}
343 </span>
344 <span className="truncate font-mono text-xs text-muted">
345 {entry.kind === "tool_result" ? "→ " : ""}
346 {entry.text.split("\n")[0]}
347 </span>
348 </summary>
349 <pre className="overflow-x-auto border-t border-line p-3 font-mono text-xs whitespace-pre-wrap text-muted">
350 <code>{entry.text}</code>
351 </pre>
352 </details>
353 ) : (
354 <div>
355 <p className="text-xs font-medium text-faint">
356 {entry.kind === "prompt"
357 ? "Prompt"
358 : entry.kind === "note"
359 ? "Note"
360 : agent}
361 </p>
362 {entry.kind === "message" ? (
363 <div className="mt-1">
364 <Markdown source={entry.text} />
365 </div>
366 ) : entry.kind === "prompt" ? (
367 <div className="mt-1.5 max-h-[32rem] overflow-y-auto rounded-xl border border-line bg-surface p-4">
368 <Markdown source={entry.text} />
369 </div>
370 ) : (
371 <p className="mt-1 text-[0.9375rem] leading-relaxed wrap-break-word whitespace-pre-wrap">
372 {entry.text}
373 </p>
374 )}
375 </div>
376 )}
377 {entry.commit && (
378 <p className="mt-1.5 flex items-center gap-1 font-mono text-xs text-faint">
379 <GitCommitHorizontal size={12} />
380 {entry.commit.slice(0, 7)}
381 </p>
382 )}
383 </li>
384 );
385}
386
387/** The box at the foot of the conversation saying what stands before a merge. */
388function StatusBox({ children }: { children: React.ReactNode }) {
389 return (
390 <div className="flex gap-3">
391 <span className="hidden w-8 shrink-0 sm:block" />
392 <section className="min-w-0 grow divide-y divide-line overflow-hidden rounded-xl border border-line-strong bg-surface">
393 {children}
394 </section>
395 </div>
396 );
397}
398
399function StatusRow({
400 icon,
401 title,
402 children,
403}: {
404 icon: React.ReactNode;
405 title: string;
406 children?: React.ReactNode;
407}) {
408 return (
409 <div className="flex gap-3 px-4 py-3 text-sm">
410 <span className="mt-0.5 shrink-0">{icon}</span>
411 <div className="min-w-0">
412 <p className="font-medium">{title}</p>
413 {children && <div className="mt-0.5 text-muted">{children}</div>}
414 </div>
415 </div>
416 );
417}
418
419export default function PullPage({ loaderData, actionData, params }: Route.ComponentProps) {
420 const {
421 pull,
422 issue,
423 comments,
424 checks,
425 overlaps,
426 behind,
427 reviewPending,
428 lifecycle,
429 messages,
430 statuses = [],
431 mergeable = "unknown",
432 conflicts = [],
433 earlierChecks = [],
434 workflowJobs,
435 affects,
436 preview,
437 build,
438 stacked,
439 landing,
440 stalled,
441 requireUpToDate,
442 mergeQueue,
443 requiredApprovals,
444 canIgnoreChecks,
445 canUpdate,
446 agentsEnabled,
447 members,
448 tab,
449 session,
450 comparison,
451 viewer,
452 canMerge,
453 canManage,
454 defaultBranch,
455 } = loaderData;
456 const base = `/${params.owner}/${params.repo}`;
457 const here = `${base}/pull/${pull.number}`;
458 const remote = pull.fork
459 ? `https://g1t.sh/${pull.fork.namespace}/${pull.fork.name}.git`
460 : `https://g1t.sh/${params.owner}/${params.repo}.git`;
461 const active = pull.status === "draft" || pull.status === "open";
462
463 // Follow an agent at work, or checks in progress, without a manual reload.
464 const revalidator = useRevalidator();
465 const working = pull.status === "draft";
466 const checking =
467 checks?.status === "queued" || checks?.status === "running" || statuses.some((status) => status.state === "pending");
468 const reviews = verdicts(comments);
469 // What stands between this pull request and a merge, if anything.
470 const unchecked = checks && checks.status !== "passed";
471 // Pull requests for other issues changing the same files will conflict;
472 // ones for the same issue are alternatives, and expected to.
473 const collisions = overlaps.filter((other) => other.issue == null || other.issue !== pull.issue);
474 const review = {
475 changesUrl: here + "?tab=changes",
476 // Nobody reviews their own pull request.
477 canJudge: active && viewer != null && viewer.id !== pull.author.id,
478 };
479 // Everyone whose review was asked for, then anyone who reviewed unasked.
480 const reviewerNames = [
481 ...new Set([...pull.reviewers, ...reviews.map(({ reviewer }) => reviewer)]),
482 ];
483 // A catch-up: the click shows at once; the answer says whether it is
484 // done already or a sandbox is on it.
485 const navigation = useNavigation();
486 const catchUpPending = navigation.state !== "idle" && navigation.formData?.get("action") === "update";
487 const catchUp = actionData?.action === "update" ? actionData : null;
488 const caughtUp = catchUp && "updated" in catchUp ? catchUp.updated : null;
489 const agentCatchUp = catchUp && "agent" in catchUp ? catchUp.agent : null;
490 // Pushed already: followed until the pull request's head is the merge,
491 // so its checks show starting again, for a minute at most.
492 const settling =
493 caughtUp != null && !caughtUp.already && pull.headCommit !== caughtUp.commit && Date.now() - caughtUp.at < 60_000;
494 // Followed until it is pushed, or for so long, never longer.
495 const catchingUp =
496 agentCatchUp != null && behind && Date.now() - agentCatchUp.startedAt < CATCH_UP_TIMEOUT_MS + REFRESH_MS;
497 // g1t is taking a step of its own accord, so the page will change.
498 const moving =
499 lifecycle != null && lifecycle.stage !== "ready" && lifecycle.stage !== "needs_you";
500 // Whether it merges cleanly is being worked out, so the box will change.
501 const probing = active && mergeable === "checking";
502 const conflicting = active && mergeable === "conflicting";
503 useEffect(() => {
504 if (!working && !checking && !reviewPending && !catchingUp && !settling && !moving && !landing && !probing) return;
505 const timer = setInterval(() => {
506 if (document.visibilityState === "visible") revalidator.revalidate();
507 }, REFRESH_MS);
508 return () => clearInterval(timer);
509 }, [working, checking, reviewPending, catchingUp, settling, moving, landing, probing, revalidator]);
510 // Why the merge button cannot be pressed, if it cannot.
511 const mergeBlocked = conflicting
512 ? "Resolve the conflicts first."
513 : probing
514 ? "Waiting to find out whether it merges cleanly."
515 : behind && requireUpToDate
516 ? `This repository requires it to be up to date with ${defaultBranch} first.`
517 : null;
518
519 return (
520 // The changes get the whole width; people and settings are a tab away.
521 <div>
522 {/* The title and the tabs run the full width; what is happening on
523 the pull request sits at the top of Conversation. */}
524 <header>
525 <h2 className="text-2xl font-semibold tracking-tight text-balance">
526 {pull.title} <span className="font-normal text-faint">#{pull.number}</span>
527 </h2>
528 <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted">
529 <PullState status={pull.status} />
530 <span className="flex items-center gap-2">
531 <Avatar
532 name={(pull.status === "merged" && pull.mergedBy) || pull.author.username}
533 size={18}
534 />
535 <span>
536 <PersonLink
537 name={(pull.status === "merged" && pull.mergedBy) || pull.author.username}
538 className="font-medium text-fg hover:underline"
539 />{" "}
540 {pull.status === "merged" ? "merged" : "wants to merge"}
541 {pull.branch && (
542 <>
543 {" "}
544 <span className="font-mono text-fg">{pull.branch}</span>
545 </>
546 )}{" "}
547 into <span className="font-mono text-fg">{defaultBranch}</span>
548 </span>
549 </span>
550 {/* A pull request from a branch was made by its author, not an agent. */}
551 {!pull.branch && (
552 <span className="flex items-center gap-1.5 font-mono text-xs">
553 <Bot size={14} />
554 {pull.agent}
555 {pull.runtime === "hosted" && <span className="text-faint">on g1t</span>}
556 </span>
557 )}
558 </div>
559
560 {reviews.length > 0 && (
561 <p className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
562 {reviews.map(({ reviewer, verdict }) => (
563 <span
564 key={reviewer}
565 className={`flex items-center gap-1.5 ${
566 verdict === "approve" ? "text-accent" : "text-danger"
567 }`}
568 >
569 {verdict === "approve" ? <CircleCheck size={15} /> : <CircleSlash size={15} />}
570 {verdict === "approve" ? "Approved by" : "Changes requested by"}{" "}
571 <span className="font-medium">{reviewer}</span>
572 </span>
573 ))}
574 </p>
575 )}
576
577 <nav className="mt-6 flex items-end gap-6 border-b border-line">
578 <TabLink to={here} active={tab === "conversation"}>
579 <MessageSquare size={15} />
580 Conversation
581 <TabCount n={comments.length} />
582 </TabLink>
583 <TabLink to={here + "?tab=session"} active={tab === "session"}>
584 <MessagesSquare size={15} />
585 Session
586 </TabLink>
587 <TabLink to={here + "?tab=changes"} active={tab === "changes"}>
588 <FileDiff size={15} />
589 Files changed
590 <TabCount n={pull.files.length} />
591 </TabLink>
592 {pull.files.length > 0 && (
593 <span className="ml-auto hidden pb-2.5 font-mono text-xs sm:inline">
594 <span className="text-accent">+{pull.files.reduce((sum, file) => sum + file.additions, 0).toLocaleString("en-US")}</span>{" "}
595 <span className="text-danger">−{pull.files.reduce((sum, file) => sum + file.deletions, 0).toLocaleString("en-US")}</span>
596 </span>
597 )}
598 </nav>
599 </header>
600 <div className={`mt-6 grid gap-8 ${tab === "changes" ? "" : "lg:grid-cols-[1fr_19rem]"}`}>
601 <div className="min-w-0">
602 {/* What is happening on it now: its stage, its agent, what people told
603 the agent, and what else touches the same files. On the conversation,
604 so the tabs sit right under the title. */}
605 {tab === "conversation" && (
606 <div>
607 {issue && (
608 <Link
609 to={`${base}/issues/${issue.number}`}
610 className="mt-4 flex items-center gap-2.5 rounded-xl border border-line bg-surface px-4 py-2.5 text-sm transition-colors hover:border-line-strong"
611 >
612 <IssueIcon issue={issue} />
613 <span className="text-muted">
614 {pull.status === "merged" && issue.resolvedBy === pull.number
615 ? "Resolved"
616 : "For issue"}
617 </span>
618 <span className="truncate font-medium">{issue.title}</span>
619 <span className="text-faint">#{issue.number}</span>
620 {issue.pullCount > 1 && (
621 <span className="ml-auto shrink-0 text-xs text-faint">
622 1 of {issue.pullCount} pull requests
623 </span>
624 )}
625 </Link>
626 )}
627 {pull.status === "merged" && (
628 <p className="mt-4 flex items-center gap-2.5 rounded-xl border border-merged/40 bg-merged/5 px-4 py-2.5 text-sm">
629 <GitMerge size={16} className="shrink-0 text-merged" />
630 <span>
631 Merged into <span className="font-mono">{defaultBranch}</span> by{" "}
632 <span className="font-medium">{pull.mergedBy}</span>{" "}
633 {/* Not an account: the repository's settings said to merge it. */}
634 {pull.mergedBy === "g1t" && "automatically, once it was ready, "}
635 {pull.mergedAt && <TimeAgo at={pull.mergedAt} />}
636 {pull.headCommit && (
637 <span className="font-mono text-muted"> · {pull.headCommit.slice(0, 7)}</span>
638 )}
639 </span>
640 </p>
641 )}
642 {pull.supersededBy != null && (
643 <p className="mt-4 rounded-xl border border-line bg-surface px-4 py-2.5 text-sm text-muted">
644 Closed because{" "}
645 <Link
646 to={`${base}/pull/${pull.supersededBy}`}
647 className="font-medium text-fg hover:underline"
648 >
649 #{pull.supersededBy}
650 </Link>{" "}
651 was merged for this issue instead.
652 </p>
653 )}
654 {lifecycle && <LifecyclePanel lifecycle={lifecycle} />}
655 {/* The agent on it: who, doing what this minute, for how long, at what cost. */}
656 <AgentPanel owner={params.owner} repo={params.repo} number={pull.number} stage={lifecycle?.stage ?? null} />
657
658 {/* Steering: while its agent works, people can tell it things. */}
659 {canManage &&
660 pull.runtime === "hosted" &&
661 (working || ["working", "revising", "catching_up", "answering"].includes(lifecycle?.stage ?? "")) && (
662 <Form method="post" className="mt-4 rounded-2xl bg-surface p-4 ring-1 ring-merged/30">
663 <p className="flex items-center gap-2 text-sm font-medium">
664 <Sparkles size={15} className="text-merged" />
665 Message the agent
666 </p>
667 <p className="mt-1 text-xs text-muted">
668 A correction, a hint, a change of plan. It reads it at its next step, without
669 starting over.
670 </p>
671 <div className="mt-3 flex gap-2">
672 <input type="hidden" name="action" value="message" />
673 <input
674 name="body"
675 required
676 autoComplete="off"
677 data-1p-ignore
678 placeholder="Keep the old flag working too…"
679 className="h-9 min-w-0 grow rounded-md bg-bg px-3 text-sm ring-1 ring-line outline-none placeholder:text-faint focus:ring-merged/60"
680 />
681 <Button type="submit">Send</Button>
682 </div>
683 </Form>
684 )}
685 {messages.length > 0 && (
686 <ul className="mt-3 space-y-1.5">
687 {messages.map((message) => (
688 <li key={message.id} className="flex items-start gap-2 text-sm">
689 <Avatar name={message.author} size={18} />
690 <span className="min-w-0 grow">
691 <span className="font-medium">
692 {message.fromNumber != null ? `The agent on #${message.fromNumber}` : message.author}
693 </span>{" "}
694 <span className="text-muted">
695 {message.kind === "question"
696 ? "asked:"
697 : message.kind === "handoff"
698 ? "handed over:"
699 : message.kind === "answer"
700 ? "answered:"
701 : "to the agent:"}
702 </span>{" "}
703 {message.body}
704 {message.answer && (
705 <span className="mt-1 block border-l-2 border-merged/40 pl-2 text-muted">
706 {message.declined ? "Declined: " : "Answer: "}
707 {message.answer}
708 </span>
709 )}
710 </span>
711 <span className={`shrink-0 text-xs ${message.deliveredAt ? "text-accent" : "text-faint"}`}>
712 {message.deliveredAt ? "read by the agent" : "waiting for its next step"}
713 </span>
714 </li>
715 ))}
716 </ul>
717 )}
718 {collisions.length > 0 && active && (
719 <div className="mt-4 rounded-xl border border-line bg-surface px-4 py-3 text-sm">
720 <p className="flex items-center gap-2.5 font-medium">
721 <Radar size={16} className="shrink-0 text-info" />
722 Other work is changing the same files
723 </p>
724 <ul className="mt-2 space-y-1.5">
725 {collisions.map((other) => (
726 <li key={other.number} className="flex flex-wrap items-baseline gap-x-2 text-muted">
727 <Link
728 to={`${base}/pull/${other.number}`}
729 className="font-medium text-fg hover:underline"
730 >
731 {other.title} <span className="font-normal text-faint">#{other.number}</span>
732 </Link>
733 <span className="font-mono text-xs">{other.paths.join(", ")}</span>
734 </li>
735 ))}
736 </ul>
737 <p className="mt-2 text-xs text-faint">
738 Whichever merges second will have to catch up, and may conflict.
739 </p>
740 </div>
741 )}
742 </div>
743 )}
744 <div className="mt-5">
745 {comparison ? (
746 <DiffView
747 comparison={comparison}
748 review={{
749 comments: comments.filter((comment) => comment.path),
750 canComment: Boolean(viewer),
751 }}
752 />
753 ) : tab === "session" ? (
754 session.length === 0 ? (
755 <EmptyState title="Nothing recorded yet">
756 The agent's prompts, reasoning and tool calls appear here as it
757 works.
758 </EmptyState>
759 ) : (
760 <ol className="relative space-y-5 before:absolute before:top-2 before:bottom-2 before:left-3.25 before:w-px before:bg-line">
761 {session.map((entry) => (
762 <Entry key={entry.seq} entry={entry} agent={pull.agent} />
763 ))}
764 </ol>
765 )
766 ) : (
767 <div className="space-y-4">
768 <TimelineItem
769 author={pull.author.username}
770 at={pull.createdAt}
771 action={
772 <span>
773 opened this pull request
774 {!pull.branch && (
775 <>
776 {" "}
777 with <span className="font-mono text-xs">{pull.agent}</span>
778 </>
779 )}
780 </span>
781 }
782 >
783 {pull.body ? (
784 <Markdown source={pull.body} repo={{ namespace: params.owner, name: params.repo }} />
785 ) : (
786 <p className="text-sm text-muted">
787 {working
788 ? "No description yet. It is written when the pull request is marked ready for review."
789 : "No description."}
790 </p>
791 )}
792 {canManage && pull.status === "open" && (
793 <details className="mt-3 border-t border-line pt-3 text-sm">
794 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
795 Edit description
796 </summary>
797 <Form method="post" className="mt-3 space-y-2">
798 <Textarea
799 name="summary"
800 rows={6}
801 placeholder="What changed and why"
802 defaultValue={pull.body ?? ""}
803 />
804 <Button type="submit">Save</Button>
805 </Form>
806 </details>
807 )}
808 </TimelineItem>
809
810 <CommentList comments={comments} review={review} base={base} />
811
812 {(preview || build) && (
813 <DeploymentCard preview={preview} build={build} stacked={stacked} base={base} />
814 )}
815
816 {pull.status === "draft" && (
817 <StatusBox>
818 <StatusRow icon={<Loader size={16} className="text-faint" />} title="This is a draft">
819 It is still being worked on. It can be reviewed and merged once it is
820 marked ready.
821 </StatusRow>
822 {canManage && (
823 <Form method="post" className="space-y-2 px-4 py-3">
824 <Textarea name="summary" rows={3} placeholder="What changed and why" />
825 <Button type="submit">Mark ready for review</Button>
826 </Form>
827 )}
828 </StatusBox>
829 )}
830
831 {pull.status === "open" && (
832 <StatusBox>
833 <ChecksSection
834 run={checks}
835 commands={issue?.checks ?? []}
836 statuses={statuses}
837 jobs={workflowJobs}
838 pull={pull}
839 base={base}
840 earlier={earlierChecks}
841 canRerun={canManage}
842 canRerunWorkflows={canMerge}
843 error={
844 actionData?.action === "recheck" || actionData?.action === "rerun-workflow"
845 ? actionData.error
846 : null
847 }
848 />
849 <StatusRow
850 icon={
851 reviews.some(({ verdict }) => verdict === "request_changes") ? (
852 <CircleSlash size={16} className="text-danger" />
853 ) : reviews.length > 0 ? (
854 <CircleCheck size={16} className="text-accent" />
855 ) : (
856 <MessageSquare size={16} className="text-faint" />
857 )
858 }
859 title={
860 reviews.length === 0
861 ? "No reviews yet"
862 : reviews
863 .map(
864 ({ reviewer, verdict }) =>
865 `${verdict === "approve" ? "Approved by" : "Changes requested by"} ${reviewer}`,
866 )
867 .join(" · ")
868 }
869 >
870 {reviewPending && "A g1t agent is reviewing it now. "}
871 {requiredApprovals > 0 &&
872 `This repository requires ${requiredApprovals} approving ${
873 requiredApprovals === 1 ? "review" : "reviews"
874 } before merging.`}
875 </StatusRow>
876 {landing ? (
877 <StatusRow
878 icon={<Loader size={16} className="animate-spin text-accent" />}
879 title="Merging"
880 >
881 {defaultBranch} has moved, so g1t is bringing this up to date first. It
882 lands as soon as that is done. Watch it in the Session tab.
883 </StatusRow>
884 ) : agentCatchUp && behind ? (
885 <CatchUpProgress
886 owner={params.owner}
887 repo={params.repo}
888 number={pull.number}
889 defaultBranch={defaultBranch}
890 behind={behind}
891 update={agentCatchUp.update}
892 startedAt={agentCatchUp.startedAt}
893 retrying={catchUpPending}
894 />
895 ) : conflicting ? (
896 <ConflictsSection
897 conflicts={conflicts}
898 pull={pull}
899 owner={params.owner}
900 repo={params.repo}
901 defaultBranch={defaultBranch}
902 changesUrl={here + "?tab=changes"}
903 canResolve={canUpdate && agentsEnabled}
904 resolving={catchUpPending}
905 error={catchUp && "error" in catchUp ? catchUp.error : null}
906 />
907 ) : probing ? (
908 <MergeabilityRow mergeable={mergeable} defaultBranch={defaultBranch} />
909 ) : behind ? (
910 <StatusRow
911 icon={<GitPullRequestArrow size={16} className="text-info" />}
912 title={`${defaultBranch} has moved since this was made`}
913 >
914 {mergeable === "clean" && "It has no conflicts with it. "}
915 {requireUpToDate
916 ? "This repository requires pull requests to be up to date, so it has to catch up before it can merge."
917 : "That does not stop it merging: it is brought up to date as part of the merge."}
918 {canUpdate && (
919 <Form method="post" className="mt-2">
920 <input type="hidden" name="action" value="update" />
921 <Button variant="quiet" type="submit" disabled={catchUpPending}>
922 {catchUpPending && <Loader size={14} className="animate-spin" />}
923 {catchUpPending ? `Merging ${defaultBranch} in…` : `Catch up with ${defaultBranch} now`}
924 </Button>
925 </Form>
926 )}
927 {catchUp && "error" in catchUp && <ErrorText>{catchUp.error}</ErrorText>}
928 </StatusRow>
929 ) : (
930 pull.headCommit && (
931 <StatusRow
932 icon={<CircleCheck size={16} className="text-accent" />}
933 title={
934 (caughtUp && !caughtUp.already) || agentCatchUp
935 ? `Brought up to date with ${defaultBranch}`
936 : `Up to date with ${defaultBranch}`
937 }
938 >
939 {caughtUp && !caughtUp.already && (
940 <>
941 Merged <span className="font-mono">{defaultBranch}</span> in as{" "}
942 <span className="font-mono text-fg">{caughtUp.commit.slice(0, 7)}</span>. Its checks run
943 again on the new commit.
944 </>
945 )}
946 {agentCatchUp && "g1t-agent merged it in and pushed the result. Its checks run again on the new commit."}
947 </StatusRow>
948 )
949 )}
950 {stalled && !lifecycle && (
951 <StatusRow icon={<Hand size={16} className="text-warn" />} title="Needs you">
952 {stalled}
953 </StatusRow>
954 )}
955 {canMerge && lifecycle?.stage === "queued" && (
956 <Form method="post" className="flex flex-wrap items-center gap-3 px-4 py-3">
957 <ButtonLink to={`${base}/queue`}>
958 <Layers size={15} />
959 See the queue
960 </ButtonLink>
961 <Button variant="quiet" type="submit" name="action" value="unqueue">
962 Remove from the queue
963 </Button>
964 </Form>
965 )}
966 {canMerge && !landing && lifecycle?.stage !== "queued" && (
967 <Form method="post" className="space-y-3 px-4 py-3">
968 {issue?.state === "open" && (
969 <CheckboxOption
970 name="keepIssueOpen"
971 label={`Keep #${issue.number} open. This is only part of the work.`}
972 labelClassName="text-xs text-muted"
973 />
974 )}
975 {unchecked && canIgnoreChecks && (
976 <CheckboxOption
977 name="ignoreChecks"
978 label={`Merge although the checks ${checking ? "have not finished" : "did not pass"}.`}
979 labelClassName="text-xs text-muted"
980 />
981 )}
982 <div className="flex flex-wrap items-center gap-3">
983 <Button
984 variant="accent"
985 type="submit"
986 name="action"
987 value="merge"
988 disabled={mergeBlocked != null}
989 title={mergeBlocked ?? undefined}
990 >
991 {mergeQueue ? <Layers size={15} /> : <GitMerge size={15} />}
992 {mergeQueue ? "Add to the merge queue" : `Merge into ${defaultBranch}`}
993 </Button>
994 <span className={`text-xs ${mergeBlocked ? "text-danger" : "text-muted"}`}>
995 {mergeBlocked ?? (mergeQueue
996 ? `Tested together with everything ahead of it, then lands on ${defaultBranch}.`
997 : issue?.state === "open"
998 ? `Closes issue #${issue.number}, and any other pull requests still open for it.`
999 : `Lands its commits on ${defaultBranch}.`)}
1000 </span>
1001 </div>
1002 {actionData?.action === "merge" && (
1003 <ErrorText>{actionData.error}</ErrorText>
1004 )}
1005 </Form>
1006 )}
1007 </StatusBox>
1008 )}
1009
1010 <CommentForm
1011 author={viewer?.username ?? null}
1012 resetKey={comments.length}
1013 review={review}
1014 />
1015 {canManage && active && (
1016 <Form method="post" className="flex justify-end">
1017 <Button variant="quiet" type="submit" name="action" value="close">
1018 Close pull request
1019 </Button>
1020 </Form>
1021 )}
1022 {actionData &&
1023 !["merge", "comment", "recheck", "rerun-workflow", "update", "agent-review", "reviewers", "assign"].includes(
1024 String(actionData.action),
1025 ) && <ErrorText>{actionData.error}</ErrorText>}
1026 </div>
1027 )}
1028 {actionData?.action === "comment" && (
1029 <div className="mt-2">
1030 <ErrorText>{actionData.error}</ErrorText>
1031 </div>
1032 )}
1033 </div>
1034 </div>
1035
1036 <aside className={tab === "changes" ? "hidden" : "space-y-6"}>
1037 {/* An open pull request shows its checks in full in the merge box. */}
1038 {pull.status !== "open" && (
1039 <>
1040 <ChecksPanel run={checks} commands={issue?.checks ?? []} canRerun={false} />
1041 <WorkflowStatuses statuses={statuses} />
1042 </>
1043 )}
1044 {affects.length > 0 && (
1045 <section>
1046 <h3 className="flex items-center gap-1.5 text-sm font-medium">
1047 <Network size={14} className="text-faint" />
1048 Affects
1049 </h3>
1050 <p className="mt-1 text-xs text-muted">Projects that use this one, and so may feel this change:</p>
1051 <ul className="mt-2 space-y-1 text-sm">
1052 {affects.map((project) => (
1053 <li key={project.slug} className="flex items-center justify-between gap-2">
1054 <Link to={`/${params.owner}/${project.slug}`} className="hover:underline">
1055 {project.name}
1056 </Link>
1057 {project.as && <code className="font-mono text-xs text-faint">{project.as}</code>}
1058 </li>
1059 ))}
1060 </ul>
1061 {preview?.branch && canMerge && (
1062 <Form method="post" className="mt-3">
1063 <input type="hidden" name="action" value="stack" />
1064 <input type="hidden" name="branch" value={preview.branch} />
1065 <button
1066 type="submit"
1067 className="w-full rounded-md border border-line px-3 py-1.5 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg"
1068 >
1069 Preview them against this change
1070 </button>
1071 </Form>
1072 )}
1073 {actionData?.action === "stack" &&
1074 ("notice" in actionData ? (
1075 <p className="mt-2 text-xs text-accent">{String(actionData.notice)}</p>
1076 ) : (
1077 <ErrorText>{actionData.error}</ErrorText>
1078 ))}
1079 </section>
1080 )}
1081
1082 <section>
1083 <h3 className="text-sm font-medium">Reviewers</h3>
1084 <ul className="mt-2 space-y-1.5 text-sm">
1085 {reviewerNames.map((name) => {
1086 const verdict = reviews.find(({ reviewer }) => reviewer === name)?.verdict;
1087 const pending = name === "g1t-agent" && reviewPending;
1088 return (
1089 <li key={name} className="flex items-center gap-2 px-1">
1090 {name === "g1t-agent" ? (
1091 <Sparkles size={16} className="shrink-0 text-accent" />
1092 ) : (
1093 <Avatar name={name} size={20} />
1094 )}
1095 <span className="grow truncate font-mono text-xs">{name}</span>
1096 {pending ? (
1097 <span className="flex items-center gap-1.5 text-xs text-muted">
1098 <span className="size-1.5 animate-pulse rounded-full bg-accent" />
1099 Reviewing
1100 </span>
1101 ) : verdict === "approve" ? (
1102 <span className="flex items-center gap-1 text-xs text-accent">
1103 <CircleCheck size={13} /> Approved
1104 </span>
1105 ) : verdict === "request_changes" ? (
1106 <span className="flex items-center gap-1 text-xs text-danger">
1107 <CircleSlash size={13} /> Changes requested
1108 </span>
1109 ) : (
1110 <span className="text-xs text-faint">Review requested</span>
1111 )}
1112 </li>
1113 );
1114 })}
1115 {reviewerNames.length === 0 && (
1116 <li className="px-1 text-xs text-faint">No reviews requested yet.</li>
1117 )}
1118 </ul>
1119 {pull.status === "open" && canManage && (
1120 <div className="mt-3 space-y-2">
1121 {agentsEnabled && !reviewPending && (
1122 <Form method="post">
1123 <input type="hidden" name="action" value="agent-review" />
1124 {pull.reviewers.map((name) => (
1125 <input key={name} type="hidden" name="reviewer" value={name} />
1126 ))}
1127 <div className="*:w-full">
1128 <Button variant="quiet" type="submit">
1129 <Sparkles size={14} className="text-accent" />
1130 Request review from g1t agent
1131 </Button>
1132 </div>
1133 </Form>
1134 )}
1135 <details>
1136 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
1137 Request review from people
1138 </summary>
1139 <Form method="post" className="mt-2 space-y-2" key={pull.reviewers.join()}>
1140 <input type="hidden" name="action" value="reviewers" />
1141 {pull.reviewers.includes("g1t-agent") && (
1142 <input type="hidden" name="reviewer" value="g1t-agent" />
1143 )}
1144 <PeoplePicker
1145 name="reviewer"
1146 members={members.filter((name) => name !== pull.author.username)}
1147 chosen={pull.reviewers.filter((name) => name !== "g1t-agent")}
1148 />
1149 <Button variant="quiet" type="submit">
1150 Save reviewers
1151 </Button>
1152 </Form>
1153 </details>
1154 </div>
1155 )}
1156 {["agent-review", "reviewers"].includes(String(actionData?.action)) && (
1157 <ErrorText>{actionData?.error}</ErrorText>
1158 )}
1159 </section>
1160
1161 <section>
1162 <h3 className="text-sm font-medium">Assignees</h3>
1163 <ul className="mt-2 space-y-1.5 text-sm">
1164 {pull.assignees.map((name) => (
1165 <li key={name} className="flex items-center gap-2 px-1">
1166 <Avatar name={name} size={20} />
1167 <span className="grow truncate font-mono text-xs">{name}</span>
1168 </li>
1169 ))}
1170 {pull.assignees.length === 0 && (
1171 <li className="px-1 text-xs text-faint">No one yet.</li>
1172 )}
1173 </ul>
1174 {viewer && canManage && active && (
1175 <div className="mt-3 space-y-2">
1176 {!pull.assignees.includes(viewer.username) && (
1177 <Form method="post">
1178 <input type="hidden" name="action" value="assign" />
1179 {pull.assignees.map((name) => (
1180 <input key={name} type="hidden" name="assignee" value={name} />
1181 ))}
1182 <input type="hidden" name="assignee" value={viewer.username} />
1183 <div className="*:w-full">
1184 <Button variant="quiet" type="submit">
1185 Assign yourself
1186 </Button>
1187 </div>
1188 </Form>
1189 )}
1190 <details>
1191 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
1192 Assign people
1193 </summary>
1194 <Form method="post" className="mt-2 space-y-2" key={pull.assignees.join()}>
1195 <input type="hidden" name="action" value="assign" />
1196 <PeoplePicker name="assignee" members={members} chosen={pull.assignees} />
1197 <Button variant="quiet" type="submit">
1198 Save assignees
1199 </Button>
1200 </Form>
1201 </details>
1202 </div>
1203 )}
1204 {actionData?.action === "assign" && <ErrorText>{actionData.error}</ErrorText>}
1205 </section>
1206
1207 <section>
1208 <h3 className="text-sm font-medium">Working copy</h3>
1209 {pull.branch ? (
1210 <>
1211 <p className="mt-1 text-xs text-muted">
1212 <GitBranch size={13} className="mr-1 inline align-[-2px]" />
1213 Branch <span className="font-mono text-fg">{pull.branch}</span> of this
1214 repository. Pushes to it show up here.
1215 </p>
1216 <div className="mt-2">
1217 <CopyLine text={`git clone -b ${pull.branch} ${remote}`} />
1218 </div>
1219 </>
1220 ) : (
1221 <>
1222 <p className="mt-1 text-xs text-muted">
1223 This fork belongs to the pull request. Pushes to it show up here.
1224 </p>
1225 <div className="mt-2">
1226 <CopyLine text={`git clone ${remote}`} />
1227 </div>
1228 </>
1229 )}
1230 <p className="mt-3 flex items-center gap-1.5 font-mono text-xs text-faint">
1231 <GitCommitHorizontal size={13} />
1232 {pull.headCommit?.slice(0, 12) ?? "no commits pushed yet"}
1233 </p>
1234 </section>
1235 </aside>
1236 </div>
1237 </div>
1238 );
1239}
1240
1241/** Where the pull request's change is live, as GitHub shows a deployment. */
1242function DeploymentCard({
1243 preview,
1244 build,
1245 stacked,
1246 base,
1247}: {
1248 preview: LiveApp | null;
1249 build: Deployment | null;
1250 stacked: { name: string; slug: string; url: string }[];
1251 base: string;
1252}) {
1253 const building = build?.status === "queued" || build?.status === "building";
1254 const failed = build?.status === "failed";
1255 return (
1256 <section className="ml-12 overflow-hidden rounded-xl border border-line bg-surface">
1257 <div className="flex flex-wrap items-center gap-3 px-4 py-3">
1258 <span
1259 className={`flex size-8 shrink-0 items-center justify-center rounded-full ring-1 ${
1260 failed ? "text-danger ring-danger/40" : building ? "text-warn ring-warn/40" : "text-accent ring-accent/40"
1261 }`}
1262 >
1263 {building ? <Loader size={15} className="animate-spin" /> : <Rocket size={15} />}
1264 </span>
1265 <div className="min-w-0 grow">
1266 <p className="text-sm font-medium">
1267 {building
1268 ? preview
1269 ? "Deploying the latest push. The last preview is still live."
1270 : "Deploying a preview"
1271 : failed
1272 ? "The preview failed to deploy"
1273 : "This branch is live"}
1274 </p>
1275 {preview ? (
1276 <a href={preview.url} className="mt-0.5 block truncate font-mono text-xs text-muted hover:text-accent">
1277 {preview.url.replace(/^https?:[/][/]/, "")}
1278 </a>
1279 ) : (
1280 build?.error && <p className="mt-0.5 truncate text-xs text-muted">{build.error}</p>
1281 )}
1282 </div>
1283 <div className="flex shrink-0 items-center gap-2">
1284 {build && (
1285 <Link to={`${base}/deployments/${build.id}`} className="text-xs text-muted hover:text-fg">
1286 {failed ? "See why" : "Build log"}
1287 </Link>
1288 )}
1289 {preview && (
1290 <a
1291 href={preview.url}
1292 className="inline-flex items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-bg hover:opacity-90"
1293 >
1294 Visit preview
1295 <ArrowUpRight size={13} />
1296 </a>
1297 )}
1298 </div>
1299 </div>
1300 {preview && (
1301 <p className="border-t border-line px-4 py-2 text-xs text-faint">
1302 <span className="font-mono">{preview.commit.slice(0, 7)}</span> · deployed <TimeAgo at={preview.deployedAt} />
1303 {preview.branch && ` · from ${preview.branch}`}
1304 </p>
1305 )}
1306 {stacked.length > 0 && (
1307 <div className="border-t border-line px-4 py-2.5">
1308 <p className="text-xs text-muted">Built against this change:</p>
1309 <ul className="mt-1.5 space-y-1">
1310 {stacked.map((entry) => (
1311 <li key={entry.slug} className="flex items-center gap-2 text-xs">
1312 <span className="font-medium">{entry.name}</span>
1313 <a href={entry.url} className="truncate font-mono text-muted hover:text-accent">
1314 {entry.url.replace(/^https?:[/][/]/, "")}
1315 </a>
1316 </li>
1317 ))}
1318 </ul>
1319 </div>
1320 )}
1321 </section>
1322 );
1323}