fix(webui): prevent redundant thread and media reloads (#5164)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
|
||||
import { AlertCircle, ChevronRight, Loader2, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -21,7 +21,7 @@ interface FilePreviewPanelProps {
|
||||
|
||||
type PreviewState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; message: string }
|
||||
| { status: "error"; error: unknown }
|
||||
| { status: "ready"; payload: FilePreviewPayload };
|
||||
|
||||
export function FilePreviewPanel({
|
||||
@@ -36,6 +36,8 @@ export function FilePreviewPanel({
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<PreviewState>({ status: "loading" });
|
||||
const [entered, setEntered] = useState(false);
|
||||
const tokenRef = useRef(token);
|
||||
tokenRef.current = token;
|
||||
|
||||
useEffect(() => {
|
||||
const frame = window.requestAnimationFrame(() => setEntered(true));
|
||||
@@ -45,25 +47,17 @@ export function FilePreviewPanel({
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState({ status: "loading" });
|
||||
fetchFilePreview(token, sessionKey, path)
|
||||
fetchFilePreview(tokenRef.current, sessionKey, path)
|
||||
.then((payload) => {
|
||||
if (!cancelled) setState({ status: "ready", payload });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
const message = error instanceof ApiError
|
||||
? (error.status === 404 && /API route not found/i.test(error.message)
|
||||
? t("filePreview.routeMissing", {
|
||||
defaultValue: "File preview needs the latest gateway. Restart nanobot gateway and try again.",
|
||||
})
|
||||
: error.message)
|
||||
: t("filePreview.failed", { defaultValue: "Could not preview this file." });
|
||||
setState({ status: "error", message });
|
||||
if (!cancelled) setState({ status: "error", error });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [path, sessionKey, t, token]);
|
||||
}, [path, sessionKey]);
|
||||
|
||||
const displayPath = state.status === "ready" ? state.payload.display_path : path;
|
||||
const previewPath = state.status === "ready" ? state.payload.path : displayPath;
|
||||
@@ -92,6 +86,15 @@ export function FilePreviewPanel({
|
||||
...directoryParts,
|
||||
fileName,
|
||||
].join("/")}`;
|
||||
const errorMessage = state.status === "error"
|
||||
? (state.error instanceof ApiError
|
||||
? (state.error.status === 404 && /API route not found/i.test(state.error.message)
|
||||
? t("filePreview.routeMissing", {
|
||||
defaultValue: "File preview needs the latest gateway. Restart nanobot gateway and try again.",
|
||||
})
|
||||
: state.error.message)
|
||||
: t("filePreview.failed", { defaultValue: "Could not preview this file." }))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -222,7 +225,7 @@ export function FilePreviewPanel({
|
||||
className="mx-auto mb-3 h-5 w-5 text-muted-foreground/70"
|
||||
aria-hidden
|
||||
/>
|
||||
<p>{state.message}</p>
|
||||
<p>{errorMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -633,7 +633,7 @@ export function SettingsView({
|
||||
hostChromeInset = false,
|
||||
}: SettingsViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { token } = useClient();
|
||||
const { getToken, token } = useClient();
|
||||
const pageVisible = usePageVisibility();
|
||||
const remoteBrowserAccess =
|
||||
typeof window !== "undefined" && !isLoopbackHost(window.location.hostname);
|
||||
@@ -779,7 +779,7 @@ export function SettingsView({
|
||||
const poll = async () => {
|
||||
try {
|
||||
const payload = await completeProviderOAuth(
|
||||
token,
|
||||
getToken(),
|
||||
xaiOAuthFlow.provider,
|
||||
xaiOAuthFlow.flow_id,
|
||||
);
|
||||
@@ -803,7 +803,7 @@ export function SettingsView({
|
||||
cancelled = true;
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
};
|
||||
}, [applyPayload, closeXaiOAuthFlow, token, xaiOAuthFlow]);
|
||||
}, [applyPayload, closeXaiOAuthFlow, getToken, xaiOAuthFlow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialSettings || settings !== null) return;
|
||||
@@ -815,7 +815,7 @@ export function SettingsView({
|
||||
let cancelled = false;
|
||||
const showLoading = settings === null;
|
||||
if (showLoading) setLoading(true);
|
||||
fetchSettings(token)
|
||||
fetchSettings(getToken())
|
||||
.then((payload) => {
|
||||
if (!cancelled) {
|
||||
applyPayload(payload);
|
||||
@@ -831,30 +831,37 @@ export function SettingsView({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [applyPayload, token]);
|
||||
}, [applyPayload, getToken]);
|
||||
|
||||
const hasSettings = settings !== null;
|
||||
useEffect(() => {
|
||||
if (activeSection !== "overview" || !hasSettings || !pageVisible) return;
|
||||
let cancelled = false;
|
||||
const refresh = () => {
|
||||
fetchSettingsUsage(token)
|
||||
.then((usage) => {
|
||||
if (cancelled) return;
|
||||
let refreshing = false;
|
||||
const refresh = async () => {
|
||||
if (refreshing) return;
|
||||
refreshing = true;
|
||||
try {
|
||||
const usage = await fetchSettingsUsage(getToken());
|
||||
if (!cancelled) {
|
||||
setSettings((current) => (current ? { ...current, usage } : current));
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
// Usage is best-effort telemetry; the settings snapshot remains usable.
|
||||
} finally {
|
||||
refreshing = false;
|
||||
}
|
||||
};
|
||||
void refresh();
|
||||
const interval = window.setInterval(refresh, 5000);
|
||||
const onFocus = () => refresh();
|
||||
const interval = window.setInterval(() => void refresh(), 5000);
|
||||
const onFocus = () => void refresh();
|
||||
window.addEventListener("focus", onFocus);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
};
|
||||
}, [activeSection, hasSettings, pageVisible, token]);
|
||||
}, [activeSection, getToken, hasSettings, pageVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection !== "apps") return;
|
||||
@@ -863,7 +870,7 @@ export function SettingsView({
|
||||
let retryCount = 0;
|
||||
const loadCliApps = (showLoading: boolean) => {
|
||||
if (showLoading) setCliAppsLoading(true);
|
||||
fetchCliApps(token)
|
||||
fetchCliApps(getToken())
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
if (payload.catalog_refresh_pending && retryCount < CLI_APPS_REFRESH_MAX_RETRIES) {
|
||||
@@ -889,15 +896,23 @@ export function SettingsView({
|
||||
cancelled = true;
|
||||
if (retry !== null) window.clearTimeout(retry);
|
||||
};
|
||||
}, [activeSection, token]);
|
||||
}, [activeSection, getToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!["channels", "models", "browser", "runtime"].includes(activeSection)) return;
|
||||
if (
|
||||
!pageVisible
|
||||
|| !["channels", "models", "browser", "runtime"].includes(activeSection)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const refresh = async (showLoading = false) => {
|
||||
let refreshing = false;
|
||||
const refresh = async (showLoading = false): Promise<void> => {
|
||||
if (refreshing) return;
|
||||
refreshing = true;
|
||||
if (showLoading) setNanobotFeaturesLoading(true);
|
||||
try {
|
||||
const payload = await fetchNanobotFeatures(token);
|
||||
const payload = await fetchNanobotFeatures(getToken());
|
||||
if (!cancelled) {
|
||||
setNanobotFeatures(payload);
|
||||
setNanobotFeaturesError(null);
|
||||
@@ -906,6 +921,7 @@ export function SettingsView({
|
||||
const message = (err as Error).message;
|
||||
if (!cancelled && message !== "HTTP 404") setNanobotFeaturesError(message);
|
||||
} finally {
|
||||
refreshing = false;
|
||||
if (!cancelled && showLoading) setNanobotFeaturesLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -926,13 +942,13 @@ export function SettingsView({
|
||||
window.removeEventListener("focus", refreshOnFocus);
|
||||
document.removeEventListener("visibilitychange", refreshOnFocus);
|
||||
};
|
||||
}, [activeSection, token]);
|
||||
}, [activeSection, getToken, pageVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection !== "runtime") return;
|
||||
let cancelled = false;
|
||||
setApiServiceLoading(true);
|
||||
fetchApiService(token)
|
||||
fetchApiService(getToken())
|
||||
.then((payload) => {
|
||||
if (!cancelled) {
|
||||
setApiService(payload);
|
||||
@@ -948,13 +964,13 @@ export function SettingsView({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeSection, token]);
|
||||
}, [activeSection, getToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection !== "apps") return;
|
||||
let cancelled = false;
|
||||
setMcpPresetsLoading(true);
|
||||
fetchMcpPresets(token)
|
||||
fetchMcpPresets(getToken())
|
||||
.then((payload) => {
|
||||
if (!cancelled) {
|
||||
setMcpPresets(payload);
|
||||
@@ -970,13 +986,13 @@ export function SettingsView({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeSection, token]);
|
||||
}, [activeSection, getToken]);
|
||||
|
||||
const refreshAutomations = useCallback(
|
||||
async (showLoading = false) => {
|
||||
if (showLoading) setAutomationsLoading(true);
|
||||
try {
|
||||
const payload = await fetchAutomations(token);
|
||||
const payload = await fetchAutomations(getToken());
|
||||
setAutomations(payload);
|
||||
setAutomationsError(null);
|
||||
} catch (err) {
|
||||
@@ -985,23 +1001,26 @@ export function SettingsView({
|
||||
if (showLoading) setAutomationsLoading(false);
|
||||
}
|
||||
},
|
||||
[token],
|
||||
[getToken],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection !== "automations" || !pageVisible) return;
|
||||
let cancelled = false;
|
||||
let refreshing = false;
|
||||
const refresh = async (showLoading = false) => {
|
||||
if (cancelled) return;
|
||||
if (cancelled || refreshing) return;
|
||||
refreshing = true;
|
||||
if (showLoading) setAutomationsLoading(true);
|
||||
try {
|
||||
const payload = await fetchAutomations(token);
|
||||
const payload = await fetchAutomations(getToken());
|
||||
if (cancelled) return;
|
||||
setAutomations(payload);
|
||||
setAutomationsError(null);
|
||||
} catch (err) {
|
||||
if (!cancelled) setAutomationsError((err as Error).message);
|
||||
} finally {
|
||||
refreshing = false;
|
||||
if (!cancelled && showLoading) setAutomationsLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -1014,7 +1033,7 @@ export function SettingsView({
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener("focus", refreshOnFocus);
|
||||
};
|
||||
}, [activeSection, pageVisible, token]);
|
||||
}, [activeSection, getToken, pageVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
writeLocalPreferences(localPrefs);
|
||||
@@ -8899,6 +8918,8 @@ function ModelIdPicker({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const tokenRef = useRef(token);
|
||||
tokenRef.current = token;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [payload, setPayload] = useState<ProviderModelsPayload | null>(null);
|
||||
@@ -8967,7 +8988,7 @@ function ModelIdPicker({
|
||||
setPayload(null);
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
fetchProviderModels(token, effectiveProvider)
|
||||
fetchProviderModels(tokenRef.current, effectiveProvider)
|
||||
.then((nextPayload) => {
|
||||
if (!cancelled) setPayload(nextPayload);
|
||||
})
|
||||
@@ -8980,7 +9001,7 @@ function ModelIdPicker({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [effectiveProvider, open, shouldFetchModels, token]);
|
||||
}, [effectiveProvider, open, shouldFetchModels]);
|
||||
|
||||
const selectModel = (model: string) => {
|
||||
onChange(model);
|
||||
|
||||
@@ -302,7 +302,7 @@ function SkillDetailSheet({
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { token } = useClient();
|
||||
const { getToken } = useClient();
|
||||
const { t } = useTranslation();
|
||||
const [detail, setDetail] = useState<SkillDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -322,7 +322,7 @@ function SkillDetailSheet({
|
||||
setActionError("");
|
||||
setDeleteOpen(false);
|
||||
setDescriptionExpanded(false);
|
||||
fetchSkillDetail(token, skill.name)
|
||||
fetchSkillDetail(getToken(), skill.name)
|
||||
.then((payload) => {
|
||||
if (!cancelled) setDetail(payload);
|
||||
})
|
||||
@@ -335,7 +335,7 @@ function SkillDetailSheet({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, refreshKey, skill, token]);
|
||||
}, [getToken, open, refreshKey, skill]);
|
||||
|
||||
if (!skill) return null;
|
||||
|
||||
@@ -354,7 +354,7 @@ function SkillDetailSheet({
|
||||
setActionBusy(true);
|
||||
setActionError("");
|
||||
try {
|
||||
const payload = await updateSkillEnabled(token, activeSkill.name, !enabled);
|
||||
const payload = await updateSkillEnabled(getToken(), activeSkill.name, !enabled);
|
||||
notifySkillsChanged(payload);
|
||||
const updated = payload.skills.find((item) => item.name === activeSkill.name);
|
||||
if (updated) {
|
||||
@@ -378,7 +378,7 @@ function SkillDetailSheet({
|
||||
setActionBusy(true);
|
||||
setActionError("");
|
||||
try {
|
||||
const payload = await deleteSkill(token, activeSkill.name);
|
||||
const payload = await deleteSkill(getToken(), activeSkill.name);
|
||||
notifySkillsChanged(payload);
|
||||
onOpenChange(false);
|
||||
} catch (reason) {
|
||||
|
||||
@@ -45,7 +45,7 @@ export function SkillsMarketplace({
|
||||
installing: string;
|
||||
onInstallingChange: (skillId: string) => void;
|
||||
}) {
|
||||
const { token } = useClient();
|
||||
const { getToken } = useClient();
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<MarketplaceSkillSummary[]>([]);
|
||||
@@ -78,7 +78,7 @@ export function SkillsMarketplace({
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setTrendingLoading(true);
|
||||
fetchTrendingMarketplaceSkills(token)
|
||||
fetchTrendingMarketplaceSkills(getToken())
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
setTrending(payload.skills);
|
||||
@@ -92,7 +92,7 @@ export function SkillsMarketplace({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token]);
|
||||
}, [getToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const skills = query.trim().length < 2 ? trending : results;
|
||||
@@ -102,7 +102,7 @@ export function SkillsMarketplace({
|
||||
if (!unresolved.length) return;
|
||||
|
||||
let cancelled = false;
|
||||
fetchMarketplaceSkillTrends(token, unresolved.map((skill) => skill.id))
|
||||
fetchMarketplaceSkillTrends(getToken(), unresolved.map((skill) => skill.id))
|
||||
.then((payload) => {
|
||||
if (!cancelled) {
|
||||
setTrends((current) => ({ ...current, ...payload.trends }));
|
||||
@@ -112,7 +112,7 @@ export function SkillsMarketplace({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [query, results, token, trending, trends]);
|
||||
}, [getToken, query, results, trending, trends]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalized = query.trim();
|
||||
@@ -127,7 +127,7 @@ export function SkillsMarketplace({
|
||||
const timer = window.setTimeout(() => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
searchMarketplaceSkills(token, normalized)
|
||||
searchMarketplaceSkills(getToken(), normalized)
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
setResults(payload.skills);
|
||||
@@ -152,7 +152,7 @@ export function SkillsMarketplace({
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [query, t, token]);
|
||||
}, [getToken, query, t]);
|
||||
|
||||
const install = async (skill: MarketplaceSkillSummary) => {
|
||||
setSelected(null);
|
||||
@@ -160,7 +160,7 @@ export function SkillsMarketplace({
|
||||
setError("");
|
||||
try {
|
||||
const payload = await installMarketplaceSkill(
|
||||
token,
|
||||
getToken(),
|
||||
skill.provider,
|
||||
skill.source,
|
||||
skill.skill_id,
|
||||
|
||||
@@ -62,6 +62,8 @@ export function ChannelQrConnectFlow({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [handledRequestId, setHandledRequestId] = useState(0);
|
||||
const pollInFlight = useRef(false);
|
||||
const tokenRef = useRef(token);
|
||||
tokenRef.current = token;
|
||||
const startDomain = startOptions.domain;
|
||||
const startInstanceId = startOptions.instanceId;
|
||||
const startMode = startOptions.mode;
|
||||
@@ -100,7 +102,11 @@ export function ChannelQrConnectFlow({
|
||||
if (pollInFlight.current) return;
|
||||
pollInFlight.current = true;
|
||||
try {
|
||||
const payload = await pollChannelConnect(token, channelName, connect.session_id);
|
||||
const payload = await pollChannelConnect(
|
||||
tokenRef.current,
|
||||
channelName,
|
||||
connect.session_id,
|
||||
);
|
||||
if (cancelled) return;
|
||||
setConnect((current) => ({
|
||||
...(current ?? payload),
|
||||
@@ -129,13 +135,20 @@ export function ChannelQrConnectFlow({
|
||||
window.clearTimeout(initial);
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [channelName, connect?.interval_ms, connect?.session_id, connect?.status, onFeaturesUpdate, pageVisible, token]);
|
||||
}, [
|
||||
channelName,
|
||||
connect?.interval_ms,
|
||||
connect?.session_id,
|
||||
connect?.status,
|
||||
onFeaturesUpdate,
|
||||
pageVisible,
|
||||
]);
|
||||
|
||||
const start = useCallback(async (force = false) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payload = await startChannelConnect(token, channelName, {
|
||||
const payload = await startChannelConnect(tokenRef.current, channelName, {
|
||||
domain: startDomain,
|
||||
instanceId: startInstanceId,
|
||||
mode: startMode,
|
||||
@@ -147,7 +160,7 @@ export function ChannelQrConnectFlow({
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [channelName, startDomain, startForce, startInstanceId, startMode, token]);
|
||||
}, [channelName, startDomain, startForce, startInstanceId, startMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectRequestId || connectRequestId === handledRequestId) return;
|
||||
@@ -162,7 +175,11 @@ export function ChannelQrConnectFlow({
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await cancelChannelConnect(token, channelName, connect.session_id);
|
||||
const payload = await cancelChannelConnect(
|
||||
tokenRef.current,
|
||||
channelName,
|
||||
connect.session_id,
|
||||
);
|
||||
setConnect(payload);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
|
||||
@@ -496,7 +496,7 @@ interface PendingFirstMessage {
|
||||
}
|
||||
|
||||
interface InstalledSettingItemsOptions<Payload, Item> {
|
||||
token: string;
|
||||
getToken: () => string;
|
||||
eventName: string;
|
||||
fetchPayload: (token: string) => Promise<Payload>;
|
||||
isPayload: (value: unknown) => value is Payload;
|
||||
@@ -504,7 +504,7 @@ interface InstalledSettingItemsOptions<Payload, Item> {
|
||||
}
|
||||
|
||||
function useInstalledSettingItems<Payload, Item>({
|
||||
token,
|
||||
getToken,
|
||||
eventName,
|
||||
fetchPayload,
|
||||
isPayload,
|
||||
@@ -512,42 +512,65 @@ function useInstalledSettingItems<Payload, Item>({
|
||||
}: InstalledSettingItemsOptions<Payload, Item>): Item[] {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
|
||||
const refresh = useCallback(async (isCancelled?: () => boolean) => {
|
||||
try {
|
||||
const payload = await fetchPayload(token);
|
||||
if (!isCancelled?.()) setItems(selectItems(payload));
|
||||
} catch {
|
||||
// Keep the last successful catalog during transient focus/visibility refresh failures.
|
||||
}
|
||||
}, [fetchPayload, selectItems, token]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void refresh(() => cancelled);
|
||||
|
||||
const refreshOnFocus = () => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
void refresh();
|
||||
let refreshQueued = false;
|
||||
let refreshAfterFlight = false;
|
||||
let refreshing = false;
|
||||
let payloadVersion = 0;
|
||||
const refresh = async (): Promise<void> => {
|
||||
if (refreshing) return;
|
||||
refreshing = true;
|
||||
const version = payloadVersion;
|
||||
try {
|
||||
const payload = await fetchPayload(getToken());
|
||||
if (!cancelled && version === payloadVersion) {
|
||||
setItems(selectItems(payload));
|
||||
}
|
||||
} catch {
|
||||
// Keep the last successful catalog during transient refresh failures.
|
||||
} finally {
|
||||
refreshing = false;
|
||||
if (refreshAfterFlight && !cancelled) {
|
||||
refreshAfterFlight = false;
|
||||
void refresh();
|
||||
}
|
||||
}
|
||||
};
|
||||
const queueRefresh = () => {
|
||||
if (document.visibilityState === "hidden" || refreshQueued) return;
|
||||
refreshQueued = true;
|
||||
queueMicrotask(() => {
|
||||
refreshQueued = false;
|
||||
if (!cancelled) void refresh();
|
||||
});
|
||||
};
|
||||
void refresh();
|
||||
|
||||
const refreshOnChanged = (event: Event) => {
|
||||
const payload = (event as CustomEvent<unknown>).detail;
|
||||
if (isPayload(payload)) {
|
||||
payloadVersion += 1;
|
||||
setItems(selectItems(payload));
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
if (refreshing) {
|
||||
refreshAfterFlight = true;
|
||||
return;
|
||||
}
|
||||
queueRefresh();
|
||||
};
|
||||
|
||||
window.addEventListener("focus", refreshOnFocus);
|
||||
document.addEventListener("visibilitychange", refreshOnFocus);
|
||||
window.addEventListener("focus", queueRefresh);
|
||||
document.addEventListener("visibilitychange", queueRefresh);
|
||||
window.addEventListener(eventName, refreshOnChanged);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("focus", refreshOnFocus);
|
||||
document.removeEventListener("visibilitychange", refreshOnFocus);
|
||||
window.removeEventListener("focus", queueRefresh);
|
||||
document.removeEventListener("visibilitychange", queueRefresh);
|
||||
window.removeEventListener(eventName, refreshOnChanged);
|
||||
};
|
||||
}, [eventName, isPayload, refresh, selectItems]);
|
||||
}, [eventName, fetchPayload, getToken, isPayload, selectItems]);
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -581,6 +604,7 @@ export function ThreadShell({
|
||||
const {
|
||||
messages: historical,
|
||||
loading,
|
||||
error: historyError,
|
||||
loadingOlder,
|
||||
loadOlder,
|
||||
hasMoreBefore,
|
||||
@@ -594,19 +618,19 @@ export function ThreadShell({
|
||||
version: historyVersion,
|
||||
forkBoundaryMessageCount,
|
||||
} = useSessionHistory(historyKey);
|
||||
const { client, ingressLimits, modelName, token } = useClient();
|
||||
const { client, getToken, ingressLimits, modelName, token } = useClient();
|
||||
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
|
||||
const [booting, setBooting] = useState(false);
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||
const cliApps = useInstalledSettingItems({
|
||||
token,
|
||||
getToken,
|
||||
eventName: CLI_APPS_CHANGED_EVENT,
|
||||
fetchPayload: fetchInstalledCliApps,
|
||||
isPayload: isCliAppsPayload,
|
||||
selectItems: installedCliAppsFromPayload,
|
||||
});
|
||||
const mcpPresets = useInstalledSettingItems({
|
||||
token,
|
||||
getToken,
|
||||
eventName: MCP_PRESETS_CHANGED_EVENT,
|
||||
fetchPayload: fetchMcpPresets,
|
||||
isPayload: isMcpPresetsPayload,
|
||||
@@ -738,7 +762,7 @@ export function ThreadShell({
|
||||
}, [chatId, messagesReady, rememberedViewportTurnId, turnActive]);
|
||||
const filePreviewAvailabilityCache = useMemo(
|
||||
() => new Map<string, FilePreviewAvailabilityCacheEntry>(),
|
||||
[historyKey, token],
|
||||
[historyKey],
|
||||
);
|
||||
const filePreviewAvailabilityRevision = displayMessages.length;
|
||||
const resolveFilePreviewAvailability = useCallback((path: string) => {
|
||||
@@ -750,7 +774,7 @@ export function ThreadShell({
|
||||
) {
|
||||
return cached.promise;
|
||||
}
|
||||
const pending = fetchFilePreviewAvailability(token, historyKey, path).catch(
|
||||
const pending = fetchFilePreviewAvailability(getToken(), historyKey, path).catch(
|
||||
(error: unknown) => {
|
||||
if (error instanceof ApiError) {
|
||||
if (error.status === 404 && /API route not found/i.test(error.message)) {
|
||||
@@ -775,8 +799,8 @@ export function ThreadShell({
|
||||
}, [
|
||||
filePreviewAvailabilityCache,
|
||||
filePreviewAvailabilityRevision,
|
||||
getToken,
|
||||
historyKey,
|
||||
token,
|
||||
]);
|
||||
|
||||
const showHeroComposer = displayMessages.length === 0 && !loading;
|
||||
@@ -829,11 +853,11 @@ export function ThreadShell({
|
||||
|
||||
const refreshModelSettings = useCallback(async () => {
|
||||
try {
|
||||
setSettings(await fetchSettings(token));
|
||||
setSettings(await fetchSettings(getToken()));
|
||||
} catch {
|
||||
if (!settingsSnapshot) setSettings(null);
|
||||
}
|
||||
}, [settingsSnapshot, token]);
|
||||
}, [getToken, settingsSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsSnapshot) {
|
||||
@@ -1067,14 +1091,37 @@ export function ThreadShell({
|
||||
});
|
||||
}, [chatId, client, refreshCanonicalHistory]);
|
||||
|
||||
const wasPageHiddenRef = useRef(document.visibilityState === "hidden");
|
||||
useEffect(() => {
|
||||
const refreshOnReturn = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (document.visibilityState === "hidden") {
|
||||
wasPageHiddenRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (!wasPageHiddenRef.current) return;
|
||||
wasPageHiddenRef.current = false;
|
||||
if (!chatId || client.status !== "open" || loading) return;
|
||||
if (
|
||||
!turnActive
|
||||
&& !hasPendingToolCalls
|
||||
&& !client.hasUnsettledRun(chatId)
|
||||
&& !historyError
|
||||
) {
|
||||
return;
|
||||
}
|
||||
refreshCanonicalHistory();
|
||||
};
|
||||
document.addEventListener("visibilitychange", refreshOnReturn);
|
||||
return () => document.removeEventListener("visibilitychange", refreshOnReturn);
|
||||
}, [refreshCanonicalHistory]);
|
||||
}, [
|
||||
chatId,
|
||||
client,
|
||||
hasPendingToolCalls,
|
||||
historyError,
|
||||
loading,
|
||||
refreshCanonicalHistory,
|
||||
turnActive,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
let refreshOnNextOpen = client.status !== "open";
|
||||
@@ -1154,7 +1201,7 @@ export function ThreadShell({
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const commands = await listSlashCommands(token);
|
||||
const commands = await listSlashCommands(getToken());
|
||||
if (!cancelled) setSlashCommands(commands);
|
||||
} catch {
|
||||
if (!cancelled) setSlashCommands([]);
|
||||
@@ -1163,7 +1210,7 @@ export function ThreadShell({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token]);
|
||||
}, [getToken]);
|
||||
|
||||
const handleWelcomeSend = useCallback(
|
||||
async (content: string, images?: SendAttachment[], options?: SendOptions) => {
|
||||
|
||||
Reference in New Issue
Block a user