proteinea / src /app /chat /_components /ToolRunPanel.tsx
Mahmoud Eljendy
feat: Antibody Studio — AI-native antibody design workspace by Proteinea
30cc31a
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { Artifact, Skill } from "@/lib/types";
import {
estimateToolCostAPI,
submitJobAPI,
getJobStatusAPI,
type CostEstimate,
} from "@/lib/api";
function detectMime(key: string): string {
if (key.endsWith('.pdb') || key.endsWith('.cif')) return 'chemical/x-pdb';
if (key.endsWith('.fasta') || key.endsWith('.fa')) return 'application/fasta';
if (key.endsWith('.csv')) return 'text/csv';
if (key.endsWith('.json')) return 'application/json';
return 'application/octet-stream';
}
type RunState = "configure" | "submitting" | "polling" | "completed" | "failed";
interface Props {
skill: Skill;
campaignId: string;
onComplete: (artifacts: Artifact[]) => void;
onClose: () => void;
}
export default function ToolRunPanel({
skill,
campaignId,
onComplete,
onClose,
}: Props) {
const [runState, setRunState] = useState<RunState>("configure");
const [estimate, setEstimate] = useState<CostEstimate | null>(null);
const [estimateError, setEstimateError] = useState<string | null>(null);
const [callParams, setCallParams] = useState("{}");
const [file, setFile] = useState<File | null>(null);
const [provider, setProvider] = useState<"runpod" | "slurm">("runpod");
const [error, setError] = useState<string | null>(null);
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
const [elapsed, setElapsed] = useState(0);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const jobIdRef = useRef<string | null>(null);
// Fetch cost estimate on mount
useEffect(() => {
let cancelled = false;
estimateToolCostAPI(skill.id, skill.gpuType)
.then((est) => {
if (!cancelled) setEstimate(est);
})
.catch((e) => {
if (!cancelled)
setEstimateError(e instanceof Error ? e.message : "estimate_failed");
});
return () => {
cancelled = true;
};
}, [skill.id, skill.gpuType]);
// Clean up intervals on unmount
useEffect(() => {
return () => {
if (pollRef.current) clearInterval(pollRef.current);
if (timerRef.current) clearInterval(timerRef.current);
};
}, []);
const startPolling = useCallback(
(jobId: string) => {
setRunState("polling");
setElapsed(0);
const start = Date.now();
timerRef.current = setInterval(() => {
setElapsed(Math.floor((Date.now() - start) / 1000));
}, 1000);
pollRef.current = setInterval(async () => {
try {
const status = await getJobStatusAPI(jobId);
const hubStatus = String(
status.hub?.status ?? "",
).toLowerCase();
if (hubStatus === "completed") {
if (pollRef.current) clearInterval(pollRef.current);
if (timerRef.current) clearInterval(timerRef.current);
// Map backend artifact shape to frontend Artifact type.
// Artifacts from Hub jobs are served by the backend, not the
// Next.js local artifact store. Proxy via the backend URL.
const hubOutputData =
status.hub?.output_data && typeof status.hub.output_data === "object"
? (status.hub.output_data as Record<string, unknown>)
: undefined;
const mapped: Artifact[] = (status.artifacts ?? []).map(
(a, i) => ({
id: a.id ?? `artifact-${i}`,
name: a.hub_output_key ?? `output-${i}`,
bytes: 0,
mime: detectMime(a.hub_output_key ?? ''),
url: `/api/jobs/${encodeURIComponent(jobId!)}/artifacts/${encodeURIComponent(a.id ?? "")}`,
createdAt: Date.now(),
hubOutputData,
}),
);
setArtifacts(mapped);
setRunState("completed");
onComplete(mapped);
} else if (
hubStatus === "failed" ||
hubStatus === "error" ||
hubStatus === "cancelled"
) {
if (pollRef.current) clearInterval(pollRef.current);
if (timerRef.current) clearInterval(timerRef.current);
setError(
String(
status.hub?.error ??
status.hub?.message ??
`Job ${hubStatus}`,
),
);
setRunState("failed");
}
} catch (e) {
// Don't stop polling on transient network errors
console.warn("Poll error:", e);
}
}, 3000);
},
[onComplete],
);
const handleRun = useCallback(async () => {
if (!file) {
setError("Please select an input file.");
return;
}
// Validate JSON
try {
JSON.parse(callParams);
} catch {
setError("call_params is not valid JSON.");
return;
}
setError(null);
setRunState("submitting");
try {
const formData = new FormData();
formData.set("endpoint_name", skill.id);
formData.set("provider", provider);
formData.set("call_params", callParams);
formData.set("input_file", file);
const result = await submitJobAPI(campaignId, formData);
jobIdRef.current = result.campaign_job_id;
startPolling(result.campaign_job_id);
} catch (e) {
setError(e instanceof Error ? e.message : "Submission failed");
setRunState("failed");
}
}, [file, callParams, skill.id, provider, campaignId, startPolling]);
const isRunning = runState === "submitting" || runState === "polling";
return (
<div
data-testid="tool-run-panel"
className="fixed inset-0 z-[70] flex justify-end bg-black/30 backdrop-blur-sm"
onClick={onClose}
>
<div
className="w-[560px] max-w-[96vw] h-full bg-white border-l border-border shadow-xl flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-4 py-3 border-b border-border flex items-start gap-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-foreground truncate">
Run {skill.name}
</p>
<p className="text-[10px] text-muted-fg font-mono truncate">
{skill.id}
</p>
</div>
<button
onClick={onClose}
className="text-[11px] text-muted-fg hover:text-foreground"
aria-label="Close"
>
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
{/* Description */}
{skill.description && (
<p className="text-[11px] text-foreground leading-relaxed">
{skill.description}
</p>
)}
{/* Cost estimate */}
<section>
<h3 className="text-[10px] uppercase tracking-wider text-muted-fg font-semibold mb-1">
Cost Estimate
</h3>
{estimate ? (
<div className="grid grid-cols-3 gap-2 text-[11px]">
<div>
<p className="text-[10px] text-muted-fg">Cost</p>
<p
data-testid="estimate-cost"
className={`font-medium ${estimate.cost_alert ? "text-red-600" : "text-foreground"}`}
>
${estimate.estimated_usd.toFixed(2)}
</p>
</div>
<div>
<p className="text-[10px] text-muted-fg">GPU</p>
<p className="text-foreground">{estimate.gpu_type || "CPU"}</p>
</div>
<div>
<p className="text-[10px] text-muted-fg">Est. Time</p>
<p className="text-foreground">
{estimate.minutes_used.toFixed(1)} min
</p>
</div>
</div>
) : estimateError ? (
<p className="text-[11px] text-muted-fg">
Estimate unavailable
</p>
) : (
<p className="text-[11px] text-muted-fg">Loading estimate...</p>
)}
</section>
{/* Configuration (only visible before run) */}
{runState === "configure" && (
<>
{/* Provider */}
<section>
<h3 className="text-[10px] uppercase tracking-wider text-muted-fg font-semibold mb-1">
Provider
</h3>
<div className="flex gap-2">
{(["runpod", "slurm"] as const).map((p) => (
<button
key={p}
onClick={() => setProvider(p)}
className={`text-[11px] px-3 py-1.5 rounded-md border transition-colors ${
provider === p
? "border-accent bg-accent/10 text-accent font-medium"
: "border-border text-muted-fg hover:border-accent/30"
}`}
>
{p}
</button>
))}
</div>
</section>
{/* Input file */}
<section>
<h3 className="text-[10px] uppercase tracking-wider text-muted-fg font-semibold mb-1">
Input File
</h3>
<input
type="file"
data-testid="tool-run-file-input"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
className="text-[11px] text-foreground file:mr-3 file:px-3 file:py-1.5 file:rounded-md file:border file:border-border file:bg-muted file:text-[11px] file:text-foreground file:cursor-pointer"
/>
</section>
{/* Call params */}
<section>
<h3 className="text-[10px] uppercase tracking-wider text-muted-fg font-semibold mb-1">
Parameters (JSON)
</h3>
<textarea
data-testid="tool-run-params"
value={callParams}
onChange={(e) => setCallParams(e.target.value)}
rows={4}
className="w-full rounded-md border border-border bg-white px-3 py-2 text-xs font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 resize-y"
placeholder='{"key": "value"}'
/>
</section>
</>
)}
{/* Running state */}
{isRunning && (
<section data-testid="tool-run-polling" className="py-6 text-center space-y-3">
<div className="w-8 h-8 mx-auto rounded-full border-2 border-accent border-t-transparent animate-spin" />
<p className="text-xs text-foreground font-medium">
{runState === "submitting"
? "Submitting..."
: `Running on ${provider}...`}
</p>
{runState === "polling" && (
<p className="text-[11px] text-muted-fg">
Elapsed: {elapsed}s
</p>
)}
</section>
)}
{/* Completed */}
{runState === "completed" && (
<section data-testid="tool-run-completed" className="space-y-2">
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded-full bg-success/10 flex items-center justify-center">
<svg
className="w-3 h-3 text-success"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
>
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<p className="text-xs font-medium text-foreground">
Completed
</p>
</div>
{artifacts.length > 0 && (
<ul className="space-y-1 pl-7">
{artifacts.map((a) => (
<li
key={a.id}
className="text-[11px] text-foreground font-mono"
>
{a.name}
</li>
))}
</ul>
)}
</section>
)}
{/* Error */}
{error && (
<div
data-testid="tool-run-error"
className="rounded-md border border-red-200 bg-red-50 px-3 py-2"
>
<p className="text-[11px] text-red-700">{error}</p>
</div>
)}
</div>
{/* Footer */}
<div className="px-4 py-3 border-t border-border flex justify-end gap-2">
{runState === "configure" && (
<button
data-testid="tool-run-button"
onClick={handleRun}
disabled={!file}
className="px-4 py-2 rounded-lg bg-accent text-white text-xs font-medium hover:bg-accent-hover disabled:opacity-40 transition-colors"
>
Run
</button>
)}
{(runState === "completed" || runState === "failed") && (
<button
onClick={onClose}
className="px-4 py-2 rounded-lg bg-accent text-white text-xs font-medium hover:bg-accent-hover transition-colors"
>
Done
</button>
)}
</div>
</div>
</div>
);
}