proteinea / src /app /chat /_components /DesignIterationPanel.tsx
Mahmoud Eljendy
feat: Antibody Studio — AI-native antibody design workspace by Proteinea
30cc31a
// DesignIterationPanel.tsx — Multi-file design iteration UI.
// Renders when an assistant message contains a CSV artifact with design-like
// columns (iPAE, pLDDT, affinity, consensus_score, etc.). Provides sortable
// columns, row selection, threshold filters, and re-submit actions that send
// a new user message into the chat.
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { parseCsv, detectNumericColumns, isDesignCsv, detectDelimiter } from "@/lib/csv-utils";
import type { ParsedCsv } from "@/lib/csv-utils";
import type { Artifact } from "@/lib/types";
// ---- Column scoring metadata ----
/** Columns where LOWER is better (e.g., iPAE < 8). */
const LOWER_IS_BETTER = new Set(["ipae", "interaction_pae"]);
/** Columns where HIGHER is better (e.g., pLDDT > 85). */
const HIGHER_IS_BETTER = new Set(["plddt", "pred_lddt", "affinity", "affinity_pm", "consensus_score"]);
type SortDir = "asc" | "desc";
interface Props {
artifact: Artifact;
/** Called with a message to send as the next user turn. */
onSendMessage: (msg: string) => void;
}
export default function DesignIterationPanel({ artifact, onSendMessage }: Props) {
const [raw, setRaw] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Sort state
const [sortCol, setSortCol] = useState<number | null>(null);
const [sortDir, setSortDir] = useState<SortDir>("asc");
// Selection state
const [selected, setSelected] = useState<Set<number>>(new Set());
// Filter thresholds
const [ipaeMax, setIpaeMax] = useState<number>(10);
const [plddtMin, setPlddtMin] = useState<number>(80);
const [filtersActive, setFiltersActive] = useState(false);
// Top-N re-submit count
const [topN, setTopN] = useState(5);
const delim = useMemo(() => detectDelimiter(artifact.name, artifact.url), [artifact]);
useEffect(() => {
let cancelled = false;
setRaw(null);
setError(null);
fetch(artifact.url)
.then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.text(); })
.then((t) => { if (!cancelled) setRaw(t); })
.catch((e) => { if (!cancelled) setError(e.message || String(e)); });
return () => { cancelled = true; };
}, [artifact.url]);
const parsed = useMemo<ParsedCsv | null>(() => {
if (raw == null) return null;
try {
const p = parseCsv(raw, delim);
if (p.header.length === 0) return null;
return p;
} catch { return null; }
}, [raw, delim]);
const numericCols = useMemo(
() => (parsed ? detectNumericColumns(parsed.header, parsed.rows) : []),
[parsed],
);
// Column indices for design metrics
const colMap = useMemo(() => {
if (!parsed) return {} as Record<string, number>;
const map: Record<string, number> = {};
parsed.header.forEach((h, i) => {
const key = h.toLowerCase().replace(/[^a-z0-9_]/g, "_");
map[key] = i;
});
return map;
}, [parsed]);
const ipaeCol = colMap["ipae"] ?? colMap["interaction_pae"] ?? -1;
const plddtCol = colMap["plddt"] ?? colMap["pred_lddt"] ?? -1;
// Filtered rows (apply threshold filters when active)
const filteredRows = useMemo(() => {
if (!parsed) return [];
let rows = parsed.rows.map((r, origIdx) => ({ row: r, origIdx }));
if (filtersActive) {
rows = rows.filter(({ row }) => {
if (ipaeCol >= 0) {
const v = parseFloat(row[ipaeCol] ?? "");
if (!isNaN(v) && v > ipaeMax) return false;
}
if (plddtCol >= 0) {
const v = parseFloat(row[plddtCol] ?? "");
if (!isNaN(v) && v < plddtMin) return false;
}
return true;
});
}
// Apply sort
if (sortCol !== null && numericCols[sortCol]) {
rows.sort((a, b) => {
const va = parseFloat(a.row[sortCol] ?? "");
const vb = parseFloat(b.row[sortCol] ?? "");
if (isNaN(va) && isNaN(vb)) return 0;
if (isNaN(va)) return 1;
if (isNaN(vb)) return -1;
return sortDir === "asc" ? va - vb : vb - va;
});
} else if (sortCol !== null) {
rows.sort((a, b) => {
const va = a.row[sortCol] ?? "";
const vb = b.row[sortCol] ?? "";
return sortDir === "asc" ? va.localeCompare(vb) : vb.localeCompare(va);
});
}
return rows;
}, [parsed, filtersActive, ipaeMax, plddtMin, ipaeCol, plddtCol, sortCol, sortDir, numericCols]);
const handleSort = useCallback((colIdx: number) => {
if (sortCol === colIdx) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortCol(colIdx);
// Default sort direction: lower-is-better → asc, higher-is-better → desc
const key = parsed?.header[colIdx]?.toLowerCase().replace(/[^a-z0-9_]/g, "_") ?? "";
if (LOWER_IS_BETTER.has(key)) setSortDir("asc");
else if (HIGHER_IS_BETTER.has(key)) setSortDir("desc");
else setSortDir("asc");
}
}, [sortCol, parsed]);
const toggleSelect = useCallback((origIdx: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(origIdx)) next.delete(origIdx);
else next.add(origIdx);
return next;
});
}, []);
const toggleSelectAll = useCallback(() => {
if (selected.size === filteredRows.length) {
setSelected(new Set());
} else {
setSelected(new Set(filteredRows.map((r) => r.origIdx)));
}
}, [selected, filteredRows]);
// Build IDs from selected rows (use design_id/tag column or row index)
const getDesignIds = useCallback((indices: number[]): string[] => {
if (!parsed) return [];
const idCol = colMap["design_id"] ?? colMap["tag"] ?? -1;
return indices.map((idx) => {
if (idCol >= 0) return parsed.rows[idx]?.[idCol] ?? `row_${idx}`;
return `row_${idx}`;
});
}, [parsed, colMap]);
const handleSubmitSelected = useCallback(() => {
const ids = getDesignIds(Array.from(selected));
if (ids.length === 0) return;
onSendMessage(`Score these designs: ${ids.join(", ")}`);
}, [selected, getDesignIds, onSendMessage]);
const handleResubmitTop = useCallback(() => {
const topRows = filteredRows.slice(0, topN);
const ids = getDesignIds(topRows.map((r) => r.origIdx));
if (ids.length === 0) return;
const filterDesc: string[] = [];
if (ipaeCol >= 0) filterDesc.push(`iPAE < ${ipaeMax}`);
if (plddtCol >= 0) filterDesc.push(`pLDDT > ${plddtMin}`);
const filterStr = filterDesc.length > 0 ? ` (filtered by ${filterDesc.join(" and ")})` : "";
onSendMessage(
`Filter ${artifact.name} by${filterStr} and re-run the top ${topN} designs with ProteinMPNN`,
);
}, [filteredRows, topN, getDesignIds, ipaeCol, plddtCol, ipaeMax, plddtMin, artifact.name, onSendMessage]);
if (error) {
return (
<div className="p-3 text-xs text-red-600 border border-red-200 rounded-lg bg-red-50">
Failed to load design CSV: {error}
</div>
);
}
if (raw == null) {
return <div className="p-3 text-xs text-muted-fg">Loading design data...</div>;
}
if (!parsed || !isDesignCsv(parsed.header)) {
return null; // Not a design CSV — don't render this panel
}
const allSelected = selected.size === filteredRows.length && filteredRows.length > 0;
return (
<div
data-testid="design-iteration-panel"
className="mt-3 rounded-xl border border-accent/20 bg-white shadow-sm overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border-subtle bg-accent/5">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M2 15c6.667-6 13.333 0 20-6" />
<path d="M2 9c6.667 6 13.333 0 20 6" />
</svg>
<span className="text-xs font-semibold text-foreground">Design Iteration</span>
<span className="text-[10px] text-muted-fg">
{filteredRows.length} design{filteredRows.length === 1 ? "" : "s"}
{filtersActive && ` (filtered from ${parsed.rows.length})`}
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setFiltersActive((f) => !f)}
className={`text-[10px] px-2 py-1 rounded-md transition-colors ${
filtersActive
? "bg-accent text-white"
: "text-muted-fg hover:text-foreground hover:bg-muted"
}`}
>
<svg className="w-3 h-3 inline mr-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
</svg>
Filters
</button>
</div>
</div>
{/* Filter controls */}
{filtersActive && (
<div className="px-4 py-2.5 border-b border-border-subtle bg-muted/30 flex flex-wrap gap-4 items-center">
{ipaeCol >= 0 && (
<label className="flex items-center gap-2 text-[11px] text-foreground-dim">
iPAE &lt;
<input
type="number"
value={ipaeMax}
onChange={(e) => setIpaeMax(parseFloat(e.target.value) || 0)}
step={0.5}
min={0}
max={50}
className="w-16 rounded-md border border-border bg-white px-2 py-1 text-xs focus:outline-none focus:border-accent"
/>
</label>
)}
{plddtCol >= 0 && (
<label className="flex items-center gap-2 text-[11px] text-foreground-dim">
pLDDT &gt;
<input
type="number"
value={plddtMin}
onChange={(e) => setPlddtMin(parseFloat(e.target.value) || 0)}
step={1}
min={0}
max={100}
className="w-16 rounded-md border border-border bg-white px-2 py-1 text-xs focus:outline-none focus:border-accent"
/>
</label>
)}
<div className="flex items-center gap-2 ml-auto text-[11px] text-foreground-dim">
<span>Top</span>
<input
type="number"
value={topN}
onChange={(e) => setTopN(Math.max(1, parseInt(e.target.value) || 1))}
min={1}
max={filteredRows.length || 100}
className="w-14 rounded-md border border-border bg-white px-2 py-1 text-xs focus:outline-none focus:border-accent"
/>
<button
type="button"
onClick={handleResubmitTop}
disabled={filteredRows.length === 0}
className="px-2.5 py-1 rounded-md bg-accent text-white text-[10px] font-medium hover:bg-accent-hover disabled:opacity-30 transition-colors"
>
Re-submit top {Math.min(topN, filteredRows.length)}
</button>
</div>
</div>
)}
{/* Table */}
<div className="max-h-80 overflow-auto">
<table className="w-full border-collapse font-mono text-[11px] leading-tight">
<thead className="sticky top-0 z-10 bg-muted/80 backdrop-blur">
<tr>
<th className="px-2 py-1.5 text-left w-8">
<input
type="checkbox"
checked={allSelected}
onChange={toggleSelectAll}
className="accent-accent"
aria-label="Select all designs"
/>
</th>
{parsed.header.map((h, idx) => {
const isSorted = sortCol === idx;
return (
<th
key={idx}
title={h}
onClick={() => handleSort(idx)}
className={`px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wide border-b border-border-subtle whitespace-nowrap cursor-pointer select-none hover:text-accent transition-colors ${
numericCols[idx] ? "text-right" : "text-left"
} ${isSorted ? "text-accent" : "text-muted-fg"}`}
style={{ maxWidth: 140 }}
>
<span className="inline-flex items-center gap-1">
<span className="overflow-hidden text-ellipsis whitespace-nowrap">{h}</span>
{isSorted && (
<svg className={`w-3 h-3 shrink-0 ${sortDir === "desc" ? "rotate-180" : ""}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<polyline points="18 15 12 9 6 15" />
</svg>
)}
</span>
</th>
);
})}
</tr>
</thead>
<tbody>
{filteredRows.map(({ row, origIdx }) => {
const isSelected = selected.has(origIdx);
return (
<tr
key={origIdx}
className={`${isSelected ? "bg-accent/5" : origIdx % 2 === 1 ? "bg-muted/30" : ""} hover:bg-accent/10 transition-colors cursor-pointer`}
onClick={() => toggleSelect(origIdx)}
>
<td className="px-2 py-1">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelect(origIdx)}
className="accent-accent"
aria-label={`Select design ${origIdx}`}
onClick={(e) => e.stopPropagation()}
/>
</td>
{parsed.header.map((_, cIdx) => {
const val = row[cIdx] ?? "";
return (
<td
key={cIdx}
title={val}
className={`px-2 py-1 border-b border-border-subtle/60 ${
numericCols[cIdx] ? "text-right tabular-nums" : "text-left"
}`}
style={{ maxWidth: 140 }}
>
<span className="block overflow-hidden text-ellipsis whitespace-nowrap">
{val}
</span>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
{/* Action footer */}
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border-subtle bg-muted/20">
<span className="text-[10px] text-muted-fg">
{selected.size} of {filteredRows.length} selected
</span>
<button
type="button"
onClick={handleSubmitSelected}
disabled={selected.size === 0}
className="px-3 py-1.5 rounded-md bg-accent text-white text-[10px] font-medium hover:bg-accent-hover disabled:opacity-30 transition-colors"
>
Submit selected for scoring
</button>
</div>
</div>
);
}
export { DesignIterationPanel };