339 lines
11 KiB
TypeScript
339 lines
11 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
|
|
import { Button } from '@renderer/components/ui/button';
|
|
import { cn } from '@renderer/lib/utils';
|
|
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Loader2, X } from 'lucide-react';
|
|
|
|
import { MarkdownViewer } from '../chat/viewers/MarkdownViewer';
|
|
|
|
import { CliLogsRichView } from './CliLogsRichView';
|
|
import { DISPLAY_STEPS } from './provisioningSteps';
|
|
import { StepProgressBar } from './StepProgressBar';
|
|
|
|
import type { StepProgressBarStep } from './StepProgressBar';
|
|
|
|
/** Pre-built step definitions for the provisioning stepper. */
|
|
const PROVISIONING_STEPS: StepProgressBarStep[] = DISPLAY_STEPS.map((s) => ({
|
|
key: s.key,
|
|
label: s.label,
|
|
}));
|
|
|
|
export interface ProvisioningProgressBlockProps {
|
|
/** Title above the steps, e.g. "Launching team" */
|
|
title: string;
|
|
/** Optional status message */
|
|
message?: string | null;
|
|
/** Visual severity for the message subtitle */
|
|
messageSeverity?: 'error' | 'warning';
|
|
/** Visual tone (e.g. highlight errors) */
|
|
tone?: 'default' | 'error';
|
|
/** Whether Live output is expanded by default */
|
|
defaultLiveOutputOpen?: boolean;
|
|
/** Display step index (0-3 for active steps, 4 for ready/all done, -1 for terminal) */
|
|
currentStepIndex: number;
|
|
/** If set, this step index shows a red error indicator */
|
|
errorStepIndex?: number;
|
|
/** Show spinner next to title */
|
|
loading?: boolean;
|
|
/** Cancel button label and handler */
|
|
onCancel?: (() => void) | null;
|
|
/** Success message shown inside the block header (e.g. "Team launched — all N teammates online") */
|
|
successMessage?: string | null;
|
|
/** Visual tone for the status banner above the block. */
|
|
successMessageSeverity?: 'success' | 'warning';
|
|
/** Dismiss handler — renders an X button in the block header top-right */
|
|
onDismiss?: (() => void) | null;
|
|
/** ISO timestamp when provisioning started */
|
|
startedAt?: string;
|
|
/** PID of the CLI process */
|
|
pid?: number;
|
|
/** CLI logs captured during launch */
|
|
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, isRunning = true): string | null {
|
|
const [elapsedSeconds, setElapsedSeconds] = useState<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!startedAt) {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
|
setElapsedSeconds(null);
|
|
return;
|
|
}
|
|
|
|
const startMs = Date.parse(startedAt);
|
|
if (isNaN(startMs)) {
|
|
setElapsedSeconds(null);
|
|
return;
|
|
}
|
|
|
|
const computeElapsedSeconds = (): number =>
|
|
Math.max(0, Math.floor((Date.now() - startMs) / 1000));
|
|
|
|
if (!isRunning) {
|
|
// Freeze timer on terminal states (failed/ready/cancelled) instead of continuing to tick.
|
|
setElapsedSeconds((prev) => (prev === null ? computeElapsedSeconds() : prev));
|
|
return;
|
|
}
|
|
|
|
const tick = (): void => {
|
|
setElapsedSeconds(computeElapsedSeconds());
|
|
};
|
|
|
|
tick();
|
|
const id = window.setInterval(tick, 1000);
|
|
return () => {
|
|
window.clearInterval(id);
|
|
};
|
|
}, [startedAt, isRunning]);
|
|
|
|
if (!startedAt) return null;
|
|
if (elapsedSeconds === null) return null;
|
|
return formatElapsed(elapsedSeconds);
|
|
}
|
|
|
|
function sanitizeAssistantOutput(raw?: string, isError = false): string | null {
|
|
if (!raw) return null;
|
|
if (!isError) return raw;
|
|
|
|
const looksLikeRawApiEnvelope =
|
|
raw.includes('API Error: 400') &&
|
|
(raw.includes('"_requests"') ||
|
|
raw.includes('"session_id"') ||
|
|
raw.includes('"parent_tool_use_id"') ||
|
|
raw.includes('\\u000'));
|
|
|
|
if (!looksLikeRawApiEnvelope) {
|
|
return raw;
|
|
}
|
|
|
|
return (
|
|
'API Error: 400\n\n' +
|
|
'Raw payload from CLI stream hidden because it contains encoded/binary-like content.\n\n' +
|
|
'Open **CLI logs** below for readable diagnostics.'
|
|
);
|
|
}
|
|
|
|
export const ProvisioningProgressBlock = ({
|
|
title,
|
|
message,
|
|
messageSeverity,
|
|
tone = 'default',
|
|
defaultLiveOutputOpen = true,
|
|
currentStepIndex,
|
|
errorStepIndex,
|
|
loading = false,
|
|
onCancel,
|
|
successMessage,
|
|
successMessageSeverity = 'success',
|
|
onDismiss,
|
|
startedAt,
|
|
pid,
|
|
cliLogsTail,
|
|
assistantOutput,
|
|
className,
|
|
}: ProvisioningProgressBlockProps): React.JSX.Element => {
|
|
const elapsed = useElapsedTimer(startedAt, loading);
|
|
const [logsOpen, setLogsOpen] = useState(() => Boolean(cliLogsTail) && loading);
|
|
const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen);
|
|
const outputScrollRef = useRef<HTMLDivElement>(null);
|
|
const isError = tone === 'error';
|
|
const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError);
|
|
|
|
// Auto-scroll assistant output
|
|
useEffect(() => {
|
|
if (liveOutputOpen && outputScrollRef.current) {
|
|
outputScrollRef.current.scrollTop = outputScrollRef.current.scrollHeight;
|
|
}
|
|
}, [assistantOutput, liveOutputOpen]);
|
|
|
|
// If parent changes the default (e.g. transitioning to "ready"), respect it.
|
|
useEffect(() => {
|
|
setLiveOutputOpen(defaultLiveOutputOpen);
|
|
}, [defaultLiveOutputOpen]);
|
|
|
|
// On error with logs available, prioritize logs view over noisy live stream payload.
|
|
useEffect(() => {
|
|
if (isError && cliLogsTail) {
|
|
setLogsOpen(true);
|
|
setLiveOutputOpen(false);
|
|
}
|
|
}, [isError, cliLogsTail]);
|
|
|
|
// Open CLI logs while loading, collapse when done (unless error).
|
|
const prevLoadingRef = useRef(loading);
|
|
const hadLogsRef = useRef(Boolean(cliLogsTail));
|
|
useEffect(() => {
|
|
if (!isError) {
|
|
const hasLogs = Boolean(cliLogsTail);
|
|
|
|
if (loading && hasLogs && !hadLogsRef.current) {
|
|
// Logs just appeared while loading → open
|
|
setLogsOpen(true);
|
|
} else if (loading && !prevLoadingRef.current && hasLogs) {
|
|
// Started loading with logs already present → open
|
|
setLogsOpen(true);
|
|
} else if (!loading && prevLoadingRef.current) {
|
|
// Finished loading → collapse
|
|
setLogsOpen(false);
|
|
}
|
|
|
|
hadLogsRef.current = hasLogs;
|
|
}
|
|
prevLoadingRef.current = loading;
|
|
}, [loading, cliLogsTail, isError]);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2',
|
|
isError && 'border-red-500/40 bg-red-500/10',
|
|
className
|
|
)}
|
|
>
|
|
{successMessage ? (
|
|
<div className="mb-1.5 flex items-center gap-2">
|
|
{successMessageSeverity === 'warning' ? (
|
|
<AlertTriangle size={14} className="shrink-0 text-amber-400" />
|
|
) : (
|
|
<CheckCircle2 size={14} className="shrink-0 text-[var(--step-done-text)]" />
|
|
)}
|
|
<p
|
|
className={cn(
|
|
'flex-1 text-xs',
|
|
successMessageSeverity === 'warning'
|
|
? 'text-amber-400'
|
|
: 'text-[var(--step-success-text)]'
|
|
)}
|
|
>
|
|
{successMessage}
|
|
</p>
|
|
{onDismiss ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="size-6 shrink-0 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
|
onClick={onDismiss}
|
|
>
|
|
<X size={12} />
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
) : onDismiss ? (
|
|
<div className="flex justify-end">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="size-6 shrink-0 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
|
onClick={onDismiss}
|
|
>
|
|
<X size={12} />
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
{loading ? (
|
|
<Loader2 className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]" />
|
|
) : null}
|
|
<p className="text-xs font-medium text-[var(--color-text)]">{title}</p>
|
|
{elapsed !== null ? (
|
|
<span className="text-[10px] tabular-nums text-[var(--color-text-muted)]">
|
|
{elapsed}
|
|
</span>
|
|
) : null}
|
|
{pid !== undefined ? (
|
|
<span className="text-[10px] text-[var(--color-text-muted)]">PID {pid}</span>
|
|
) : null}
|
|
</div>
|
|
{onCancel ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-6 shrink-0 px-2 text-xs"
|
|
onClick={onCancel}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
{message ? (
|
|
<p
|
|
className={cn(
|
|
'mt-1.5 text-xs',
|
|
isError || messageSeverity === 'error'
|
|
? 'text-red-400'
|
|
: messageSeverity === 'warning'
|
|
? 'text-amber-400'
|
|
: 'text-[var(--color-text-muted)]'
|
|
)}
|
|
>
|
|
{message}
|
|
</p>
|
|
) : null}
|
|
<div className="mt-2 px-2">
|
|
<StepProgressBar
|
|
steps={PROVISIONING_STEPS}
|
|
currentIndex={currentStepIndex}
|
|
errorIndex={errorStepIndex}
|
|
/>
|
|
</div>
|
|
<div className="mt-2">
|
|
<button
|
|
type="button"
|
|
className="flex items-center gap-1 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
|
onClick={() => setLiveOutputOpen((v) => !v)}
|
|
>
|
|
{liveOutputOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
|
Live output
|
|
</button>
|
|
{liveOutputOpen ? (
|
|
<div
|
|
ref={outputScrollRef}
|
|
className={cn(
|
|
'mt-1 max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2',
|
|
isError && 'border-red-500/40'
|
|
)}
|
|
>
|
|
{displayAssistantOutput ? (
|
|
<MarkdownViewer content={displayAssistantOutput} bare maxHeight="max-h-none" />
|
|
) : (
|
|
<p
|
|
className={cn(
|
|
'text-[11px]',
|
|
isError ? 'text-[var(--step-error-text-dim)]' : 'text-[var(--color-text-muted)]'
|
|
)}
|
|
>
|
|
No output captured yet.
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
{cliLogsTail ? (
|
|
<div className="mt-2">
|
|
<button
|
|
type="button"
|
|
className="flex items-center gap-1 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
|
onClick={() => setLogsOpen((v) => !v)}
|
|
>
|
|
{logsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
|
CLI logs
|
|
</button>
|
|
{logsOpen ? (
|
|
<CliLogsRichView cliLogsTail={cliLogsTail} order="newest-first" className="mt-1" />
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
};
|