Files
fusion/packages/dashboard/app/hooks/useModelsCache.ts
gsxdsm 0e8f6769ba FN-8666: add credential instance pickers
Add credential-instance selection across dashboard model configuration surfaces.

- Expose provider credential instances in custom model dropdowns and task, workflow, and settings forms.
- Persist per-role credential-instance overrides through dashboard APIs and effective model resolution.
- Document credential-instance precedence and cover dropdown/settings behavior, including task state synchronization.

Files changed:
 .changeset/fn-8666-credential-instance-picker.md   |   7 ++
 docs/settings-reference.md                         |   2 +
 docs/workflow-steps.md                             |   2 +-
 packages/dashboard/app/api.ts                      |   1 +
 packages/dashboard/app/api/models-usage.ts         |  27 ++++-
 packages/dashboard/app/api/tasks.ts                |   8 ++
 .../app/components/CustomModelDropdown.css         |  12 ++-
 .../app/components/CustomModelDropdown.tsx         |  71 ++++++++++++-
 .../dashboard/app/components/InlineCreateCard.tsx  |  23 +++-
 packages/dashboard/app/components/ListView.tsx     |  29 +++++-
 .../app/components/ModelSelectionModal.tsx         |  25 ++++-
 .../dashboard/app/components/ModelSelectorTab.tsx  |  29 +++++-
 packages/dashboard/app/components/NewTaskModal.tsx |  23 +++-
 .../dashboard/app/components/QuickEntryBox.tsx     |  28 +++++
 .../dashboard/app/components/SettingsModal.tsx     |   2 +
 .../dashboard/app/components/TaskDetailModal.tsx   |  32 +++++-
 packages/dashboard/app/components/TaskForm.tsx     |  18 ++++
 .../app/components/WorkflowNodeEditor.tsx          |  13 ++-
 .../app/components/WorkflowSettingsPanel.tsx       |  38 ++++++-
 ...ustomModelDropdown.credential-instance.test.tsx | 111 ++++++++++++++++++++
 .../__tests__/WorkflowSettingsPanel.test.tsx       |   9 +-
 .../app/components/effective-model-resolution.ts   |   8 +-
 .../settings/sections/GlobalModelsSection.tsx      |  31 +++++-
 .../settings/sections/ProjectModelsSection.tsx     | 116 ++++++++++++++++-----
 .../ProjectModelsSection.chatDefault.test.tsx      |  33 +++++-
 packages/dashboard/app/hooks/useFavorites.ts       |   6 +-
 packages/dashboard/app/hooks/useModelsCache.ts     |   5 +-
 .../src/routes/register-task-workflow-routes.ts    |  17 ++-
 28 files changed, 655 insertions(+), 71 deletions(-)

Fusion-Task-Id: FN-8666

Fusion-Task-Lineage: 6c3711b3-68b8-47a0-ac36-4b64846adf39

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-01 04:03:26 -07:00

176 lines
6.2 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { fetchModels, type ModelInfo, type ModelsResponse, type ProviderCredentialInstanceSummary } from "../api";
import { clearCache, readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, writeCache } from "../utils/swrCache";
interface ModelsCacheState {
models: ModelInfo[];
favoriteProviders: string[];
favoriteModels: string[];
defaultProvider: string | null;
defaultModelId: string | null;
providerInstances: Record<string, { instances: ProviderCredentialInstanceSummary[] }>;
}
export interface UseModelsCacheResult extends ModelsCacheState {
loading: boolean;
refresh: () => Promise<void>;
}
const EMPTY_MODELS_STATE: ModelsCacheState = {
models: [],
favoriteProviders: [],
favoriteModels: [],
defaultProvider: null,
defaultModelId: null,
providerInstances: {},
};
let inflight: Promise<ModelsResponse> | null = null;
// Guards concurrent refreshModelsCache() callers so they share one forced
// fetch instead of each spawning its own request (see FNXC:ModelCatalog
// comment on refreshModelsCache below).
let refreshInflight: Promise<void> | null = null;
const listeners = new Set<(state: ModelsCacheState) => void>();
function toModelsCacheState(response: ModelsResponse | null | undefined): ModelsCacheState {
if (!response) {
return EMPTY_MODELS_STATE;
}
return {
models: response.models ?? [],
favoriteProviders: response.favoriteProviders ?? [],
favoriteModels: response.favoriteModels ?? [],
defaultProvider: response.defaultProvider ?? null,
defaultModelId: response.defaultModelId ?? null,
providerInstances: response.providerInstances ?? {},
};
}
function readCachedModelsState(): ModelsCacheState | null {
const cached = readCache<ModelsResponse>(SWR_CACHE_KEYS.MODELS, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
return cached ? toModelsCacheState(cached) : null;
}
function notifyListeners(state: ModelsCacheState): void {
for (const listener of listeners) {
listener(state);
}
}
async function fetchModelsShared(): Promise<ModelsResponse> {
if (!inflight) {
const promise = fetchModels();
inflight = promise;
// Only clear `inflight` if it still points at *this* promise — a forced
// refresh (see refreshModelsCache) may have already replaced it with a
// newer in-flight fetch, and this stale promise resolving later must not
// wipe out that newer reference out from under it. `.catch(() => {})`
// swallows the rejection on THIS bookkeeping branch only — the original
// `promise` returned below is untouched and still rejects for callers
// that await it, so error handling (try/catch in load()/refreshModelsCache)
// is unaffected; this just avoids a duplicate unhandled-rejection listener.
promise.then(
() => {
if (inflight === promise) inflight = null;
},
() => {
if (inflight === promise) inflight = null;
},
);
}
return inflight;
}
/**
* FNXC:ModelCatalog 2026-07-08-00:00:
* FN-7710: Enabling/disabling a CLI provider (Grok, Cursor, Claude CLI,
* llama.cpp) changes which rows `/api/models` returns, but every already-
* mounted `useModelsCache()` consumer (board Quick Entry, Task Detail, New
* Agent, Workflow editor, etc.) only re-fetches on its own mount — it has no
* way to know the shared catalog is now stale. Previously the CLI provider
* cards' `onToggled` handler only refreshed the Settings Authentication
* panel (`loadAuthStatus()`), so newly-enabled `grok-cli`/`cursor-cli`
* models stayed invisible until some unrelated Settings surface happened to
* call `fetchModels()` fresh. `refreshModelsCache()` is the shared,
* single-flight, never-throw entry point any non-hook caller (a settings
* card, a toggle handler) can invoke to force a fresh fetch, write through
* `SWR_CACHE_KEYS.MODELS`, and notify every mounted `useModelsCache`
* subscriber via the module-level `listeners` set — no remount required.
* Concurrent `refreshModelsCache()` callers share one forced fetch via
* `refreshInflight`. A failed refresh must never blank an existing good
* list: on error this simply leaves the current cache/listeners state
* untouched, so a transient network hiccup degrades to "keep showing the
* last good list", not an empty picker.
*/
export async function refreshModelsCache(): Promise<void> {
if (refreshInflight) {
return refreshInflight;
}
refreshInflight = (async () => {
try {
// Force a fresh fetch: drop any pre-toggle in-flight promise so a
// response requested before the provider toggle took effect is never
// mistaken for the post-toggle catalog.
inflight = null;
const response = await fetchModelsShared();
const nextState = toModelsCacheState(response);
writeCache(SWR_CACHE_KEYS.MODELS, response, { maxBytes: 500_000 });
notifyListeners(nextState);
} catch {
// Never throw, never blank an existing good list — leave cache/listeners
// state untouched on failure (see FNXC:ModelCatalog comment above).
}
})().finally(() => {
refreshInflight = null;
});
return refreshInflight;
}
export function useModelsCache(): UseModelsCacheResult {
const cachedState = readCachedModelsState();
const [state, setState] = useState<ModelsCacheState>(() => cachedState ?? EMPTY_MODELS_STATE);
const [loading, setLoading] = useState(() => cachedState === null);
const hasCachedStateRef = useRef(cachedState !== null);
useEffect(() => {
listeners.add(setState);
return () => {
listeners.delete(setState);
};
}, []);
const load = useCallback(async () => {
try {
const response = await fetchModelsShared();
const nextState = toModelsCacheState(response);
hasCachedStateRef.current = true;
writeCache(SWR_CACHE_KEYS.MODELS, response, { maxBytes: 500_000 });
notifyListeners(nextState);
} catch {
if (!hasCachedStateRef.current) {
clearCache(SWR_CACHE_KEYS.MODELS);
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const refresh = useCallback(async () => {
setLoading(true);
await load();
}, [load]);
return {
...state,
loading,
refresh,
};
}