From b6e94366df5337bd3f26e203ebbbe3ef3a2dcead Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sun, 3 May 2026 18:15:31 +0000 Subject: [PATCH] fix: show loading view with retry instead of grey screen when state not hydrated Replace `return null` with a LoadingView component when the webview has not yet received its initial state from the extension host. This prevents users from seeing a blank grey panel (#11931). The LoadingView shows a spinner while waiting, automatically retries the webviewDidLaunch message up to 3 times (every 5 seconds), and displays a manual "Retry Connection" button after all retries are exhausted. --- webview-ui/src/App.tsx | 3 +- webview-ui/src/components/LoadingView.tsx | 73 +++++++++++++ .../components/__tests__/LoadingView.spec.tsx | 100 ++++++++++++++++++ webview-ui/src/i18n/locales/en/common.json | 5 +- 4 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 webview-ui/src/components/LoadingView.tsx create mode 100644 webview-ui/src/components/__tests__/LoadingView.spec.tsx diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index cccb0422ca8..14aa30d773f 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -19,6 +19,7 @@ import { MarketplaceView } from "./components/marketplace/MarketplaceView" import { CheckpointRestoreDialog } from "./components/chat/CheckpointRestoreDialog" import { DeleteMessageDialog, EditMessageDialog } from "./components/chat/MessageModificationConfirmationDialog" import ErrorBoundary from "./components/ErrorBoundary" +import LoadingView from "./components/LoadingView" import { CloudView } from "./components/cloud/CloudView" import { useAddNonInteractiveClickListener } from "./components/ui/hooks/useNonInteractiveClick" import { TooltipProvider } from "./components/ui/tooltip" @@ -217,7 +218,7 @@ const App = () => { }, [tab]) if (!didHydrateState) { - return null + return } // Do not conditionally load ChatView, it's expensive and there's state we diff --git a/webview-ui/src/components/LoadingView.tsx b/webview-ui/src/components/LoadingView.tsx new file mode 100644 index 00000000000..749acc22f68 --- /dev/null +++ b/webview-ui/src/components/LoadingView.tsx @@ -0,0 +1,73 @@ +import React, { useEffect, useState, useCallback } from "react" +import { useAppTranslation } from "../i18n/TranslationContext" +import { vscode } from "../utils/vscode" + +const RETRY_INTERVAL_MS = 5_000 +const MAX_RETRIES = 3 + +/** + * LoadingView is displayed while the webview waits for the extension host to + * send the initial state hydration message. It replaces the previous + * `return null` which left users staring at a blank grey panel (see #11931). + * + * If the state message does not arrive within {@link RETRY_INTERVAL_MS} the + * component automatically re-sends the `webviewDidLaunch` message up to + * {@link MAX_RETRIES} times, after which a manual "Retry" button is shown. + */ +export default function LoadingView() { + const { t } = useAppTranslation() + const [retryCount, setRetryCount] = useState(0) + const [showRetryButton, setShowRetryButton] = useState(false) + + const retry = useCallback(() => { + vscode.postMessage({ type: "webviewDidLaunch" }) + setRetryCount((prev) => prev + 1) + }, []) + + // Automatic retries on a timer + useEffect(() => { + if (showRetryButton) { + return // Stop auto-retrying once we're showing the manual button. + } + + const timer = setTimeout(() => { + if (retryCount < MAX_RETRIES) { + retry() + } else { + setShowRetryButton(true) + } + }, RETRY_INTERVAL_MS) + + return () => clearTimeout(timer) + }, [retryCount, showRetryButton, retry]) + + return ( +
+
+
+ {!showRetryButton ? ( +
+ + {t("common:ui.initializing")} +
+ ) : ( +
+

+ {t("common:ui.connection_failed")} +

+ +
+ )} +
+
+
+ ) +} diff --git a/webview-ui/src/components/__tests__/LoadingView.spec.tsx b/webview-ui/src/components/__tests__/LoadingView.spec.tsx new file mode 100644 index 00000000000..faa88bd5bf0 --- /dev/null +++ b/webview-ui/src/components/__tests__/LoadingView.spec.tsx @@ -0,0 +1,100 @@ +// npx vitest run src/components/__tests__/LoadingView.spec.tsx + +import React from "react" +import { render, screen, act } from "@testing-library/react" +import LoadingView from "../LoadingView" + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + "common:ui.initializing": "Initializing...", + "common:ui.retry_connection": "Retry Connection", + "common:ui.connection_failed": + "Unable to connect to the extension host. Click the button below to retry.", + } + return translations[key] ?? key + }, + }), +})) + +describe("LoadingView", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("renders a spinner and initializing text", () => { + render() + expect(screen.getByText("Initializing...")).toBeInTheDocument() + }) + + it("does not show retry button initially", () => { + render() + expect(screen.queryByText("Retry Connection")).not.toBeInTheDocument() + }) + + it("retries webviewDidLaunch after timeout", async () => { + const { vscode } = await import("@src/utils/vscode") + render() + + // Advance past the first retry interval (5s) + act(() => { + vi.advanceTimersByTime(5_000) + }) + + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "webviewDidLaunch" }) + }) + + it("shows retry button after max retries", async () => { + render() + + // Advance through all 3 retries (5s each) + one more to trigger the button + for (let i = 0; i < 4; i++) { + act(() => { + vi.advanceTimersByTime(5_000) + }) + } + + expect(screen.getByText("Retry Connection")).toBeInTheDocument() + expect( + screen.getByText("Unable to connect to the extension host. Click the button below to retry."), + ).toBeInTheDocument() + }) + + it("allows manual retry when button is clicked", async () => { + const { vscode } = await import("@src/utils/vscode") + render() + + // Advance through all retries to show the button + for (let i = 0; i < 4; i++) { + act(() => { + vi.advanceTimersByTime(5_000) + }) + } + + const calls = (vscode.postMessage as ReturnType).mock.calls.length + + const retryButton = screen.getByText("Retry Connection") + act(() => { + retryButton.click() + }) + + // Should have sent another webviewDidLaunch + expect((vscode.postMessage as ReturnType).mock.calls.length).toBeGreaterThan(calls) + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "webviewDidLaunch" }) + + // Should go back to showing the spinner + expect(screen.getByText("Initializing...")).toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/i18n/locales/en/common.json b/webview-ui/src/i18n/locales/en/common.json index 981eaeec755..26ff6f1d74f 100644 --- a/webview-ui/src/i18n/locales/en/common.json +++ b/webview-ui/src/i18n/locales/en/common.json @@ -21,7 +21,10 @@ }, "ui": { "search_placeholder": "Search...", - "no_results": "No results found" + "no_results": "No results found", + "initializing": "Initializing...", + "retry_connection": "Retry Connection", + "connection_failed": "Unable to connect to the extension host. Click the button below to retry." }, "mermaid": { "loading": "Generating mermaid diagram...",