11import { 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
412import { Badge } from '@/components/ui/badge' ;
513import { 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' ;
1423import 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+
1632const 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+
55170interface 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