import { useEffect, useMemo, useRef, useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { cn } from '@renderer/lib/utils'; import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'; import { MarkdownViewer } from '../chat/viewers/MarkdownViewer'; import { STEP_LABELS, STEP_ORDER } from './provisioningSteps'; import type { ProvisioningStep } from './provisioningSteps'; // --------------------------------------------------------------------------- // JSON syntax-highlighted CLI logs // --------------------------------------------------------------------------- const JSON_KEY_COLOR = 'var(--syntax-property, #7dd3fc)'; const JSON_STRING_COLOR = 'var(--syntax-string, #86efac)'; const JSON_NUMBER_COLOR = 'var(--syntax-number, #fde68a)'; const JSON_BOOL_NULL_COLOR = 'var(--syntax-keyword, #c4b5fd)'; const JSON_BRACKET_COLOR = 'var(--color-text-muted)'; function syntaxHighlightJson(json: string): string { return ( json .replace(/("(?:\\.|[^"\\])*")\s*:/g, `$1:`) .replace(/:\s*("(?:\\.|[^"\\])*")/g, (match, str: string) => match.replace(str, `${str}`) ) // eslint-disable-next-line security/detect-unsafe-regex -- number format is bounded, input is our JSON .replace(/:\s*(-?\d+(?:\.\d{1,20})?(?:[eE][+-]?\d{1,5})?)/g, (match, num: string) => match.replace(num, `${num}`) ) .replace(/:\s*(true|false|null)/g, (match, kw: string) => match.replace(kw, `${kw}`) ) .replace(/([{}[\]])/g, `$1`) ); } function escapeHtml(text: string): string { return text.replace(/&/g, '&').replace(//g, '>'); } function prettyFormatLogs(raw: string): string { return raw .split('\n') .map((line) => { const trimmed = line.trim(); if (!trimmed) return ''; try { const parsed = JSON.parse(trimmed) as unknown; const pretty = escapeHtml(JSON.stringify(parsed, null, 2)); return syntaxHighlightJson(pretty); } catch { return escapeHtml(trimmed); } }) .join('\n'); } export interface ProvisioningProgressBlockProps { /** Title above the steps, e.g. "Launching team" */ title: string; /** Optional status message */ message?: string | null; /** Index of the current step in STEP_ORDER (0-based), or -1 if unknown */ currentStepIndex: number; /** Show spinner next to title */ loading?: boolean; /** Cancel button label and handler */ onCancel?: (() => void) | null; /** ISO timestamp when provisioning started */ startedAt?: string; /** PID of the CLI process */ pid?: number; /** Tail of CLI logs */ cliLogsTail?: string; /** Accumulated assistant text output for live preview */ assistantOutput?: string; className?: string; } function formatElapsed(seconds: number): string { const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m}:${String(s).padStart(2, '0')}`; } function useElapsedTimer(startedAt?: string): string | null { const [elapsed, setElapsed] = useState(null); useEffect(() => { if (!startedAt) return () => setElapsed(null); const startMs = Date.parse(startedAt); if (isNaN(startMs)) return () => setElapsed(null); const tick = (): void => { const seconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000)); setElapsed(formatElapsed(seconds)); }; tick(); const id = window.setInterval(tick, 1000); return () => { window.clearInterval(id); }; }, [startedAt]); if (!startedAt) return null; return elapsed; } export const ProvisioningProgressBlock = ({ title, message, currentStepIndex, loading = false, onCancel, startedAt, pid, cliLogsTail, assistantOutput, className, }: ProvisioningProgressBlockProps): React.JSX.Element => { const elapsed = useElapsedTimer(startedAt); const [logsOpen, setLogsOpen] = useState(false); const logsRef = useRef(null); const outputScrollRef = useRef(null); const prettyLogs = useMemo( () => (cliLogsTail ? prettyFormatLogs(cliLogsTail) : ''), [cliLogsTail] ); // Auto-scroll CLI logs useEffect(() => { if (logsOpen && logsRef.current) { logsRef.current.scrollTop = logsRef.current.scrollHeight; } }, [logsOpen, cliLogsTail]); // Auto-scroll assistant output useEffect(() => { if (outputScrollRef.current) { outputScrollRef.current.scrollTop = outputScrollRef.current.scrollHeight; } }, [assistantOutput]); return (
{loading ? ( ) : null}

{title}

{elapsed !== null ? ( {elapsed} ) : null} {pid !== undefined ? ( PID {pid} ) : null}
{onCancel ? ( ) : null}
{message ?

{message}

: null}
{STEP_ORDER.filter((s): s is ProvisioningStep => s !== 'ready').map((step, index) => { const isDone = currentStepIndex >= 0 && index < currentStepIndex; const isCurrent = currentStepIndex >= 0 && index === currentStepIndex; return (
{/* eslint-disable tailwindcss/no-custom-classname -- theme CSS vars */} {index + 1} {STEP_LABELS[step]} {/* eslint-enable tailwindcss/no-custom-classname -- end theme CSS vars block */} {index < STEP_ORDER.filter((s) => s !== 'ready').length - 1 ? ( ) : null}
); })}
{assistantOutput ? (

Live output

) : null} {cliLogsTail ? (
{logsOpen ? (
          ) : null}
        
) : null}
); };