Skip to content

Commit 6680c15

Browse files
authored
feat(website): add Inspect prompt button on failed commits (#30)
1 parent 2a66611 commit 6680c15

3 files changed

Lines changed: 461 additions & 15 deletions

File tree

website/src/components/timeline.tsx

Lines changed: 213 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import { useWindowVirtualizer } from '@tanstack/react-virtual';
2-
import { useCallback, useMemo, useState } from 'react';
2+
import {
3+
useCallback,
4+
useEffect,
5+
useLayoutEffect,
6+
useMemo,
7+
useRef,
8+
useState,
9+
} from 'react';
10+
import { createPortal } from 'react-dom';
311

412
import { Badge } from '@/components/ui/badge';
513
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -10,9 +18,17 @@ import {
1018
SelectTrigger,
1119
SelectValue,
1220
} from '@/components/ui/select';
13-
import { cn } from '@/lib/utils';
21+
import { buildAgentPrompt } from '@/lib/agent-prompt';
22+
import { cn, shortSha } from '@/lib/utils';
1423
import type { EcosystemCommitRecord } from '@/types';
1524

25+
type CopyStatus = 'copied' | 'failed';
26+
27+
const COPY_LABELS: Record<CopyStatus, string> = {
28+
copied: 'Copied!',
29+
failed: 'Copy failed',
30+
};
31+
1632
const commitStatusStyles = {
1733
success: {
1834
dotRing: 'border-emerald-400/70',
@@ -52,6 +68,105 @@ const suiteStatusStyles = {
5268
},
5369
} as const;
5470

71+
interface InspectPromptButtonProps {
72+
copyStatus: CopyStatus | null;
73+
stackLabel: string | null;
74+
onClick: () => void;
75+
}
76+
77+
const INSPECT_BUTTON_BG = '#2747c5';
78+
const INSPECT_BUTTON_BG_COPIED = '#ffffff';
79+
const INSPECT_BUTTON_TEXT = '#e9e9e9';
80+
const INSPECT_BUTTON_BORDER = '#6387e3';
81+
82+
function InspectPromptButton({
83+
copyStatus,
84+
stackLabel,
85+
onClick,
86+
}: InspectPromptButtonProps) {
87+
const buttonRef = useRef<HTMLButtonElement>(null);
88+
const [tooltipPos, setTooltipPos] = useState<{
89+
top: number;
90+
left: number;
91+
} | null>(null);
92+
const isCopied = copyStatus === 'copied';
93+
94+
useLayoutEffect(() => {
95+
if (!isCopied) {
96+
setTooltipPos(null);
97+
return;
98+
}
99+
const update = () => {
100+
const rect = buttonRef.current?.getBoundingClientRect();
101+
if (!rect) return;
102+
const next = { top: rect.top, left: rect.left + rect.width / 2 };
103+
// Diff numerically — without this, every scroll tick allocates a
104+
// fresh object and re-renders the portal, defeating React's bail-out.
105+
setTooltipPos((prev) =>
106+
prev && prev.top === next.top && prev.left === next.left ? prev : next,
107+
);
108+
};
109+
update();
110+
window.addEventListener('scroll', update, true);
111+
window.addEventListener('resize', update);
112+
return () => {
113+
window.removeEventListener('scroll', update, true);
114+
window.removeEventListener('resize', update);
115+
};
116+
}, [isCopied]);
117+
118+
return (
119+
<>
120+
<button
121+
ref={buttonRef}
122+
type="button"
123+
onClick={onClick}
124+
className="relative inline-flex w-[104px] items-center justify-center overflow-hidden rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide transition-colors duration-200 sm:w-[110px] sm:px-2.5"
125+
style={{ color: isCopied ? INSPECT_BUTTON_BG : INSPECT_BUTTON_TEXT }}
126+
title="Copy a prompt to paste into an AI agent to diagnose this failure"
127+
>
128+
<span
129+
aria-hidden
130+
className="absolute inset-0 transition-colors duration-200"
131+
style={{
132+
backgroundColor: isCopied
133+
? INSPECT_BUTTON_BG_COPIED
134+
: INSPECT_BUTTON_BG,
135+
}}
136+
/>
137+
<span
138+
aria-hidden
139+
className="absolute inset-0 rounded-full border border-dashed transition-colors duration-200"
140+
style={{
141+
borderColor: isCopied
142+
? INSPECT_BUTTON_BG_COPIED
143+
: INSPECT_BUTTON_BORDER,
144+
}}
145+
/>
146+
<span className="relative">
147+
{copyStatus ? COPY_LABELS[copyStatus] : 'Inspect prompt'}
148+
</span>
149+
</button>
150+
{isCopied && stackLabel && tooltipPos
151+
? createPortal(
152+
<div
153+
role="tooltip"
154+
className="pointer-events-none fixed z-50 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded-md border border-border/60 bg-black/95 px-3 py-1.5 text-[11px] font-medium text-foreground shadow-lg"
155+
style={{ top: tooltipPos.top - 8, left: tooltipPos.left }}
156+
>
157+
Paste the prompt to agent within {stackLabel} to inspect
158+
<span
159+
aria-hidden
160+
className="absolute left-1/2 top-full h-2 w-2 -translate-x-1/2 -translate-y-1/2 rotate-45 border-b border-r border-border/60 bg-black/95"
161+
/>
162+
</div>,
163+
document.body,
164+
)
165+
: null}
166+
</>
167+
);
168+
}
169+
55170
interface TimelineProps {
56171
entries: EcosystemCommitRecord[];
57172
selectedSuite?: string;
@@ -80,8 +195,74 @@ export function Timeline({
80195
const [internalSelectedSuite, setInternalSelectedSuite] =
81196
useState<string>('all');
82197
const [internalSearchQuery, setInternalSearchQuery] = useState('');
198+
const [copyState, setCopyState] = useState<{
199+
sha: string;
200+
status: CopyStatus;
201+
} | null>(null);
202+
const copyTimerRef = useRef<number | null>(null);
203+
204+
useEffect(
205+
() => () => {
206+
if (copyTimerRef.current != null) {
207+
window.clearTimeout(copyTimerRef.current);
208+
}
209+
},
210+
[],
211+
);
212+
213+
const handleCopyPrompt = useCallback(
214+
async (entry: EcosystemCommitRecord) => {
215+
if (!externalSelectedStack) return;
216+
// When a suite filter is active, `entry` comes from `suiteFilteredEntries`
217+
// with a narrowed `suites` array — look up the unfiltered record by SHA
218+
// so the prompt reflects every failing suite on the commit.
219+
const fullEntry =
220+
entries.find((e) => e.commitSha === entry.commitSha) ?? entry;
221+
const prompt = buildAgentPrompt({
222+
entry: fullEntry,
223+
stackId: externalSelectedStack,
224+
history: entries,
225+
});
226+
227+
const setState = (status: CopyStatus) => {
228+
setCopyState({ sha: entry.commitSha, status });
229+
if (copyTimerRef.current != null) {
230+
window.clearTimeout(copyTimerRef.current);
231+
}
232+
copyTimerRef.current = window.setTimeout(() => {
233+
setCopyState(null);
234+
copyTimerRef.current = null;
235+
}, 2000);
236+
};
237+
238+
try {
239+
await navigator.clipboard.writeText(prompt);
240+
setState('copied');
241+
return;
242+
} catch {
243+
// Fall through to legacy execCommand path (headless / non-secure contexts).
244+
}
245+
246+
try {
247+
const ta = document.createElement('textarea');
248+
ta.value = prompt;
249+
ta.setAttribute('readonly', '');
250+
ta.style.position = 'fixed';
251+
ta.style.top = '0';
252+
ta.style.left = '0';
253+
ta.style.opacity = '0';
254+
document.body.appendChild(ta);
255+
ta.select();
256+
const ok = document.execCommand('copy');
257+
document.body.removeChild(ta);
258+
setState(ok ? 'copied' : 'failed');
259+
} catch {
260+
setState('failed');
261+
}
262+
},
263+
[entries, externalSelectedStack],
264+
);
83265

84-
// Use external state if provided, otherwise use internal state
85266
const selectedSuite = externalSelectedSuite ?? internalSelectedSuite;
86267
const setSelectedSuite = onSuiteChange ?? setInternalSelectedSuite;
87268
const searchQuery = externalSearchQuery ?? internalSearchQuery;
@@ -96,7 +277,6 @@ export function Timeline({
96277
[],
97278
);
98279

99-
// Get all unique suite names
100280
const allSuiteNames = useMemo(() => {
101281
const names = new Set<string>();
102282
for (const entry of entries) {
@@ -107,7 +287,6 @@ export function Timeline({
107287
return Array.from(names).sort();
108288
}, [entries]);
109289

110-
// Filter entries based on selected suite
111290
const suiteFilteredEntries = useMemo(() => {
112291
if (selectedSuite === 'all') {
113292
return entries;
@@ -120,7 +299,6 @@ export function Timeline({
120299
.filter((entry) => entry.suites.length > 0);
121300
}, [entries, selectedSuite]);
122301

123-
// Filter entries based on search query
124302
const filteredEntries = useMemo(() => {
125303
const q = searchQuery.trim().toLowerCase();
126304
if (!q) {
@@ -159,10 +337,11 @@ export function Timeline({
159337
const commitStyles =
160338
commitStatusStyles[entry.overallStatus] ?? commitStatusStyles.failure;
161339
const formattedDate = formatter.format(new Date(entry.commitTimestamp));
162-
const shortSha = entry.commitSha.slice(0, 7);
340+
const sha = shortSha(entry.commitSha);
163341
const commitUrl = `https://github.com/${entry.repository.fullName}/commit/${entry.commitSha}`;
164342
const isFirst = index === 0;
165343
const isLast = index === filteredEntries.length - 1;
344+
const stackLabel = selectedStackMeta?.label;
166345
const isRenovateBot = entry.author?.login === 'renovate[bot]';
167346
const avatarUrl = isRenovateBot
168347
? 'https://avatars.githubusercontent.com/in/2740?s=80&v=4'
@@ -244,7 +423,7 @@ export function Timeline({
244423
target="_blank"
245424
rel="noreferrer"
246425
>
247-
{shortSha}
426+
{sha}
248427
<span className="text-[10px] text-muted-foreground/80">
249428
250429
</span>
@@ -280,12 +459,25 @@ export function Timeline({
280459
</div>
281460

282461
<div className="flex flex-none items-center gap-2 sm:flex-col sm:items-end">
283-
<Badge
284-
variant={commitStyles.badge}
285-
className="px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide sm:px-2.5"
286-
>
287-
{commitStyles.label}
288-
</Badge>
462+
<div className="flex items-center gap-2">
463+
{entry.overallStatus === 'failure' ? (
464+
<InspectPromptButton
465+
copyStatus={
466+
copyState?.sha === entry.commitSha
467+
? copyState.status
468+
: null
469+
}
470+
stackLabel={stackLabel ?? null}
471+
onClick={() => handleCopyPrompt(entry)}
472+
/>
473+
) : null}
474+
<Badge
475+
variant={commitStyles.badge}
476+
className="px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide sm:px-2.5"
477+
>
478+
{commitStyles.label}
479+
</Badge>
480+
</div>
289481
<a
290482
href={entry.workflowRunUrl}
291483
className="text-[11px] text-muted-foreground transition-[color] hover:text-foreground/90"
@@ -347,7 +539,13 @@ export function Timeline({
347539
</div>
348540
);
349541
},
350-
[filteredEntries.length, formatter],
542+
[
543+
filteredEntries.length,
544+
formatter,
545+
handleCopyPrompt,
546+
copyState,
547+
selectedStackMeta,
548+
],
351549
);
352550

353551
return (

0 commit comments

Comments
 (0)