evOo-local/apps/web/src/pages/shell/run-steps.tsx

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>
);
}