feat(webui): add guided setup flows
* feat(channels): add guided setup flows * test(channels): preserve setup config values * fix(channels): reflect saved setup state * refactor(channels): simplify setup state metadata * fix(channels): harden setup lifecycle * refactor(channels): centralize setup contracts * fix(channels): route setup actions through webui shim * fix(channels): adapt settings for compact screens * fix(models): preserve default preset display * feat(models): add curated Codex catalog * fix(webui): stop attached gateway on interrupt * fix(webui): simplify apps catalog * docs(webui): clarify apps and runtime features * feat(settings): add guided capability setup * fix(webui): harden setup and managed services * test: keep managed runtime checks portable * test: scope POSIX runtime coverage * fix(webui): simplify file settings * feat(files): bundle document reading * fix(webui): harden setup request boundaries * fix(webui): prevent channel setup status squeeze * fix(settings): group provider compatibility aliases * refactor(settings): remove redundant setup surfaces * fix(webui): harden guided setup lifecycle * fix(webui): preserve channel setup compatibility
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
const loadedLogoUrls = new Set<string>();
|
||||
const failedLogoUrls = new Set<string>();
|
||||
const resolvedLogoIndexByKey = new Map<string, number>();
|
||||
|
||||
function logoCacheKey(urls: readonly string[]): string {
|
||||
return urls.join("\n");
|
||||
}
|
||||
|
||||
function logoUrlsFromKey(key: string): string[] {
|
||||
return key ? key.split("\n") : [];
|
||||
}
|
||||
|
||||
function firstUsableLogoIndex(urls: readonly string[]): number {
|
||||
const key = logoCacheKey(urls);
|
||||
const cachedIndex = resolvedLogoIndexByKey.get(key);
|
||||
if (
|
||||
typeof cachedIndex === "number" &&
|
||||
cachedIndex >= 0 &&
|
||||
cachedIndex < urls.length &&
|
||||
!failedLogoUrls.has(urls[cachedIndex])
|
||||
) {
|
||||
return cachedIndex;
|
||||
}
|
||||
|
||||
const loadedIndex = urls.findIndex((url) => loadedLogoUrls.has(url));
|
||||
if (loadedIndex >= 0) {
|
||||
resolvedLogoIndexByKey.set(key, loadedIndex);
|
||||
return loadedIndex;
|
||||
}
|
||||
|
||||
const firstUnfailedIndex = urls.findIndex((url) => !failedLogoUrls.has(url));
|
||||
if (firstUnfailedIndex >= 0) return firstUnfailedIndex;
|
||||
return -1;
|
||||
}
|
||||
|
||||
function nextLogoIndex(urls: readonly string[], afterIndex: number): number {
|
||||
for (let index = afterIndex + 1; index < urls.length; index += 1) {
|
||||
if (!failedLogoUrls.has(urls[index])) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function useLogoFallback(urls: readonly string[] | undefined) {
|
||||
const cacheKey = useMemo(() => logoCacheKey(urls?.filter(Boolean) ?? []), [urls]);
|
||||
const safeUrls = useMemo(() => logoUrlsFromKey(cacheKey), [cacheKey]);
|
||||
const [logoIndex, setLogoIndex] = useState(() => firstUsableLogoIndex(safeUrls));
|
||||
const logoUrl = logoIndex >= 0 ? safeUrls[logoIndex] : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
setLogoIndex(firstUsableLogoIndex(safeUrls));
|
||||
}, [cacheKey, safeUrls]);
|
||||
|
||||
const onLogoLoad = useCallback(() => {
|
||||
if (!logoUrl || logoIndex < 0) return;
|
||||
loadedLogoUrls.add(logoUrl);
|
||||
failedLogoUrls.delete(logoUrl);
|
||||
resolvedLogoIndexByKey.set(cacheKey, logoIndex);
|
||||
}, [cacheKey, logoIndex, logoUrl]);
|
||||
|
||||
const onLogoError = useCallback(() => {
|
||||
if (!logoUrl || logoIndex < 0) return;
|
||||
failedLogoUrls.add(logoUrl);
|
||||
if (resolvedLogoIndexByKey.get(cacheKey) === logoIndex) {
|
||||
resolvedLogoIndexByKey.delete(cacheKey);
|
||||
}
|
||||
setLogoIndex(nextLogoIndex(safeUrls, logoIndex));
|
||||
}, [cacheKey, logoIndex, logoUrl, safeUrls]);
|
||||
|
||||
return { logoUrl, onLogoLoad, onLogoError };
|
||||
}
|
||||
|
||||
export function __clearLogoFallbackCacheForTests(): void {
|
||||
loadedLogoUrls.clear();
|
||||
failedLogoUrls.clear();
|
||||
resolvedLogoIndexByKey.clear();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useMediaQuery(query: string, fallback = false): boolean {
|
||||
const readMatch = () => {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
typeof window.matchMedia !== "function"
|
||||
) {
|
||||
return fallback;
|
||||
}
|
||||
return window.matchMedia(query).matches;
|
||||
};
|
||||
|
||||
const [matches, setMatches] = useState(readMatch);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
typeof window.matchMedia !== "function"
|
||||
)
|
||||
return;
|
||||
const media = window.matchMedia(query);
|
||||
const update = () => setMatches(media.matches);
|
||||
update();
|
||||
media.addEventListener("change", update);
|
||||
return () => media.removeEventListener("change", update);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
Reference in New Issue
Block a user