import { useState, useCallback, useEffect, useMemo } from "react"; import type { DatasetInfo, QuestionData, Preset, FilterMode } from "./types"; import { api } from "./api"; interface GroupIndices { questionIdx: number; sampleIdx: number; } export function useAppState() { const [datasets, setDatasets] = useState([]); const [presets, setPresets] = useState([]); const [filter, setFilter] = useState("all"); const [questionDataMap, setQuestionDataMap] = useState>({}); const [loading, setLoading] = useState>({}); const [error, setError] = useState(null); // Per-group navigation indices const [groupIndices, setGroupIndices] = useState>({}); // Which group is currently displayed (fingerprint) const [currentGroupId, setCurrentGroupId] = useState(null); // Load presets on mount useEffect(() => { api.listPresets().then(setPresets).catch(() => {}); }, []); // Sync URL state on mount useEffect(() => { const params = new URLSearchParams(window.location.search); const q = parseInt(params.get("q") || "0"); const s = parseInt(params.get("s") || "0"); const f = (params.get("filter") || "all") as FilterMode; setFilter(f); // q and s will be applied once the first group is set if (!isNaN(q) || !isNaN(s)) { // Store initial URL indices to apply to first group loaded (window as unknown as Record).__initialQ = isNaN(q) ? 0 : q; (window as unknown as Record).__initialS = isNaN(s) ? 0 : s; } }, []); // Derive groups from datasets by fingerprint const groups = useMemo(() => { const map: Record = {}; for (const ds of datasets) { const fp = ds.questionFingerprint; if (!map[fp]) map[fp] = []; map[fp].push(ds); } return map; }, [datasets]); const groupIds = useMemo(() => Object.keys(groups).sort(), [groups]); // Auto-set currentGroupId if not set or invalid useEffect(() => { if (currentGroupId && groups[currentGroupId]) return; // Pick first group that has active datasets, or first group overall const activeGroup = groupIds.find(gid => groups[gid].some(d => d.active)); if (activeGroup) { setCurrentGroupId(activeGroup); } else if (groupIds.length > 0) { setCurrentGroupId(groupIds[0]); } else { setCurrentGroupId(null); } }, [groupIds, groups, currentGroupId]); // Active datasets = active datasets in current group const activeDatasets = useMemo( () => datasets.filter(d => d.active && d.questionFingerprint === currentGroupId), [datasets, currentGroupId] ); // Panel ordering: track display order of active dataset IDs const [panelOrder, setPanelOrder] = useState([]); // Keep panelOrder in sync with activeDatasets: add new IDs at end, remove stale ones useEffect(() => { const activeIds = new Set(activeDatasets.map(d => d.id)); setPanelOrder(prev => { const kept = prev.filter(id => activeIds.has(id)); const newIds = activeDatasets.map(d => d.id).filter(id => !prev.includes(id)); const merged = [...kept, ...newIds]; // Only update if changed to avoid unnecessary renders if (merged.length === prev.length && merged.every((id, i) => id === prev[i])) return prev; return merged; }); }, [activeDatasets]); // Ordered active datasets according to panelOrder const orderedActiveDatasets = useMemo(() => { const map = new Map(activeDatasets.map(d => [d.id, d])); return panelOrder.map(id => map.get(id)).filter((d): d is DatasetInfo => d !== undefined); }, [activeDatasets, panelOrder]); const reorderPanels = useCallback((fromId: string, toId: string) => { if (fromId === toId) return; setPanelOrder(prev => { const order = [...prev]; const fromIdx = order.indexOf(fromId); const toIdx = order.indexOf(toId); if (fromIdx === -1 || toIdx === -1) return prev; order.splice(fromIdx, 1); order.splice(toIdx, 0, fromId); return order; }); }, []); // Current group's indices const currentIndices = currentGroupId ? groupIndices[currentGroupId] : undefined; const questionIdx = currentIndices?.questionIdx ?? 0; const sampleIdx = currentIndices?.sampleIdx ?? 0; const setQuestionIdx = useCallback((val: number | ((prev: number) => number)) => { if (!currentGroupId) return; setGroupIndices(prev => { const cur = prev[currentGroupId] ?? { questionIdx: 0, sampleIdx: 0 }; const newQ = typeof val === "function" ? val(cur.questionIdx) : val; return { ...prev, [currentGroupId]: { ...cur, questionIdx: newQ } }; }); }, [currentGroupId]); const setSampleIdx = useCallback((val: number | ((prev: number) => number)) => { if (!currentGroupId) return; setGroupIndices(prev => { const cur = prev[currentGroupId] ?? { questionIdx: 0, sampleIdx: 0 }; const newS = typeof val === "function" ? val(cur.sampleIdx) : val; return { ...prev, [currentGroupId]: { ...cur, sampleIdx: newS } }; }); }, [currentGroupId]); // Update URL when state changes useEffect(() => { const params = new URLSearchParams(); const activeRepos = datasets.filter((d) => d.active); if (activeRepos.length > 0) { params.set("repos", activeRepos.map((d) => d.repo).join(",")); params.set("cols", activeRepos.map((d) => d.column).join(",")); params.set("pcols", activeRepos.map((d) => d.promptColumn || "formatted_prompt").join(",")); } params.set("q", String(questionIdx)); params.set("s", String(sampleIdx)); if (filter !== "all") params.set("filter", filter); const newUrl = `${window.location.pathname}?${params.toString()}`; window.history.replaceState({}, "", newUrl); }, [datasets, questionIdx, sampleIdx, filter]); // Fetch question data for active datasets in current group when question changes useEffect(() => { activeDatasets.forEach((ds) => { const key = `${ds.id}:${questionIdx}`; if (!questionDataMap[key]) { api.getQuestion(ds.id, questionIdx).then((data) => { setQuestionDataMap((prev) => ({ ...prev, [key]: data })); }).catch(() => {}); } }); }, [questionIdx, activeDatasets]); const addDataset = useCallback(async ( repo: string, column?: string, split?: string, promptColumn?: string, presetId?: string, presetName?: string, ) => { setLoading((prev) => ({ ...prev, [repo]: true })); setError(null); try { const { question_fingerprint, ...rest } = await api.loadDataset(repo, column, split, promptColumn); const fp = question_fingerprint ?? ""; const dsInfo: DatasetInfo = { ...rest, questionFingerprint: fp, active: true, presetId, presetName, }; setDatasets((prev) => { if (prev.some((d) => d.id === dsInfo.id)) return prev; return [...prev, dsInfo]; }); // Initialize group indices if new group, or inherit existing setGroupIndices(prev => { if (prev[fp]) return prev; // Group already exists, new repo inherits its indices // New group — check for initial URL params or start at 0 const win = window as unknown as Record; const initQ = typeof win.__initialQ === "number" ? win.__initialQ : 0; const initS = typeof win.__initialS === "number" ? win.__initialS : 0; // Only use initial params for the very first group const isFirstGroup = Object.keys(prev).length === 0; return { ...prev, [fp]: { questionIdx: isFirstGroup ? initQ : 0, sampleIdx: isFirstGroup ? initS : 0 }, }; }); // Switch to the new dataset's group setCurrentGroupId(fp); } catch (e: unknown) { setError(e instanceof Error ? e.message : "Failed to load dataset"); } finally { setLoading((prev) => ({ ...prev, [repo]: false })); } }, []); const removeDataset = useCallback(async (id: string) => { await api.unloadDataset(id).catch(() => {}); setDatasets((prev) => prev.filter((d) => d.id !== id)); }, []); const toggleDataset = useCallback((id: string) => { setDatasets((prev) => { const updated = prev.map((d) => (d.id === id ? { ...d, active: !d.active } : d)); // If toggling ON a dataset from a different group, switch to that group const toggled = updated.find(d => d.id === id); if (toggled && toggled.active) { setCurrentGroupId(toggled.questionFingerprint); } return updated; }); }, []); const updateDatasetPresetName = useCallback((dsId: string, name: string) => { setDatasets(prev => prev.map(d => d.id === dsId ? { ...d, presetName: name } : d)); }, []); const clearDatasetPreset = useCallback((dsId: string) => { setDatasets(prev => prev.map(d => d.id === dsId ? { ...d, presetId: undefined, presetName: undefined } : d)); }, []); const maxQuestions = Math.min(...activeDatasets.map((d) => d.n_rows), Infinity); const maxSamples = Math.max(...activeDatasets.map((d) => d.n_samples), 0); const getQuestionData = (dsId: string): QuestionData | undefined => { return questionDataMap[`${dsId}:${questionIdx}`]; }; return { datasets, presets, setPresets, questionIdx, setQuestionIdx, sampleIdx, setSampleIdx, filter, setFilter, loading, error, setError, activeDatasets, orderedActiveDatasets, maxQuestions, maxSamples, addDataset, removeDataset, toggleDataset, updateDatasetPresetName, clearDatasetPreset, getQuestionData, reorderPanels, // Group state groups, groupIds, currentGroupId, setCurrentGroupId, }; }