mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 10:11:46 +03:00
fix(webui): prevent redundant thread and media reloads (#5164)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
||||
import { fetchSessionAutomations } from "@/lib/api";
|
||||
@@ -12,20 +12,25 @@ export function useSessionAutomationJobs(open: boolean, token: string, sessionKe
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const tokenRef = useRef(token);
|
||||
tokenRef.current = token;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !pageVisible) return;
|
||||
let cancelled = false;
|
||||
let loadedOnce = false;
|
||||
let refreshing = false;
|
||||
|
||||
const refresh = async (showLoading = false) => {
|
||||
if (refreshing) return;
|
||||
refreshing = true;
|
||||
if (showLoading) {
|
||||
setLoading(true);
|
||||
setLoadFailed(false);
|
||||
setJobs([]);
|
||||
}
|
||||
try {
|
||||
const next = await fetchSessionAutomations(token, sessionKey);
|
||||
const next = await fetchSessionAutomations(tokenRef.current, sessionKey);
|
||||
if (cancelled) return;
|
||||
setJobs(next.jobs);
|
||||
setLoadFailed(false);
|
||||
@@ -33,6 +38,7 @@ export function useSessionAutomationJobs(open: boolean, token: string, sessionKe
|
||||
} catch {
|
||||
if (!cancelled && !loadedOnce) setLoadFailed(true);
|
||||
} finally {
|
||||
refreshing = false;
|
||||
if (!cancelled && showLoading) setLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -46,7 +52,7 @@ export function useSessionAutomationJobs(open: boolean, token: string, sessionKe
|
||||
window.clearInterval(refreshId);
|
||||
window.removeEventListener("focus", refreshOnFocus);
|
||||
};
|
||||
}, [open, pageVisible, sessionKey, token]);
|
||||
}, [open, pageVisible, sessionKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !pageVisible) return;
|
||||
|
||||
@@ -24,6 +24,15 @@ const INITIAL_HISTORY_PAGE_LIMIT = 160;
|
||||
const OLDER_HISTORY_PAGE_LIMIT = 120;
|
||||
const CHAT_CREATE_TIMEOUT_MS = 60_000;
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object"
|
||||
&& error !== null
|
||||
&& "name" in error
|
||||
&& error.name === "AbortError"
|
||||
);
|
||||
}
|
||||
|
||||
export type SessionHistoryContinuity = "initial" | "overlap" | "reset";
|
||||
|
||||
function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] {
|
||||
@@ -132,32 +141,46 @@ export function useSessions(): {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const tokenRef = useRef(token);
|
||||
const optimisticKeysRef = useRef<Set<string>>(new Set());
|
||||
const refreshPendingRef = useRef(false);
|
||||
const refreshInFlightRef = useRef<Promise<void> | null>(null);
|
||||
tokenRef.current = token;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const refresh = useCallback((): Promise<void> => {
|
||||
refreshPendingRef.current = true;
|
||||
if (refreshInFlightRef.current) return refreshInFlightRef.current;
|
||||
const request = (async () => {
|
||||
setLoading(true);
|
||||
const rows = await listSessions(tokenRef.current);
|
||||
const serverKeys = new Set(rows.map((row) => row.key));
|
||||
setSessions((prev) => [
|
||||
...rows,
|
||||
...prev.filter(
|
||||
(session) =>
|
||||
optimisticKeysRef.current.has(session.key) &&
|
||||
!serverKeys.has(session.key),
|
||||
),
|
||||
]);
|
||||
for (const key of Array.from(optimisticKeysRef.current)) {
|
||||
if (serverKeys.has(key)) optimisticKeysRef.current.delete(key);
|
||||
try {
|
||||
while (refreshPendingRef.current) {
|
||||
refreshPendingRef.current = false;
|
||||
try {
|
||||
const rows = await listSessions(tokenRef.current);
|
||||
const serverKeys = new Set(rows.map((row) => row.key));
|
||||
setSessions((prev) => [
|
||||
...rows,
|
||||
...prev.filter(
|
||||
(session) =>
|
||||
optimisticKeysRef.current.has(session.key)
|
||||
&& !serverKeys.has(session.key),
|
||||
),
|
||||
]);
|
||||
for (const key of Array.from(optimisticKeysRef.current)) {
|
||||
if (serverKeys.has(key)) optimisticKeysRef.current.delete(key);
|
||||
}
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof ApiError ? `HTTP ${e.status}` : (e as Error).message;
|
||||
setError(msg);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
refreshInFlightRef.current = null;
|
||||
setLoading(false);
|
||||
}
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof ApiError ? `HTTP ${e.status}` : (e as Error).message;
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
refreshInFlightRef.current = request;
|
||||
return request;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -165,9 +188,20 @@ export function useSessions(): {
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
return client.onSessionUpdate(() => {
|
||||
void refresh();
|
||||
let disposed = false;
|
||||
let refreshQueued = false;
|
||||
const unsubscribe = client.onSessionUpdate(() => {
|
||||
if (refreshQueued) return;
|
||||
refreshQueued = true;
|
||||
queueMicrotask(() => {
|
||||
refreshQueued = false;
|
||||
if (!disposed) void refresh();
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [client, refresh]);
|
||||
|
||||
const createChat = useCallback(async (workspaceScope?: WorkspaceScopePayload | null): Promise<string> => {
|
||||
@@ -272,8 +306,9 @@ export function useSessionHistory(key: string | null): {
|
||||
/** Exact active turn when supplied by a current gateway. */
|
||||
activeTurnId: string | null;
|
||||
} {
|
||||
const { token } = useClient();
|
||||
const { getToken } = useClient();
|
||||
const loadingOlderRef = useRef(false);
|
||||
const olderRequestAbortRef = useRef<AbortController | null>(null);
|
||||
const historyVersionRef = useRef(0);
|
||||
const [refreshSeq, setRefreshSeq] = useState(0);
|
||||
const refresh = useCallback(() => {
|
||||
@@ -313,8 +348,17 @@ export function useSessionHistory(key: string | null): {
|
||||
activeTurnId: null,
|
||||
});
|
||||
|
||||
useEffect(() => () => {
|
||||
olderRequestAbortRef.current?.abort();
|
||||
olderRequestAbortRef.current = null;
|
||||
loadingOlderRef.current = false;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!key) {
|
||||
olderRequestAbortRef.current?.abort();
|
||||
olderRequestAbortRef.current = null;
|
||||
loadingOlderRef.current = false;
|
||||
setState({
|
||||
key: null,
|
||||
messages: [],
|
||||
@@ -335,6 +379,10 @@ export function useSessionHistory(key: string | null): {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
olderRequestAbortRef.current?.abort();
|
||||
olderRequestAbortRef.current = null;
|
||||
loadingOlderRef.current = false;
|
||||
// Mark the new key as loading immediately so callers never see stale
|
||||
// messages from the previous session during the render right after a switch.
|
||||
setState((prev) => prev.key === key
|
||||
@@ -358,9 +406,10 @@ export function useSessionHistory(key: string | null): {
|
||||
});
|
||||
(async () => {
|
||||
try {
|
||||
const body = await fetchWebuiThread(token, key, {
|
||||
const body = await fetchWebuiThread(getToken(), key, {
|
||||
limit: INITIAL_HISTORY_PAGE_LIMIT,
|
||||
direction: "latest",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (cancelled) return;
|
||||
historyVersionRef.current += 1;
|
||||
@@ -414,7 +463,7 @@ export function useSessionHistory(key: string | null): {
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
if (cancelled || isAbortError(e)) return;
|
||||
if (e instanceof ApiError && e.status === 404) {
|
||||
historyVersionRef.current += 1;
|
||||
const responseVersion = historyVersionRef.current;
|
||||
@@ -463,8 +512,9 @@ export function useSessionHistory(key: string | null): {
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [key, token, refreshSeq]);
|
||||
}, [getToken, key, refreshSeq]);
|
||||
|
||||
const loadOlder = useCallback(async () => {
|
||||
if (!key || loadingOlderRef.current) return;
|
||||
@@ -478,13 +528,16 @@ export function useSessionHistory(key: string | null): {
|
||||
&& candidate.beforeCursor === beforeCursor
|
||||
);
|
||||
loadingOlderRef.current = true;
|
||||
const controller = new AbortController();
|
||||
olderRequestAbortRef.current = controller;
|
||||
setState((prev) => matchesRequest(prev)
|
||||
? { ...prev, loadingOlder: true, error: null }
|
||||
: prev);
|
||||
try {
|
||||
const body = await fetchWebuiThread(token, requestKey, {
|
||||
const body = await fetchWebuiThread(getToken(), requestKey, {
|
||||
limit: OLDER_HISTORY_PAGE_LIMIT,
|
||||
before: beforeCursor,
|
||||
signal: controller.signal,
|
||||
});
|
||||
setState((prev) => {
|
||||
if (!matchesRequest(prev)) return prev;
|
||||
@@ -518,6 +571,7 @@ export function useSessionHistory(key: string | null): {
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) return;
|
||||
setState((prev) => matchesRequest(prev)
|
||||
? {
|
||||
...prev,
|
||||
@@ -526,7 +580,10 @@ export function useSessionHistory(key: string | null): {
|
||||
}
|
||||
: prev);
|
||||
} finally {
|
||||
loadingOlderRef.current = false;
|
||||
if (olderRequestAbortRef.current === controller) {
|
||||
olderRequestAbortRef.current = null;
|
||||
loadingOlderRef.current = false;
|
||||
}
|
||||
}
|
||||
}, [
|
||||
key,
|
||||
@@ -534,7 +591,7 @@ export function useSessionHistory(key: string | null): {
|
||||
state.hasMoreBefore,
|
||||
state.key,
|
||||
state.lineage,
|
||||
token,
|
||||
getToken,
|
||||
]);
|
||||
|
||||
if (!key) {
|
||||
|
||||
@@ -4,19 +4,28 @@ import { fetchSkills } from "@/lib/api";
|
||||
import { isSkillsPayload, SKILLS_CHANGED_EVENT } from "@/lib/skill-events";
|
||||
import type { SkillSummary } from "@/lib/types";
|
||||
|
||||
export function useSkills(token: string): SkillSummary[] {
|
||||
export function useSkills(getToken: () => string): SkillSummary[] {
|
||||
const [skills, setSkills] = useState<SkillSummary[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let payloadVersion = 0;
|
||||
const refresh = () => {
|
||||
fetchSkills(token)
|
||||
.then(({ skills: nextSkills }) => !cancelled && setSkills(nextSkills))
|
||||
.catch(() => !cancelled && setSkills([]));
|
||||
const version = payloadVersion;
|
||||
fetchSkills(getToken())
|
||||
.then(({ skills: nextSkills }) => {
|
||||
if (!cancelled && version === payloadVersion) setSkills(nextSkills);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled && version === payloadVersion) setSkills([]);
|
||||
});
|
||||
};
|
||||
const onSkillsChanged = (event: Event) => {
|
||||
const payload = (event as CustomEvent<unknown>).detail;
|
||||
if (!cancelled && isSkillsPayload(payload)) setSkills(payload.skills);
|
||||
if (!cancelled && isSkillsPayload(payload)) {
|
||||
payloadVersion += 1;
|
||||
setSkills(payload.skills);
|
||||
}
|
||||
};
|
||||
|
||||
refresh();
|
||||
@@ -25,7 +34,7 @@ export function useSkills(token: string): SkillSummary[] {
|
||||
cancelled = true;
|
||||
window.removeEventListener(SKILLS_CHANGED_EVENT, onSkillsChanged);
|
||||
};
|
||||
}, [token]);
|
||||
}, [getToken]);
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user