459 lines
16 KiB
TypeScript
459 lines
16 KiB
TypeScript
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: <GitBranch className="size-3.5 shrink-0" />,
|
|
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: <Terminal className="size-3.5 shrink-0" />,
|
|
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: <FileCode className="size-3.5 shrink-0" />,
|
|
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: <Globe className="size-3.5 shrink-0" />,
|
|
type: "web",
|
|
};
|
|
}
|
|
return {
|
|
label: "Step",
|
|
icon: <CheckCircle2 className="size-3.5 shrink-0" />,
|
|
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<void>;
|
|
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<string>();
|
|
const badges: { label: string; icon: React.ReactNode; count: number }[] = [];
|
|
const counts = new Map<string, number>();
|
|
|
|
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 (
|
|
<div
|
|
data-testid="job-progress-card"
|
|
data-run-status={status}
|
|
className={cn(
|
|
"my-2.5 w-full max-w-[660px] rounded-2xl border p-4 shadow-2xs transition-all",
|
|
running
|
|
? "border-primary/40 bg-card/90 ring-1 ring-primary/20 shadow-xs"
|
|
: "border-border/80 bg-card/75 hover:border-border hover:shadow-xs",
|
|
)}
|
|
>
|
|
{/* Top Header: Bot info + Live Status Badge */}
|
|
<div className="flex items-center justify-between gap-2.5 flex-wrap">
|
|
<div className="flex items-center gap-2.5 shrink-0">
|
|
<div className="relative shrink-0">
|
|
<BotAvatar
|
|
color={bot?.color ?? FALLBACK_BOT_COLOR}
|
|
identity={bot?.id ?? "bot"}
|
|
size={32}
|
|
status={running ? "running" : isInterrupted ? "failed" : "completed"}
|
|
moment={stepMoment}
|
|
/>
|
|
{running ? (
|
|
<span className="absolute -bottom-0.5 -right-0.5 size-2 rounded-full bg-warning animate-pulse" />
|
|
) : isInterrupted ? (
|
|
<span className="absolute -bottom-0.5 -right-0.5 size-2 rounded-full bg-muted-foreground/80" />
|
|
) : (
|
|
<span className="absolute -bottom-0.5 -right-0.5 size-2 rounded-full bg-success" />
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<span className="shrink-0 text-ui-body font-semibold text-foreground" dir="auto">
|
|
{botName}
|
|
</span>
|
|
<span className="shrink-0 rounded-full border border-border/60 bg-muted/50 px-2 py-0.5 text-ui-label font-medium text-muted-foreground">
|
|
{botRole}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Status Badge */}
|
|
<div className="shrink-0 flex items-center gap-1.5">
|
|
{running ? (
|
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-warning/30 bg-warning/10 px-2.5 py-0.5 text-ui-label font-medium text-warning">
|
|
<span className="size-1.5 rounded-full bg-warning animate-pulse" />
|
|
<Shimmer>{t`In Bearbeitung`}</Shimmer>
|
|
{liveElapsed ? <span className="font-mono tabular-nums">· {liveElapsed}</span> : null}
|
|
</span>
|
|
) : isInterrupted ? (
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-muted/60 px-2.5 py-0.5 text-ui-label font-medium text-muted-foreground">
|
|
<PauseCircle className="size-3 text-muted-foreground" />
|
|
<span>{t`Unterbrochen`}</span>
|
|
{durationText ? (
|
|
<span className="font-mono tabular-nums">· {durationText}</span>
|
|
) : null}
|
|
</span>
|
|
{onRestart ? (
|
|
<button
|
|
type="button"
|
|
data-testid="restart-task-button"
|
|
disabled={restarting || running}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
void onRestart();
|
|
}}
|
|
className="inline-flex items-center gap-1 rounded-full border border-primary/30 bg-primary/10 hover:bg-primary/20 text-primary px-2.5 py-0.5 text-ui-label font-medium transition-all cursor-pointer active:scale-95 disabled:opacity-50"
|
|
title={t`Aufgabe neu starten`}
|
|
>
|
|
<RotateCcw className={cn("size-3", restarting && "animate-spin")} />
|
|
<span>{t`Neustarten`}</span>
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-success/30 bg-success/10 px-2.5 py-0.5 text-ui-label font-medium text-success">
|
|
<CheckCircle2 className="size-3 text-success" />
|
|
<span>{t`Abgeschlossen`}</span>
|
|
{durationText ? (
|
|
<span className="font-mono tabular-nums">· {durationText}</span>
|
|
) : null}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Task / Objective Brief (only while running or interrupted to avoid repeating user bubble above completed answers) */}
|
|
{taskTitle && status !== "completed" ? (
|
|
<div
|
|
dir="auto"
|
|
className="mt-3 rounded-xl border border-border/50 bg-muted/25 px-3 py-2 text-ui-body-sm text-foreground/85 font-medium leading-relaxed line-clamp-2"
|
|
>
|
|
<span className="text-muted-foreground font-normal text-ui-label uppercase tracking-wider block mb-0.5">
|
|
<Trans>Aufgabe</Trans>
|
|
</span>
|
|
{taskTitle}
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Live Action or Latest Finding Preview */}
|
|
{running ? (
|
|
<div className="mt-3 space-y-2">
|
|
<div className="flex items-center justify-between gap-2 text-ui-body-sm">
|
|
<span className="font-medium text-foreground flex items-center gap-1.5">
|
|
{stepCount > 0 ? (
|
|
<span>
|
|
<Trans>Schritt {stepCount + 1}</Trans>
|
|
</span>
|
|
) : (
|
|
<span>
|
|
<Trans>Initialisiere…</Trans>
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className="truncate text-ui-body-sm text-foreground/75 font-sans" dir="auto">
|
|
{activeAction || t`Analysiert die Aufgabe und führt Tools aus…`}
|
|
</span>
|
|
</div>
|
|
{/* Subtle animated activity track */}
|
|
<div className="h-1 w-full overflow-hidden rounded-full bg-muted/60">
|
|
<div className="h-full w-2/3 animate-[bui-shimmer-sweep_2s_infinite] rounded-full bg-primary/70" />
|
|
</div>
|
|
</div>
|
|
) : latestFinding && !open ? (
|
|
<div
|
|
className="mt-2.5 rounded-lg border border-border/40 bg-background/50 px-3 py-2 text-ui-body-sm text-foreground/80 line-clamp-2"
|
|
dir="auto"
|
|
>
|
|
<span className="text-ui-label font-semibold text-muted-foreground mr-1.5">
|
|
<Trans>Letzter Stand:</Trans>
|
|
</span>
|
|
{latestFinding}
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Tools badges + Accordion toggle */}
|
|
<div className="mt-3 pt-2.5 border-t border-border/40 flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
{previewBadges.map((badge, idx) => (
|
|
<span
|
|
key={idx}
|
|
className="inline-flex items-center gap-1 rounded-md bg-muted/60 px-2 py-0.5 font-mono text-xs text-muted-foreground border border-border/40"
|
|
>
|
|
{badge.icon}
|
|
<span>{badge.label}</span>
|
|
{badge.count > 1 ? <span className="opacity-70">({badge.count})</span> : null}
|
|
</span>
|
|
))}
|
|
{previewBadges.length === 0 && stepCount > 0 ? (
|
|
<span className="inline-flex items-center gap-1 rounded-md bg-muted/60 px-2 py-0.5 font-mono text-xs text-muted-foreground border border-border/40">
|
|
<CheckCircle2 className="size-3" />
|
|
<span>
|
|
<Plural value={stepCount} one="# step" other="# steps" />
|
|
</span>
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
|
|
{stepCount > 0 ? (
|
|
<button
|
|
type="button"
|
|
aria-expanded={open}
|
|
onClick={() => setOpen((current) => !current)}
|
|
className="inline-flex items-center gap-1 text-ui-label font-medium text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
|
|
>
|
|
<ChevronRight
|
|
aria-hidden
|
|
className={cn("size-3.5 transition-transform duration-200", open && "rotate-90")}
|
|
/>
|
|
<span>
|
|
<Plural value={stepCount} one="# step" other="# steps" />
|
|
</span>
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Expanded Step Timeline */}
|
|
{open && stepCount > 0 ? (
|
|
<div className="mt-3 rounded-xl border border-border/60 bg-background/70 backdrop-blur-xs p-3 shadow-2xs animate-in fade-in-50 duration-150">
|
|
<ol className="space-y-3">
|
|
{steps.map((step, stepIndex) => {
|
|
const meta = detectStepMeta(step);
|
|
return (
|
|
<li
|
|
key={step.id}
|
|
data-message-id={step.id}
|
|
dir="auto"
|
|
className="flex items-start gap-2.5 text-ui-body-sm"
|
|
>
|
|
<div className="flex items-center gap-1.5 shrink-0 pt-0.5">
|
|
<span className="inline-flex items-center justify-center size-5 rounded-md bg-muted text-xs font-mono text-muted-foreground font-semibold border border-border/50">
|
|
{String(stepIndex + 1).padStart(2, "0")}
|
|
</span>
|
|
<span className="text-muted-foreground" title={meta.label}>
|
|
{meta.icon}
|
|
</span>
|
|
</div>
|
|
<div className="min-w-0 flex-1 space-y-1">
|
|
{step.blocks.map((block, index) =>
|
|
block.kind === "text" ? (
|
|
<div key={index} className="text-foreground/90 leading-relaxed font-sans">
|
|
<ChatMarkdown>{block.text}</ChatMarkdown>
|
|
</div>
|
|
) : null,
|
|
)}
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 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<void>;
|
|
restarting?: boolean;
|
|
}) {
|
|
return (
|
|
<div data-testid="run-steps" className="mb-2">
|
|
<JobProgressCard
|
|
steps={steps}
|
|
bot={bot}
|
|
taskTitle={taskTitle}
|
|
running={status === "running"}
|
|
status={status}
|
|
defaultOpen={status === "running" || status === "interrupted"}
|
|
onRestart={onRestart}
|
|
restarting={restarting}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|