Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion webview-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions webview-ui/src/components/LoadingView.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
100 changes: 100 additions & 0 deletions webview-ui/src/components/__tests__/LoadingView.spec.tsx
Original file line number Diff line number Diff line change
@@ -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()
})
})
5 changes: 4 additions & 1 deletion webview-ui/src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand Down
Loading