import { Plural, Trans, useLingui } from "@lingui/react/macro"; import { ChatMarkdown } from "@rakazo/chat-ui/web"; import type { ThreadMessage } from "@rakazo/contracts"; import { BotAvatar, cn } from "@rakazo/ui-web"; import { CheckCircle2, ChevronDown, ChevronRight, Clock, FileCode, GitBranch, Globe, PauseCircle, RotateCcw, Terminal, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { Shimmer } from "../../components/ai/primitives"; import { FALLBACK_BOT_COLOR } from "./shell-types"; export function detectStepMeta(step: ThreadMessage) { const fullText = step.blocks .filter((b): b is { kind: "text"; text: string } => b.kind === "text") .map((b) => b.text) .join(" ") .trim() .toLowerCase(); if ( /^running:\s*git\b/.test(fullText) || /\b(?:git\s+(?:status|diff|log|commit|push|pull|checkout|branch|clone|fetch|merge|rebase)|commit|branch|repo)\b/.test( fullText, ) ) { return { label: "Git", icon: , type: "git", }; } if ( fullText.startsWith("running:") || fullText.startsWith("$") || /\b(?:shell|terminal|bash|command|pnpm|npm|pytest|vitest|cargo)\b/.test(fullText) || /\brun\b/.test(fullText) ) { return { label: "Shell", icon: , type: "shell", }; } if ( fullText.startsWith("reading ") || fullText.startsWith("writing ") || fullText.startsWith("listing ") || fullText.startsWith("attaching ") || /\b(?:file|read|write|package\.json)\b/.test(fullText) || /\.(?:ts|tsx|js|jsx|py|md|json|yaml|yml|css|html)\b/.test(fullText) ) { return { label: "File", icon: , type: "file", }; } if ( fullText.startsWith("searching the web:") || fullText.startsWith("reading page:") || fullText.startsWith("opening page:") || /\b(?:search|web|browser|https?:\/\/|page)\b/.test(fullText) ) { return { label: "Web", icon: , type: "web", }; } return { label: "Step", icon: , type: "default", }; } function useLiveElapsed(startedAtMs?: number, active = false): string { const [now, setNow] = useState(() => Date.now()); useEffect(() => { if (!active || !startedAtMs) return; const interval = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(interval); }, [active, startedAtMs]); if (!startedAtMs) return ""; const sec = Math.max(0, Math.floor((now - startedAtMs) / 1000)); const m = Math.floor(sec / 60); const s = sec % 60; return `${m}:${s.toString().padStart(2, "0")}`; } export interface JobProgressCardProps { bot?: { id?: string; name?: string; color?: string; role?: string; title?: string; status?: string; } | null; taskTitle?: string | null; steps?: readonly ThreadMessage[]; running?: boolean; status?: "running" | "completed" | "interrupted" | "waiting"; activeAction?: string | null; startedAt?: number; defaultOpen?: boolean; onRestart?: () => void | Promise; restarting?: boolean; } /** Comprehensive, structured Progress Card for bot tasks and execution runs. */ export function JobProgressCard({ bot, taskTitle, steps = [], running = false, status = running ? "running" : "completed", activeAction, startedAt, defaultOpen = false, onRestart, restarting = false, }: JobProgressCardProps) { const { t } = useLingui(); const [open, setOpen] = useState(defaultOpen); const calculatedStartedAt = useMemo(() => { if (startedAt) return startedAt; if (steps.length > 0) { const first = new Date(steps[0]?.createdAt ?? "").getTime(); if (!Number.isNaN(first)) return first; } return undefined; }, [startedAt, steps]); const liveElapsed = useLiveElapsed(calculatedStartedAt, running); const { durationText, previewBadges } = useMemo(() => { let durText: string | null = null; if (steps.length >= 2) { const first = new Date(steps[0]?.createdAt ?? "").getTime(); const last = new Date(steps[steps.length - 1]?.createdAt ?? "").getTime(); if (!Number.isNaN(first) && !Number.isNaN(last) && last >= first) { const diffSec = Math.round((last - first) / 1000); if (diffSec > 0) { durText = `${diffSec}s`; } } } const seen = new Set(); const badges: { label: string; icon: React.ReactNode; count: number }[] = []; const counts = new Map(); for (const step of steps) { const meta = detectStepMeta(step); counts.set(meta.type, (counts.get(meta.type) ?? 0) + 1); if (meta.type !== "default" && !seen.has(meta.type)) { seen.add(meta.type); badges.push({ label: meta.label, icon: meta.icon, count: 1 }); } } for (const badge of badges) { badge.count = counts.get(badge.label.toLowerCase()) ?? 1; } return { durationText: durText, previewBadges: badges }; }, [steps]); const latestFinding = useMemo(() => { if (steps.length === 0) return null; for (let i = steps.length - 1; i >= 0; i--) { const s = steps[i]; const text = s?.blocks .filter((b): b is { kind: "text"; text: string } => b.kind === "text") .map((b) => b.text) .join(" ") .trim(); if (text && text.length > 10) { return text; } } return null; }, [steps]); const botName = bot?.name || "Steve Jobs"; const botRole = bot?.title || bot?.role || t`Agent`; const isInterrupted = status === "interrupted"; const stepCount = steps.length; const stepMoment = useMemo(() => { if (status === "waiting") return "waiting" as const; if (isInterrupted) return "concerned" as const; if (!running) return undefined; const lastStep = steps.at(-1); if (!lastStep) return "thinking" as const; const meta = detectStepMeta(lastStep); if (meta.type === "file" || meta.type === "web") return "reading" as const; return "working" as const; }, [status, isInterrupted, running, steps]); return (
{/* Top Header: Bot info + Live Status Badge */}
{running ? ( ) : isInterrupted ? ( ) : ( )}
{botName} {botRole}
{/* Right Status Badge */}
{running ? ( {t`In Bearbeitung`} {liveElapsed ? · {liveElapsed} : null} ) : isInterrupted ? (
{t`Unterbrochen`} {durationText ? ( · {durationText} ) : null} {onRestart ? ( ) : null}
) : ( {t`Abgeschlossen`} {durationText ? ( · {durationText} ) : null} )}
{/* Task / Objective Brief (only while running or interrupted to avoid repeating user bubble above completed answers) */} {taskTitle && status !== "completed" ? (
Aufgabe {taskTitle}
) : null} {/* Live Action or Latest Finding Preview */} {running ? (
{stepCount > 0 ? ( Schritt {stepCount + 1} ) : ( Initialisiere… )} {activeAction || t`Analysiert die Aufgabe und führt Tools aus…`}
{/* Subtle animated activity track */}
) : latestFinding && !open ? (
Letzter Stand: {latestFinding}
) : null} {/* Tools badges + Accordion toggle */}
{previewBadges.map((badge, idx) => ( {badge.icon} {badge.label} {badge.count > 1 ? ({badge.count}) : null} ))} {previewBadges.length === 0 && stepCount > 0 ? ( ) : null}
{stepCount > 0 ? ( ) : null}
{/* Expanded Step Timeline */} {open && stepCount > 0 ? (
    {steps.map((step, stepIndex) => { const meta = detectStepMeta(step); return (
  1. {String(stepIndex + 1).padStart(2, "0")} {meta.icon}
    {step.blocks.map((block, index) => block.kind === "text" ? (
    {block.text}
    ) : null, )}
  2. ); })}
) : null}
); } /** A run's earlier step messages, folded behind an interactive milestone card. */ export function RunSteps({ steps, bot, taskTitle, status = "completed", onRestart, restarting = false, }: { steps: readonly ThreadMessage[]; bot?: JobProgressCardProps["bot"]; taskTitle?: string | null; status?: "running" | "completed" | "interrupted" | "waiting"; onRestart?: () => void | Promise; restarting?: boolean; }) { return (
); }