import { useCallback, useEffect, useRef, useState } from 'react'; import { fetchCatalog, fetchHarnesses, fetchPersistedSelection, persistSelection, type SelectionErrorCode, } from './chat-api'; import type { HarnessCatalog, HarnessSelection, HarnessSummary } from '@/lib/types'; export interface HarnessPersistError { code: SelectionErrorCode; message: string; /** The exact tuple the user requested — preserved so the failed selection * stays visible rather than being silently dropped. */ requested: HarnessSelection; } export interface HarnessSelectionValue { harnesses: HarnessSummary[]; catalog: HarnessCatalog | null; /** True when the selected harness has no usable catalog (404/error). */ catalogUnavailable: boolean; /** The working (displayed) selection, kept as three distinct ids. Empty * strings mean "not chosen yet" — there is deliberately no first-row default. */ harnessId: string; providerId: string; modelId: string; /** The last tuple confirmed persisted by the server, or null. */ persistedSelection: HarnessSelection | null; /** True when a persisted selection references a model no longer present as an * available catalog entry — it stays visibly displayed rather than dropped. */ isStale: boolean; /** True ONLY once a full tuple has been confirmed persisted AND it is a * currently-available catalog entry. Send stays disabled otherwise, so a send * can never race ahead of successful persistence. */ canSend: boolean; persistError: HarnessPersistError | null; selectHarness: (harnessId: string) => void; selectProvider: (providerId: string) => void; /** Persist the EXACT catalog row's `{providerId, modelId}` — the caller * resolves the composite option identity to the real entry and passes both * ids, so a bare model id is never combined with ambient provider state. */ selectModel: (providerId: string, modelId: string) => void; } /** A tuple is a currently-usable catalog option only when the catalog holds a * matching, available entry — the single gate that keeps a stale/unavailable * model from ever counting as sendable. */ function isAvailableInCatalog( selection: HarnessSelection | null, catalog: HarnessCatalog | null, ): boolean { if (selection === null || catalog === null) return false; return catalog.models.some( (model) => model.providerId === selection.providerId && model.modelId === selection.modelId && model.availability === 'available', ); } function tuplesEqual(a: HarnessSelection | null, b: HarnessSelection | null): boolean { if (a === null || b === null) return a === b; return a.harnessId === b.harnessId && a.providerId === b.providerId && a.modelId === b.modelId; } /** * Owns the harness/catalog/selection state for the chat composer: loads the * harness list and any persisted tuple on mount, loads a harness's catalog when * chosen, and PUT-persists the full `{harnessId, providerId, modelId}` tuple * when a model is picked. It never auto-selects a catalog row, keeps a * stale/unavailable persisted tuple visible, and only reports `canSend` true * once a full tuple has actually persisted as an available catalog entry. */ export function useHarnessSelection(): HarnessSelectionValue { const [harnesses, setHarnesses] = useState([]); const [catalog, setCatalog] = useState(null); const [catalogUnavailable, setCatalogUnavailable] = useState(false); const [harnessId, setHarnessId] = useState(''); const [providerId, setProviderId] = useState(''); const [modelId, setModelId] = useState(''); const [persistedSelection, setPersistedSelection] = useState(null); const [persistError, setPersistError] = useState(null); // Monotonic request ids so a slow in-flight catalog/persist response can never // overwrite the result of a newer request the user has since triggered. const catalogRequestRef = useRef(0); const persistRequestRef = useRef(0); const loadCatalog = useCallback(async (id: string): Promise => { const requestId = catalogRequestRef.current + 1; catalogRequestRef.current = requestId; setCatalog(null); setCatalogUnavailable(false); const result = await fetchCatalog(id); if (catalogRequestRef.current !== requestId) return; if (result.ok) { setCatalog(result.catalog); setCatalogUnavailable(false); } else { setCatalog(null); setCatalogUnavailable(true); } }, []); useEffect(() => { let active = true; void (async (): Promise => { const [list, persisted] = await Promise.all([fetchHarnesses(), fetchPersistedSelection()]); if (!active) return; setHarnesses(list); if (persisted !== null) { // Adopt the persisted tuple as the displayed selection and load its // catalog. If the model has since been retired, it still shows (stale). setHarnessId(persisted.harnessId); setProviderId(persisted.providerId); setModelId(persisted.modelId); setPersistedSelection(persisted); await loadCatalog(persisted.harnessId); } // No persisted selection → nothing is auto-selected; the user must choose. })(); return () => { active = false; }; }, [loadCatalog]); const selectHarness = useCallback( (id: string): void => { setHarnessId(id); // Changing harness invalidates the provider/model draft — never carry a // model across harnesses. setProviderId(''); setModelId(''); setPersistError(null); void loadCatalog(id); }, [loadCatalog], ); const selectProvider = useCallback((id: string): void => { setProviderId(id); // A new provider invalidates the chosen model — no cross-provider carryover. setModelId(''); setPersistError(null); }, []); const selectModel = useCallback( (selectedProviderId: string, selectedModelId: string): void => { // Bind the model to the EXACT catalog row's provider — never to ambient // provider state — so two providers exposing the same modelId can never // collide or mis-resolve. Keep the displayed provider consistent with the // resolved row. setProviderId(selectedProviderId); setModelId(selectedModelId); setPersistError(null); const requested: HarnessSelection = { harnessId, providerId: selectedProviderId, modelId: selectedModelId, }; const requestId = persistRequestRef.current + 1; persistRequestRef.current = requestId; void (async (): Promise => { const result = await persistSelection(requested); if (persistRequestRef.current !== requestId) return; if (result.ok) { setPersistedSelection(result.selection); setPersistError(null); } else { // Leave persistedSelection unchanged (send stays disabled) and surface // the typed error carrying the exact requested tuple. setPersistError({ code: result.code, message: result.message, requested: result.requested, }); } })(); }, [harnessId], ); const draft: HarnessSelection = { harnessId, providerId, modelId }; const isStale = persistedSelection !== null && !isAvailableInCatalog(persistedSelection, catalog); const canSend = persistedSelection !== null && !catalogUnavailable && tuplesEqual(draft, persistedSelection) && isAvailableInCatalog(persistedSelection, catalog); return { harnesses, catalog, catalogUnavailable, harnessId, providerId, modelId, persistedSelection, isStale, canSend, persistError, selectHarness, selectProvider, selectModel, }; }