Spaces:
Sleeping
Sleeping
Sync from enhance: d795216 feat: implement patch response parser for search and replace functionality
9bd4242 | import { useMemo, useState } from 'react'; | |
| import type { JobTimings } from '../types/api'; | |
| import { useI18n } from '../i18n'; | |
| function formatDuration(ms: number): string { | |
| if (ms >= 1000) { | |
| return `${(ms / 1000).toFixed(1)}s`; | |
| } | |
| return `${Math.round(ms)}ms`; | |
| } | |
| interface TimingPanelProps { | |
| timings?: JobTimings; | |
| submittedAt?: string | null; | |
| finishedAt?: string | null; | |
| } | |
| export function TimingPanel({ timings, submittedAt, finishedAt }: TimingPanelProps) { | |
| const { t } = useI18n(); | |
| const [isOpen, setIsOpen] = useState(false); | |
| const { total, items } = useMemo(() => { | |
| const timingLabels: Array<{ key: keyof JobTimings; label: string }> = [ | |
| { key: 'analyze', label: t('timing.analyze') }, | |
| { key: 'edit', label: t('timing.edit') }, | |
| { key: 'retry', label: t('timing.retry') }, | |
| { key: 'render', label: t('timing.render') }, | |
| { key: 'store', label: t('timing.store') }, | |
| ]; | |
| const items = timingLabels | |
| .map(({ key, label }) => ({ key, label, value: timings?.[key] })) | |
| .filter((item) => typeof item.value === 'number'); | |
| const submittedMs = submittedAt ? Date.parse(submittedAt) : Number.NaN; | |
| const finishedMs = finishedAt ? Date.parse(finishedAt) : Number.NaN; | |
| const endToEndTotal = Number.isFinite(submittedMs) && Number.isFinite(finishedMs) | |
| ? Math.max(0, finishedMs - submittedMs) | |
| : undefined; | |
| const total = typeof endToEndTotal === 'number' | |
| ? endToEndTotal | |
| : typeof timings?.total === 'number' | |
| ? timings.total | |
| : items.reduce((sum, item) => sum + (item.value || 0), 0); | |
| return { total, items }; | |
| }, [finishedAt, submittedAt, t, timings]); | |
| if (!items.length && !Number.isFinite(total)) { | |
| return null; | |
| } | |
| return ( | |
| <div className="fixed left-4 bottom-4 z-40"> | |
| <div className="relative"> | |
| <div | |
| className={`absolute left-0 bottom-full mb-2 w-56 rounded-2xl bg-bg-secondary/90 text-xs text-text-secondary shadow-lg shadow-black/10 border border-bg-secondary/60 backdrop-blur px-4 py-3 space-y-2 origin-bottom-left transition-all duration-200 ease-out ${ | |
| isOpen | |
| ? 'opacity-100 translate-y-0 scale-100 pointer-events-auto' | |
| : 'opacity-0 translate-y-2 scale-95 pointer-events-none' | |
| }`} | |
| aria-hidden={!isOpen} | |
| > | |
| {items.map((item) => ( | |
| <div key={item.key} className="flex items-center justify-between"> | |
| <span>{item.label}</span> | |
| <span className="text-text-primary font-medium">{formatDuration(item.value!)}</span> | |
| </div> | |
| ))} | |
| </div> | |
| <button | |
| type="button" | |
| onClick={() => setIsOpen((prev) => !prev)} | |
| aria-expanded={isOpen} | |
| className="flex items-center gap-2 px-3 py-2 rounded-full bg-bg-secondary/80 text-xs text-text-secondary/90 shadow-lg shadow-black/10 backdrop-blur border border-bg-secondary/60 hover:text-text-primary hover:bg-bg-secondary transition-colors" | |
| > | |
| <span className="text-[11px] tracking-wide">{t('timing.title')}</span> | |
| <span className="text-text-primary font-medium">{formatDuration(total)}</span> | |
| <svg | |
| className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`} | |
| fill="none" | |
| stroke="currentColor" | |
| viewBox="0 0 24 24" | |
| > | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> | |
| </svg> | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| } | |