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...",