fix(frontend): stabilize model load error feedback (#5021)

This commit is contained in:
Stellar鱼 2026-08-30 10:56:55 +08:00 committed by GitHub
parent e12925458a
commit 468eab4b5d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 60 additions and 11 deletions

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useRef, useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -23,7 +23,17 @@ export function ModelLoadErrorBanner({
const [isRetrying, setIsRetrying] = useState(false); const [isRetrying, setIsRetrying] = useState(false);
// Observe the shared query without starting it. Model consumers remain in // Observe the shared query without starting it. Model consumers remain in
// charge of loading; this single observer only centralizes their feedback. // charge of loading; this single observer only centralizes their feedback.
const { error, refetch } = useModels({ enabled: false }); const { error, isFetching, refetch } = useModels({ enabled: false });
const visibleErrorRef = useRef<Error | null>(null);
// TanStack clears an empty query's error while another observer refetches.
// Keep the banner stable until that shared request either succeeds or fails.
if (error) {
visibleErrorRef.current = error;
} else if (!isFetching) {
visibleErrorRef.current = null;
}
const visibleError = error ?? visibleErrorRef.current;
const retry = async () => { const retry = async () => {
setIsRetrying(true); setIsRetrying(true);
@ -38,8 +48,8 @@ export function ModelLoadErrorBanner({
// Rendering a model-specific warning during navigation would be duplicate // Rendering a model-specific warning during navigation would be duplicate
// and misleading feedback. // and misleading feedback.
if ( if (
(!error && !isRetrying) || (!visibleError && !isRetrying) ||
error instanceof UnauthorizedError || visibleError instanceof UnauthorizedError ||
shouldShowOfflineBanner(user, gatewayUnavailable) shouldShowOfflineBanner(user, gatewayUnavailable)
) { ) {
return null; return null;

View File

@ -1,14 +1,20 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { UnauthorizedError } from "@/core/api/errors";
import { loadModels } from "./api"; import { loadModels } from "./api";
export const MODELS_QUERY_KEY = ["models"] as const; export const MODELS_QUERY_KEY = ["models"] as const;
export function useModels({ enabled = true }: { enabled?: boolean } = {}) { export function useModels({ enabled = true }: { enabled?: boolean } = {}) {
const { data, isLoading, error, refetch } = useQuery({ const { data, isLoading, isFetching, error, refetch } = useQuery({
queryKey: MODELS_QUERY_KEY, queryKey: MODELS_QUERY_KEY,
queryFn: () => loadModels(), queryFn: () => loadModels(),
enabled, enabled,
// Surface persistent gateway failures promptly while retaining one retry
// for transient startup or network errors.
retry: (failureCount, queryError) =>
!(queryError instanceof UnauthorizedError) && failureCount < 1,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
// Model config changes rarely and every subtask card mounts its own // Model config changes rarely and every subtask card mounts its own
// observer of this query; without a staleTime each newly-mounted card would // observer of this query; without a staleTime each newly-mounted card would
@ -21,6 +27,7 @@ export function useModels({ enabled = true }: { enabled?: boolean } = {}) {
models: data?.models ?? [], models: data?.models ?? [],
tokenUsageEnabled: data?.token_usage.enabled ?? false, tokenUsageEnabled: data?.token_usage.enabled ?? false,
isLoading, isLoading,
isFetching,
error, error,
refetch, refetch,
}; };

View File

@ -76,7 +76,7 @@ afterEach(() => {
function createWrapper() { function createWrapper() {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { retry: false }, queries: { retry: false, retryDelay: 0 },
}, },
}); });
@ -105,9 +105,38 @@ describe("ModelLoadErrorBanner", () => {
expect(screen.queryByRole("alert")).toBeNull(); expect(screen.queryByRole("alert")).toBeNull();
}); });
it("limits automatic model loading retries before surfacing the error", async () => {
mockedLoadModels.mockRejectedValue(new Error("Gateway returned 503"));
const queryClient = new QueryClient({
defaultOptions: {
queries: { retryDelay: 0 },
},
});
function RetryPolicyWrapper({ children }: PropsWithChildren) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
render(
<>
<ModelLoadErrorBanner />
<ModelConsumer />
</>,
{ wrapper: RetryPolicyWrapper },
);
expect(await screen.findByRole("alert")).not.toBeNull();
expect(mockedLoadModels).toHaveBeenCalledTimes(2);
});
it("shows one actionable error for all model consumers and clears after retry", async () => { it("shows one actionable error for all model consumers and clears after retry", async () => {
const retryResult = createDeferred<ModelsResponse>(); const retryResult = createDeferred<ModelsResponse>();
mockedLoadModels mockedLoadModels
.mockRejectedValueOnce(new Error("Gateway returned 503"))
.mockRejectedValueOnce(new Error("Gateway returned 503")) .mockRejectedValueOnce(new Error("Gateway returned 503"))
.mockImplementationOnce(() => retryResult.promise); .mockImplementationOnce(() => retryResult.promise);
const { QueryWrapper } = createWrapper(); const { QueryWrapper } = createWrapper();
@ -125,7 +154,7 @@ describe("ModelLoadErrorBanner", () => {
expect(alert.textContent).toContain("Models couldn't be loaded"); expect(alert.textContent).toContain("Models couldn't be loaded");
expect(alert.textContent).not.toContain("Gateway returned 503"); expect(alert.textContent).not.toContain("Gateway returned 503");
expect(screen.getAllByRole("alert")).toHaveLength(1); expect(screen.getAllByRole("alert")).toHaveLength(1);
expect(mockedLoadModels).toHaveBeenCalledTimes(1); expect(mockedLoadModels).toHaveBeenCalledTimes(2);
fireEvent.click(screen.getByRole("button", { name: "Retry" })); fireEvent.click(screen.getByRole("button", { name: "Retry" }));
@ -142,7 +171,7 @@ describe("ModelLoadErrorBanner", () => {
await waitFor(() => { await waitFor(() => {
expect(screen.queryByRole("alert")).toBeNull(); expect(screen.queryByRole("alert")).toBeNull();
}); });
expect(mockedLoadModels).toHaveBeenCalledTimes(2); expect(mockedLoadModels).toHaveBeenCalledTimes(3);
}); });
it("does not duplicate the login redirect with a model warning", async () => { it("does not duplicate the login redirect with a model warning", async () => {
@ -160,12 +189,13 @@ describe("ModelLoadErrorBanner", () => {
await waitFor(() => { await waitFor(() => {
expect(queryClient.getQueryState(MODELS_QUERY_KEY)?.status).toBe("error"); expect(queryClient.getQueryState(MODELS_QUERY_KEY)?.status).toBe("error");
}); });
expect(mockedLoadModels).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("alert")).toBeNull(); expect(screen.queryByRole("alert")).toBeNull();
}); });
it("suppresses a model symptom only while the gateway banner is visible", async () => { it("suppresses a model symptom only while the gateway banner is visible", async () => {
mockedUseAuth.mockReturnValue(createAuthState(null)); mockedUseAuth.mockReturnValue(createAuthState(null));
mockedLoadModels.mockRejectedValueOnce(new Error("Gateway returned 503")); mockedLoadModels.mockRejectedValue(new Error("Gateway returned 503"));
const { queryClient, QueryWrapper } = createWrapper(); const { queryClient, QueryWrapper } = createWrapper();
const renderView = () => ( const renderView = () => (
@ -187,9 +217,10 @@ describe("ModelLoadErrorBanner", () => {
expect(await screen.findByRole("alert")).not.toBeNull(); expect(await screen.findByRole("alert")).not.toBeNull();
}); });
it("does not show manual retry progress for a shared background refetch", async () => { it("keeps the error visible without showing manual retry progress during a shared refetch", async () => {
const backgroundResult = createDeferred<ModelsResponse>(); const backgroundResult = createDeferred<ModelsResponse>();
mockedLoadModels mockedLoadModels
.mockRejectedValueOnce(new Error("Gateway returned 503"))
.mockRejectedValueOnce(new Error("Gateway returned 503")) .mockRejectedValueOnce(new Error("Gateway returned 503"))
.mockImplementationOnce(() => backgroundResult.promise); .mockImplementationOnce(() => backgroundResult.promise);
const { queryClient, QueryWrapper } = createWrapper(); const { queryClient, QueryWrapper } = createWrapper();
@ -207,9 +238,10 @@ describe("ModelLoadErrorBanner", () => {
queryKey: MODELS_QUERY_KEY, queryKey: MODELS_QUERY_KEY,
}); });
await waitFor(() => { await waitFor(() => {
expect(mockedLoadModels).toHaveBeenCalledTimes(2); expect(mockedLoadModels).toHaveBeenCalledTimes(3);
}); });
expect(screen.getByRole("alert")).not.toBeNull();
expect(screen.queryByRole("button", { name: "Retrying…" })).toBeNull(); expect(screen.queryByRole("button", { name: "Retrying…" })).toBeNull();
backgroundResult.resolve({ backgroundResult.resolve({