forked from Zakaria/hermes-agent
189 lines
6.3 KiB
TypeScript
189 lines
6.3 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import { useStore } from '@nanostores/react'
|
|
import { Button } from '../components/button'
|
|
import {
|
|
cancelInstall,
|
|
$progress,
|
|
type BootstrapStateModel,
|
|
type StageState
|
|
} from '../store'
|
|
import { Check, X, ChevronRight, FileText, Loader2 } from 'lucide-react'
|
|
import clsx from 'clsx'
|
|
|
|
interface ProgressProps {
|
|
bootstrap: BootstrapStateModel
|
|
}
|
|
|
|
/*
|
|
* Progress screen — drives a stage list + collapsible log panel. Uses
|
|
* the DS <Progress> for the top bar so its motion + ring match the rest
|
|
* of the product.
|
|
*/
|
|
export default function ProgressScreen({ bootstrap }: ProgressProps) {
|
|
const progress = useStore($progress)
|
|
const [showLogs, setShowLogs] = useState(false)
|
|
const logEndRef = useRef<HTMLDivElement>(null)
|
|
|
|
useEffect(() => {
|
|
if (showLogs && logEndRef.current) {
|
|
logEndRef.current.scrollIntoView({ behavior: 'smooth' })
|
|
}
|
|
}, [bootstrap.logs.length, showLogs])
|
|
|
|
const currentStage =
|
|
bootstrap.currentStage != null
|
|
? bootstrap.stages[bootstrap.currentStage]
|
|
: null
|
|
|
|
return (
|
|
<div className="hermes-fade-in flex h-full flex-col">
|
|
<div className="border-b border-border px-6 py-4">
|
|
<div className="mb-3 flex items-center justify-between text-xs">
|
|
<div className="flex items-center gap-2 text-foreground">
|
|
{bootstrap.status === 'running' && (
|
|
<Loader2 size={12} className="animate-spin text-primary" />
|
|
)}
|
|
<span>
|
|
{bootstrap.status === 'running'
|
|
? currentStage
|
|
? currentStage.info.title
|
|
: 'Preparing\u2026'
|
|
: bootstrap.status === 'completed'
|
|
? 'Done'
|
|
: 'Installing'}
|
|
</span>
|
|
</div>
|
|
<div className="text-muted-foreground">
|
|
{progress.done} of {progress.total} steps
|
|
</div>
|
|
</div>
|
|
{/* Top progress bar — plain HTML, derived from --primary so it
|
|
tracks the theme accent. */}
|
|
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
|
|
<div
|
|
className="h-full bg-primary transition-all duration-300 ease-out"
|
|
style={{ width: `${Math.max(2, progress.fraction * 100)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-1 overflow-hidden">
|
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
|
<ol className="space-y-1">
|
|
{bootstrap.stageOrder.map((name) => {
|
|
const rec = bootstrap.stages[name]
|
|
if (!rec) return null
|
|
return (
|
|
<li
|
|
key={name}
|
|
className={clsx(
|
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors',
|
|
rec.state === 'running' && 'bg-card text-foreground',
|
|
rec.state === 'succeeded' && 'text-foreground/80',
|
|
rec.state === 'skipped' && 'text-muted-foreground',
|
|
rec.state === 'failed' &&
|
|
'bg-destructive/10 text-destructive',
|
|
!rec.state && 'text-muted-foreground/60'
|
|
)}
|
|
>
|
|
<StateIcon state={rec.state ?? null} />
|
|
<span className="flex-1 truncate">{rec.info.title}</span>
|
|
{rec.durationMs != null && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{formatDuration(rec.durationMs)}
|
|
</span>
|
|
)}
|
|
</li>
|
|
)
|
|
})}
|
|
</ol>
|
|
</div>
|
|
|
|
{showLogs && (
|
|
<div className="flex w-1/2 flex-col border-l border-border bg-card/40">
|
|
<div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2">
|
|
<div className="text-xs font-medium text-foreground/80">
|
|
Live output
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{bootstrap.logs.length} lines
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto px-3 py-2 font-mono text-[11px] leading-relaxed">
|
|
{bootstrap.logs.map((entry, idx) => (
|
|
<div
|
|
key={idx}
|
|
className={clsx(
|
|
'whitespace-pre-wrap',
|
|
entry.stream === 'stderr' ? 'text-foreground/45' : 'text-foreground/70'
|
|
)}
|
|
>
|
|
{entry.line}
|
|
</div>
|
|
))}
|
|
<div ref={logEndRef} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center justify-between border-t border-border px-6 py-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowLogs((v) => !v)}
|
|
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
|
>
|
|
<FileText size={14} />
|
|
{showLogs ? 'Hide details' : 'Show details'}
|
|
<ChevronRight
|
|
size={12}
|
|
className={clsx(
|
|
'transition-transform',
|
|
showLogs && 'rotate-90'
|
|
)}
|
|
/>
|
|
</button>
|
|
|
|
{bootstrap.status === 'running' && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => void cancelInstall()}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function StateIcon({ state }: { state: StageState | null }) {
|
|
if (state === 'running') {
|
|
return <Loader2 size={14} className="animate-spin text-primary" />
|
|
}
|
|
if (state === 'succeeded') {
|
|
return <Check size={14} className="text-emerald-400" />
|
|
}
|
|
if (state === 'skipped') {
|
|
return <ChevronRight size={14} className="text-muted-foreground/70" />
|
|
}
|
|
if (state === 'failed') {
|
|
return <X size={14} className="text-destructive" />
|
|
}
|
|
return (
|
|
<div
|
|
className="h-[6px] w-[6px] rounded-full bg-muted-foreground/40"
|
|
aria-hidden
|
|
/>
|
|
)
|
|
}
|
|
|
|
function formatDuration(ms: number): string {
|
|
if (ms < 1000) return `${ms}ms`
|
|
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
|
const m = Math.floor(ms / 60000)
|
|
const s = Math.round((ms % 60000) / 1000)
|
|
return `${m}m ${s}s`
|
|
}
|