cpns / apps /web /src /components /generate /GlobalGenerationProgress.tsx
pramudya-yb
Rename project from Labas to Pram
2b62b0c
Raw
History Blame Contribute Delete
10.5 kB
import { useState, useRef, useEffect } from "react";
import { useRouterState, Link } from "@tanstack/react-router";
import { useMutation } from "@tanstack/react-query";
import { MaterialIcon } from "@/components/ui/MaterialIcon";
import { useGenerationJobs, type ActiveJob } from "@/hooks/use-generation-jobs";
import { isTerminal } from "@/hooks/use-job-shared";
import { trpc, queryClient } from "@/utils/trpc";
interface LogEntry {
step: string;
message: string;
status: "running" | "done" | "error";
timestamp: string;
details?: string;
}
const STEP_LABELS: Record<string, string> = {
generate_passage: "Menulis Bacaan",
validate_passage: "Memvalidasi Bacaan",
generate_questions: "Membuat Soal",
self_validate: "Self-Check Kualitas",
save: "Menyimpan Hasil",
cancel: "Dibatalkan",
error: "Error",
};
function LogStatusIcon({ status }: { status: string }) {
if (status === "done")
return (
<MaterialIcon
name="check_circle"
className="text-[var(--matcha-400)] text-xs shrink-0"
/>
);
if (status === "error")
return (
<MaterialIcon
name="error"
className="text-[var(--pomegranate-400)] text-xs shrink-0"
/>
);
return (
<MaterialIcon
name="sync"
className="text-[var(--matcha-400)] text-xs shrink-0 animate-spin"
/>
);
}
function TerminalLog({ logs, isRunning }: { logs: LogEntry[]; isRunning: boolean }) {
const scrollRef = useRef<HTMLDivElement>(null);
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs]);
if (!logs?.length) {
return (
<div className="mt-2 rounded-lg bg-black/40 p-2 border border-white/5">
<div className="flex items-center gap-2 text-white/40 text-[10px] font-mono">
<MaterialIcon name="psychology" className="text-[10px] animate-pulse" />
AI sedang mempersiapkan...
</div>
</div>
);
}
return (
<div
ref={scrollRef}
className="mt-2 max-h-32 overflow-y-auto rounded-lg bg-black/40 p-2 font-mono text-[10px] leading-relaxed border border-white/5"
>
{logs.map((log, i) => {
const isExpanded = expandedIdx === i;
const hasDetails = !!log.details && log.details.length > 0;
const isLast = i === logs.length - 1;
const isCurrentRunning = isLast && log.status === "running" && isRunning;
return (
<div key={i} className="flex flex-col">
<div
className={`flex items-start gap-1.5 py-0.5 ${hasDetails ? "cursor-pointer hover:bg-white/5 rounded px-1 -mx-1 transition-colors" : ""}`}
onClick={() => hasDetails && setExpandedIdx(isExpanded ? null : i)}
>
<LogStatusIcon status={log.status} />
<span className="text-white/30 shrink-0">
{new Date(log.timestamp).toLocaleTimeString("id-ID", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
<span
className={`font-semibold truncate ${
log.status === "error"
? "text-[var(--pomegranate-400)]"
: log.status === "done"
? "text-[var(--matcha-400)]"
: "text-white/80"
}`}
>
{STEP_LABELS[log.step] ?? log.step}
</span>
{isCurrentRunning && (
<span className="w-1 h-1 bg-[var(--matcha-400)] rounded-full animate-pulse shrink-0 mt-1" />
)}
</div>
{isExpanded && hasDetails && (
<div className="ml-5 mt-0.5 mb-1 p-2 rounded bg-black/60 border border-white/10 text-white/70 whitespace-pre-wrap text-[9px] leading-relaxed max-h-24 overflow-y-auto">
{log.details}
</div>
)}
</div>
);
})}
</div>
);
}
function SingleJobCard({
job,
onCancel,
cancelPending,
}: {
job: ActiveJob;
onCancel: (jobId: string) => void;
cancelPending: boolean;
}) {
const [minimized, setMinimized] = useState(false);
const progress = job.progress ?? 0;
const message = job.progressMessage ?? "Sedang berjalan...";
const logs = (job.logs ?? []) as LogEntry[];
const isAgentic = job.mode === "agentic";
if (minimized) {
return (
<button
onClick={() => setMinimized(false)}
className="flex items-center gap-2 py-1.5 px-3 bg-[var(--clay-black)]/90 rounded-lg text-white text-xs font-semibold border border-[var(--matcha-400)]/30 hover:border-[var(--matcha-400)]/60 transition-all w-full"
>
<MaterialIcon
name="psychology"
className="text-[var(--matcha-400)] animate-pulse text-xs"
/>
<span className="truncate flex-1 text-left">{message}</span>
<span className="tabular-nums text-[var(--matcha-400)]">{progress}%</span>
</button>
);
}
return (
<div className="rounded-xl bg-[var(--clay-black)] border border-white/10 p-3 transition-all">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 min-w-0">
<MaterialIcon
name="psychology"
className="text-[var(--matcha-400)] animate-pulse text-sm shrink-0"
/>
<div className="min-w-0">
<span className="text-xs font-semibold block truncate">{message}</span>
{isAgentic && (
<span className="text-[10px] text-[var(--matcha-400)]">Agentic</span>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => setMinimized(true)}
className="w-6 h-6 flex items-center justify-center rounded-full hover:bg-white/10 text-white/50 hover:text-white transition-colors"
>
<MaterialIcon name="expand_less" className="text-xs" />
</button>
<button
onClick={() => onCancel(job.id)}
disabled={cancelPending}
className="w-6 h-6 flex items-center justify-center rounded-full hover:bg-[var(--pomegranate-400)]/20 text-white/50 hover:text-[var(--pomegranate-400)] transition-colors disabled:opacity-30"
>
<MaterialIcon name="close" className="text-xs" />
</button>
</div>
</div>
<div className="mb-2">
<div className="flex justify-between items-center mb-1">
<span className="tabular-nums text-xs font-bold text-[var(--matcha-400)]">
{progress}%
</span>
</div>
<div className="w-full h-1.5 bg-white/20 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-[var(--matcha-600)] to-[var(--matcha-400)] transition-all duration-500 rounded-full"
style={{ width: `${progress}%` }}
/>
</div>
</div>
{isAgentic && <TerminalLog logs={logs} isRunning={!isTerminal(job.status)} />}
</div>
);
}
export function GlobalGenerationProgress() {
const { activeJobs, isGenerating, removeJob, setError } = useGenerationJobs();
const [collapsed, setCollapsed] = useState(false);
const matches = useRouterState({ select: (s) => s.matches });
const isGeneratePage = matches.some((m) => m.routeId === "/generate");
const isTakePage = matches.some((m) => m.routeId === "/package/$id/take");
const cancelMutation = useMutation({
...trpc.ai.cancelJob.mutationOptions(),
onSuccess: async (_data, variables) => {
removeJob(variables.jobId);
setError(null);
await queryClient.invalidateQueries({
queryKey: trpc.ai.myJobs.queryKey(),
});
},
});
if (!isGenerating || isTakePage) return null;
const handleCancel = (jobId: string) => {
cancelMutation.mutate({ jobId });
};
// Collapsed floating pill showing count
if (collapsed) {
return (
<button
onClick={() => setCollapsed(false)}
className="fixed bottom-8 right-6 z-50 h-11 pl-4 pr-5 bg-[var(--clay-black)] rounded-full flex items-center gap-2.5 text-white text-sm font-semibold shadow-2xl border border-[var(--matcha-400)]/30 hover:border-[var(--matcha-400)]/60 transition-all"
>
<MaterialIcon
name="psychology"
className="text-[var(--matcha-400)] animate-pulse text-sm"
/>
<span>{activeJobs.length} active</span>
</button>
);
}
return (
<div
className={`fixed bottom-8 right-6 z-50 rounded-2xl shadow-2xl border p-4 text-white transition-all ${
isGeneratePage
? "w-80 sm:w-[26rem] ring-2 ring-[var(--matcha-400)]/40 border-[var(--matcha-400)]/30 bg-[var(--clay-black)]"
: "w-72 sm:w-80 border-white/10 bg-[var(--clay-black)]"
}`}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<MaterialIcon
name="psychology"
className="text-[var(--matcha-400)] animate-pulse text-base"
/>
<span className="text-sm font-semibold">
AI Generation{activeJobs.length > 1 ? ` (${activeJobs.length})` : ""}
</span>
</div>
<div className="flex items-center gap-1">
<Link
to="/generate"
className="w-7 h-7 flex items-center justify-center rounded-full hover:bg-white/10 text-white/60 hover:text-white transition-colors"
title="Buka halaman generate"
>
<MaterialIcon name="open_in_new" className="text-sm" />
</Link>
<button
onClick={() => setCollapsed(true)}
className="w-7 h-7 flex items-center justify-center rounded-full hover:bg-white/10 text-white/60 hover:text-white transition-colors"
title="Minimize"
>
<MaterialIcon name="expand_more" className="text-sm" />
</button>
</div>
</div>
<div className="flex flex-col gap-2 max-h-[60vh] overflow-y-auto">
{activeJobs.map((job) => (
<SingleJobCard
key={job.id}
job={job}
onCancel={handleCancel}
cancelPending={cancelMutation.isPending}
/>
))}
</div>
</div>
);
}