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.
This commit is contained in:
Roo Code 2026-05-03 18:15:31 +00:00
parent ad25634905
commit b6e94366df
4 changed files with 179 additions and 2 deletions

View file

@ -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 <LoadingView />
}
// Do not conditionally load ChatView, it's expensive and there's state we

View file

@ -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 (
<div className="absolute inset-0 flex flex-col bg-vscode-editor-background text-vscode-foreground">
<div className="flex-1 flex items-center justify-center px-6">
<div className="flex flex-col items-center gap-5 text-center">
{!showRetryButton ? (
<div className="flex items-center gap-2 text-sm text-vscode-descriptionForeground">
<span className="codicon codicon-loading codicon-modifier-spin text-base" />
<span>{t("common:ui.initializing")}</span>
</div>
) : (
<div className="flex flex-col items-center gap-3">
<p className="text-sm text-vscode-descriptionForeground">
{t("common:ui.connection_failed")}
</p>
<button
className="px-4 py-1.5 rounded text-sm font-medium bg-vscode-button-background text-vscode-button-foreground hover:bg-vscode-button-hoverBackground"
onClick={() => {
setRetryCount(0)
setShowRetryButton(false)
retry()
}}>
{t("common:ui.retry_connection")}
</button>
</div>
)}
</div>
</div>
</div>
)
}

View file

@ -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<string, string> = {
"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(<LoadingView />)
expect(screen.getByText("Initializing...")).toBeInTheDocument()
})
it("does not show retry button initially", () => {
render(<LoadingView />)
expect(screen.queryByText("Retry Connection")).not.toBeInTheDocument()
})
it("retries webviewDidLaunch after timeout", async () => {
const { vscode } = await import("@src/utils/vscode")
render(<LoadingView />)
// 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(<LoadingView />)
// 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(<LoadingView />)
// 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<typeof vi.fn>).mock.calls.length
const retryButton = screen.getByText("Retry Connection")
act(() => {
retryButton.click()
})
// Should have sent another webviewDidLaunch
expect((vscode.postMessage as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThan(calls)
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "webviewDidLaunch" })
// Should go back to showing the spinner
expect(screen.getByText("Initializing...")).toBeInTheDocument()
})
})

View file

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