diff --git a/packages/vscode/src/AgentManagerPanelProvider.ts b/packages/vscode/src/AgentManagerPanelProvider.ts index 566f9679..e19ed9b7 100644 --- a/packages/vscode/src/AgentManagerPanelProvider.ts +++ b/packages/vscode/src/AgentManagerPanelProvider.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import { scheduleCachedStateRetries } from './webviewCachedStateRetry'; import { handleBridgeMessage, type BridgeRequest, type BridgeResponse } from './bridge'; import { getThemeKindName } from './theme'; import type { OpenCodeManager, ConnectionStatus } from './opencode'; @@ -23,6 +24,19 @@ export class AgentManagerPanelProvider { private _sseStreams = new Map(); private readonly _webviewDevServerUrl: string | null; + /** + * See webviewCachedStateRetry.ts — a single postMessage can be dropped + * before the webview bridge is ready, leaving the loading screen stuck. + */ + private _scheduleCachedStateRetries(targetPanel: vscode.WebviewPanel | undefined): void { + scheduleCachedStateRetries({ + target: targetPanel ?? this._panel, + getCurrent: () => this._panel, + isConnected: () => this._cachedStatus === 'connected', + send: () => this._sendCachedState(), + }); + } + constructor( private readonly _context: vscode.ExtensionContext, private readonly _extensionUri: vscode.Uri, @@ -64,6 +78,9 @@ export class AgentManagerPanelProvider { // Send cached connection status this._sendCachedState(); + // The webview bridge may not be ready yet; keep re-sending so a dropped + // `connectionStatus` can never leave the webview stuck on its loading screen. + this._scheduleCachedStateRetries(this._panel); // Handle panel disposal this._panel.onDidDispose(() => { @@ -126,6 +143,13 @@ export class AgentManagerPanelProvider { // Send to webview if it exists this._sendCachedState(); + + // When we become connected, keep re-sending at staggered delays so the + // webview cannot miss the transition (postMessage is dropped if the + // webview bridge is not ready yet). + if (status === 'connected') { + this._scheduleCachedStateRetries(this._panel); + } } public notifySettingsSynced(settings: unknown): void { diff --git a/packages/vscode/src/ChatViewProvider.ts b/packages/vscode/src/ChatViewProvider.ts index e6e7852c..ab547fa3 100644 --- a/packages/vscode/src/ChatViewProvider.ts +++ b/packages/vscode/src/ChatViewProvider.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import { scheduleCachedStateRetries } from './webviewCachedStateRetry'; import { handleBridgeMessage, type BridgeRequest, type BridgeResponse } from './bridge'; import { getThemeKindName } from './theme'; import type { OpenCodeManager, ConnectionStatus } from './opencode'; @@ -58,6 +59,19 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { private readonly _MESSAGE_TIMEOUT = 5000; // 5 seconds private readonly _MAX_RETRIES = 3; + /** + * See webviewCachedStateRetry.ts — a single postMessage can be dropped + * before the webview bridge is ready, leaving the loading screen stuck. + */ + private _scheduleCachedStateRetries(targetView: vscode.WebviewView | undefined): void { + scheduleCachedStateRetries({ + target: targetView ?? this._view, + getCurrent: () => this._view, + isConnected: () => this._cachedStatus === 'connected', + send: () => this._sendCachedState(), + }); + } + private _createMessageId(): string { return `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; } @@ -102,6 +116,9 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { // Send cached connection status and API URL (may have been set before webview was resolved) this._sendCachedState(); + // The webview bridge may not be ready yet; keep re-sending so a dropped + // `connectionStatus` can never leave the webview stuck on its loading screen. + this._scheduleCachedStateRetries(webviewView); // Send current active editor file state to the new webview this._lastActiveEditorFilePayload = null; @@ -185,6 +202,13 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { // Send to webview if it exists this._sendCachedState(); + + // When we become connected, keep re-sending at staggered delays so the + // webview cannot miss the transition (postMessage is dropped if the + // webview bridge is not ready yet). + if (status === 'connected') { + this._scheduleCachedStateRetries(this._view); + } } public addTextToInput(text: string) { diff --git a/packages/vscode/src/SessionEditorPanelProvider.ts b/packages/vscode/src/SessionEditorPanelProvider.ts index 76a82b02..6150e13d 100644 --- a/packages/vscode/src/SessionEditorPanelProvider.ts +++ b/packages/vscode/src/SessionEditorPanelProvider.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import { scheduleCachedStateRetries } from './webviewCachedStateRetry'; import { handleBridgeMessage, type BridgeRequest, type BridgeResponse } from './bridge'; import { getThemeKindName } from './theme'; import type { OpenCodeManager, ConnectionStatus } from './opencode'; @@ -49,6 +50,19 @@ export class SessionEditorPanelProvider { private _lastActiveEditorFilePayload: ActiveEditorFilePayload | null = null; private readonly _webviewDevServerUrl: string | null; + /** + * See webviewCachedStateRetry.ts — a single postMessage can be dropped + * before the webview bridge is ready, leaving the loading screen stuck. + */ + private _scheduleCachedStateRetries(panelId: string, entry: SessionPanelState): void { + scheduleCachedStateRetries({ + target: entry.panel, + getCurrent: () => this._panels.get(panelId)?.panel, + isConnected: () => this._cachedStatus === 'connected', + send: () => this._sendCachedStateToPanel(entry), + }); + } + constructor( private readonly _context: vscode.ExtensionContext, private readonly _extensionUri: vscode.Uri, @@ -116,6 +130,9 @@ export class SessionEditorPanelProvider { void this.updateTheme(vscode.window.activeColorTheme.kind); this._sendCachedStateToPanel(state); + // The webview bridge may not be ready yet; keep re-sending so a dropped + // `connectionStatus` can never leave the webview stuck on its loading screen. + this._scheduleCachedStateRetries(panelId, state); void this._broadcastActiveEditorFile(); panel.onDidDispose(() => { @@ -187,6 +204,15 @@ export class SessionEditorPanelProvider { for (const entry of this._panels.values()) { this._sendCachedStateToPanel(entry); } + + // When we become connected, keep re-sending at staggered delays so the + // webview cannot miss the transition (postMessage is dropped if the + // webview bridge is not ready yet). + if (status === 'connected') { + for (const [panelId, entry] of this._panels.entries()) { + this._scheduleCachedStateRetries(panelId, entry); + } + } } public notifySettingsSynced(settings: unknown): void { diff --git a/packages/vscode/src/webviewCachedStateRetry.ts b/packages/vscode/src/webviewCachedStateRetry.ts new file mode 100644 index 00000000..f750c344 --- /dev/null +++ b/packages/vscode/src/webviewCachedStateRetry.ts @@ -0,0 +1,31 @@ +/** + * The webview only leaves its initial loading screen once it receives a + * `connectionStatus: connected` message. VS Code drops postMessage calls made + * before the webview's acquireVsCodeApi bridge is ready (common in + * code-server / slow or flaky networks), so a single send can be lost + * forever. Re-sending the cached state at staggered delays bounds the wait + * without needing a webview-side ack protocol; the payload is idempotent + * (connection status + window focus), so duplicate deliveries are harmless. + */ +const CACHED_STATE_RETRY_DELAYS_MS = [500, 1500, 3500, 7000, 12000, 20000]; + +export function scheduleCachedStateRetries(input: { + /** The panel/view the retries belong to. */ + target: Target | undefined; + /** Reads the provider's CURRENT panel/view, so a replaced target stops its stale retries. */ + getCurrent: () => Target | undefined; + /** Retries only make sense for the connected transition. */ + isConnected: () => boolean; + /** Re-sends the provider's cached state. */ + send: () => void; +}): void { + if (!input.isConnected()) return; + const target = input.target; + if (!target) return; + for (const delayMs of CACHED_STATE_RETRY_DELAYS_MS) { + setTimeout(() => { + if (input.getCurrent() !== target) return; + input.send(); + }, delayMs); + } +}