// 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(null); const [error, setError] = useState(null); // Sort state const [sortCol, setSortCol] = useState(null); const [sortDir, setSortDir] = useState("asc"); // Selection state const [selected, setSelected] = useState>(new Set()); // Filter thresholds const [ipaeMax, setIpaeMax] = useState(10); const [plddtMin, setPlddtMin] = useState(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(() => { 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; const map: Record = {}; 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 (
Failed to load design CSV: {error}
); } if (raw == null) { return
Loading design data...
; } 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 (
{/* Header */}
Design Iteration {filteredRows.length} design{filteredRows.length === 1 ? "" : "s"} {filtersActive && ` (filtered from ${parsed.rows.length})`}
{/* Filter controls */} {filtersActive && (
{ipaeCol >= 0 && ( )} {plddtCol >= 0 && ( )}
Top 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" />
)} {/* Table */}
{parsed.header.map((h, idx) => { const isSorted = sortCol === idx; return ( ); })} {filteredRows.map(({ row, origIdx }) => { const isSelected = selected.has(origIdx); return ( toggleSelect(origIdx)} > {parsed.header.map((_, cIdx) => { const val = row[cIdx] ?? ""; return ( ); })} ); })}
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 }} > {h} {isSorted && ( )}
toggleSelect(origIdx)} className="accent-accent" aria-label={`Select design ${origIdx}`} onClick={(e) => e.stopPropagation()} /> {val}
{/* Action footer */}
{selected.size} of {filteredRows.length} selected
); } export { DesignIterationPanel };