From 4f0e1d1e4f4d332f8095147d7558b87d688bc4a3 Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Wed, 20 May 2026 18:26:34 +0200 Subject: [PATCH 1/6] feat: show connection error screen when org auth fails or org is unavailable Instead of silently showing "No agents available" when the org connection fails, display a dedicated error screen with a "Select Another Org" button that opens the org picker. Title bar icons are hidden during this state. --- package.json | 18 +++++------ .../handlers/webviewMessageHandlers.ts | 25 +++++++++++++++- .../handlers/webviewMessageSender.ts | 6 ++++ .../agentCombined/state/agentViewState.ts | 4 +++ webview/src/App.tsx | 30 +++++++++++++++++++ 5 files changed, 73 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index ee3cdfd0..47ac695c 100644 --- a/package.json +++ b/package.json @@ -180,47 +180,47 @@ "view/title": [ { "command": "sf.agent.combined.view.exportConversation", - "when": "view == sf.agent.combined.view && agentforceDX:agentSelected && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && !agentforceDX:sessionError && agentforceDX:hasConversationData", + "when": "view == sf.agent.combined.view && !agentforceDX:authError && agentforceDX:agentSelected && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && !agentforceDX:sessionError && agentforceDX:hasConversationData", "group": "navigation@3" }, { "command": "salesforcedx-vscode-agents.activateAgent", - "when": "view == sf.agent.combined.view && !agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && !agentforceDX:canResetAgentView && !agentforceDX:agentSelected", + "when": "view == sf.agent.combined.view && !agentforceDX:authError && !agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && !agentforceDX:canResetAgentView && !agentforceDX:agentSelected", "group": "navigation@1" }, { "command": "sf.agent.combined.view.activateVersion", - "when": "view == sf.agent.combined.view && agentforceDX:agentSelected && !agentforceDX:isScriptAgent && !agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && !agentforceDX:sessionError", + "when": "view == sf.agent.combined.view && !agentforceDX:authError && agentforceDX:agentSelected && !agentforceDX:isScriptAgent && !agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && !agentforceDX:sessionError", "group": "navigation@2" }, { "command": "sf.agent.combined.view.clearLoadedSession", - "when": "view == sf.agent.combined.view && agentforceDX:hasLoadedSession && !agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && !agentforceDX:canResetAgentView && !agentforceDX:sessionError", + "when": "view == sf.agent.combined.view && !agentforceDX:authError && agentforceDX:hasLoadedSession && !agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && !agentforceDX:canResetAgentView && !agentforceDX:sessionError", "group": "navigation@4" }, { "command": "sf.agent.combined.view.refreshAgents", - "when": "view == sf.agent.combined.view && !agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && !agentforceDX:canResetAgentView", + "when": "view == sf.agent.combined.view && !agentforceDX:authError && !agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && !agentforceDX:canResetAgentView", "group": "navigation@0" }, { "command": "sf.agent.combined.view.resetAgentView", - "when": "view == sf.agent.combined.view && !agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && agentforceDX:agentSelected && agentforceDX:canResetAgentView", + "when": "view == sf.agent.combined.view && !agentforceDX:authError && !agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && agentforceDX:agentSelected && agentforceDX:canResetAgentView", "group": "navigation@0" }, { "submenu": "sf.agent.combined.view.restartMenu", - "when": "view == sf.agent.combined.view && agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && agentforceDX:agentSelected && agentforceDX:isScriptAgent", + "when": "view == sf.agent.combined.view && !agentforceDX:authError && agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && agentforceDX:agentSelected && agentforceDX:isScriptAgent", "group": "navigation@-3" }, { "command": "sf.agent.combined.view.debug", - "when": "view == sf.agent.combined.view && agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && !agentforceDX:debugMode && agentforceDX:isLiveMode", + "when": "view == sf.agent.combined.view && !agentforceDX:authError && agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && !agentforceDX:debugMode && agentforceDX:isLiveMode", "group": "navigation@-2" }, { "command": "sf.agent.combined.view.debugStop", - "when": "view == sf.agent.combined.view && agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && agentforceDX:debugMode && agentforceDX:isLiveMode", + "when": "view == sf.agent.combined.view && !agentforceDX:authError && agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && agentforceDX:debugMode && agentforceDX:isLiveMode", "group": "navigation@-2" }, { diff --git a/src/views/agentCombined/handlers/webviewMessageHandlers.ts b/src/views/agentCombined/handlers/webviewMessageHandlers.ts index 2a3d917d..26d5278c 100644 --- a/src/views/agentCombined/handlers/webviewMessageHandlers.ts +++ b/src/views/agentCombined/handlers/webviewMessageHandlers.ts @@ -312,6 +312,7 @@ export class WebviewMessageHandlers { this.messageSender.sendAvailableAgents(agentsWithVersions, selectAgentId); // Update context for command visibility + await this.state.setAuthError(false); await this.state.setHasAgents(mappedAgents.length > 0); // Clear the pending/current agent IDs after use @@ -322,7 +323,29 @@ export class WebviewMessageHandlers { } catch (err) { console.error('Error getting available agents from org:', err); this.state.pendingSelectAgentId = undefined; - this.messageSender.sendAvailableAgents([], undefined); + + const errorMessage = err instanceof Error ? err.message : String(err); + const errorName = err instanceof Error ? err.name : ''; + const fullError = `${errorName}: ${errorMessage}`; + const isConnectionError = + errorName === 'RefreshTokenAuthError' || + fullError.includes('RefreshTokenAuthError') || + fullError.includes('authentication failure') || + fullError.includes('expired') || + fullError.includes('INVALID_CROSS_REFERENCE_KEY') || + fullError.includes('invalid cross reference id') || + fullError.includes('INVALID_SESSION_ID') || + fullError.includes('No default org configured'); + + if (isConnectionError) { + this.messageSender.sendAuthError( + 'Unable to connect to org', + 'Set a new default org or re-authenticate to continue.' + ); + await this.state.setAuthError(true); + } else { + this.messageSender.sendAvailableAgents([], undefined); + } await this.state.setHasAgents(false); } } diff --git a/src/views/agentCombined/handlers/webviewMessageSender.ts b/src/views/agentCombined/handlers/webviewMessageSender.ts index 1fadceb8..a53faac7 100644 --- a/src/views/agentCombined/handlers/webviewMessageSender.ts +++ b/src/views/agentCombined/handlers/webviewMessageSender.ts @@ -117,6 +117,12 @@ export class WebviewMessageSender { this.postMessage('error', { message: sanitizedMessage, details: sanitizedDetails }); } + sendAuthError(message: string, details?: string): void { + const sanitizedMessage = this.stripHtmlTags(message); + const sanitizedDetails = details ? this.stripHtmlTags(details) : undefined; + this.postMessage('authError', { message: sanitizedMessage, details: sanitizedDetails }); + } + sendDebugLogError(message: string): void { this.postMessage('debugLogError', { message }); } diff --git a/src/views/agentCombined/state/agentViewState.ts b/src/views/agentCombined/state/agentViewState.ts index 8bc0151f..fd883c48 100644 --- a/src/views/agentCombined/state/agentViewState.ts +++ b/src/views/agentCombined/state/agentViewState.ts @@ -255,6 +255,10 @@ export class AgentViewState { await vscode.commands.executeCommand('setContext', 'agentforceDX:hasAgents', hasAgents); } + async setAuthError(hasError: boolean): Promise { + await vscode.commands.executeCommand('setContext', 'agentforceDX:authError', hasError); + } + getExportDirectory(): string | undefined { return this.context.workspaceState.get(AgentViewState.EXPORT_DIR_KEY); } diff --git a/webview/src/App.tsx b/webview/src/App.tsx index 8c61c546..61e528a0 100644 --- a/webview/src/App.tsx +++ b/webview/src/App.tsx @@ -4,6 +4,7 @@ import AgentTracer from './components/AgentTracer/AgentTracer.js'; import AgentSelector from './components/AgentPreview/AgentSelector.js'; import SessionHistory from './components/SessionHistory/SessionHistory.js'; import TabNavigation from './components/shared/TabNavigation.js'; +import { Button } from './components/shared/Button.js'; import { vscodeApi, AgentInfo, AgentSource } from './services/vscodeApi.js'; import './App.css'; @@ -40,6 +41,7 @@ const App: React.FC = () => { const [selectedAgentInfo, setSelectedAgentInfo] = useState(null); const [hasAgents, setHasAgents] = useState(false); const [isLoadingAgents, setIsLoadingAgents] = useState(true); + const [authError, setAuthError] = useState<{ message: string; details?: string } | null>(null); const sessionChangeQueueRef = useRef(Promise.resolve()); const displayedAgentIdRef = useRef(''); const desiredAgentIdRef = useRef(''); @@ -91,6 +93,7 @@ const App: React.FC = () => { const disposeRefreshAgents = vscodeApi.onMessage('refreshAgents', () => { // Switch back to preview tab when refreshing agents setActiveTab('preview'); + setAuthError(null); sessionActiveRef.current = false; setIsSessionActive(false); setIsSessionStarting(false); @@ -152,6 +155,11 @@ const App: React.FC = () => { setActiveTab(tab); }); + const disposeAuthError = vscodeApi.onMessage('authError', (data: { message: string; details?: string }) => { + setAuthError({ message: data.message, details: data.details }); + setIsLoadingAgents(false); + }); + // Request initial live mode state from extension vscodeApi.getInitialLiveMode(); @@ -159,6 +167,7 @@ const App: React.FC = () => { disposeSelectAgent(); disposeRefreshAgents(); disposeSetLiveMode(); + disposeAuthError(); disposeTestStartSession(); disposeTestSendMessage(); disposeTestEndSession(); @@ -501,6 +510,27 @@ const App: React.FC = () => { const previewAgentId = desiredAgentId !== '' ? desiredAgentId : displayedAgentId; const pendingAgentId = desiredAgentId !== displayedAgentId ? desiredAgentId : null; + if (authError) { + return ( +
+
+
+
+
+

{authError.message}

+ {authError.details &&

{authError.details}

} + +
+
+
+
+ ); + } + return (
From 904998c31401c6a03c96c9ac3812af9ff264e3ec Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Wed, 20 May 2026 18:35:33 +0200 Subject: [PATCH 2/6] test: add coverage for auth error detection and webview error screen --- .../handlers/webviewMessageHandlers.test.ts | 85 +++++++++++++++++++ test/webview/App.test.tsx | 67 ++++++++++++++- 2 files changed, 151 insertions(+), 1 deletion(-) diff --git a/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts b/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts index bb40f37f..5334dac7 100644 --- a/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts +++ b/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts @@ -113,12 +113,18 @@ describe('WebviewMessageHandlers', () => { setResetAgentViewAvailable: jest.fn().mockResolvedValue(undefined), setSessionErrorState: jest.fn().mockResolvedValue(undefined), setConversationDataAvailable: jest.fn().mockResolvedValue(undefined), + setAuthError: jest.fn().mockResolvedValue(undefined), + setHasAgents: jest.fn().mockResolvedValue(undefined), + agentVersionsCache: new Map(), + pendingSelectAgentId: undefined, cancelPendingSessionStart: jest.fn(), clearSessionState: jest.fn() }; mockMessageSender = { sendError: jest.fn().mockResolvedValue(undefined), + sendAuthError: jest.fn(), + sendAvailableAgents: jest.fn(), sendClearMessages: jest.fn(), sendSessionList: jest.fn(), sendSessionStarting: jest.fn(), @@ -536,4 +542,83 @@ describe('WebviewMessageHandlers', () => { expect(mockHistoryManager.loadAndSendTracesForSession).not.toHaveBeenCalled(); }); }); + + describe('handleGetAvailableAgents - connection errors', () => { + const originalError = console.error; + beforeAll(() => { + console.error = jest.fn(); + }); + afterAll(() => { + console.error = originalError; + }); + + it('sends authError when RefreshTokenAuthError occurs', async () => { + const error = new Error('Error authenticating with the refresh token due to: authentication failure'); + error.name = 'RefreshTokenAuthError'; + (CoreExtensionService.getDefaultConnection as jest.Mock).mockRejectedValueOnce(error); + + await handlers.handleMessage({ command: 'getAvailableAgents' } as any); + + expect(mockMessageSender.sendAuthError).toHaveBeenCalledWith( + 'Unable to connect to org', + 'Set a new default org or re-authenticate to continue.' + ); + expect(mockState.setAuthError).toHaveBeenCalledWith(true); + expect(mockMessageSender.sendAvailableAgents).not.toHaveBeenCalled(); + }); + + it('sends authError when INVALID_CROSS_REFERENCE_KEY occurs', async () => { + const error = new Error('invalid cross reference id'); + error.name = 'INVALID_CROSS_REFERENCE_KEY'; + (CoreExtensionService.getDefaultConnection as jest.Mock).mockRejectedValueOnce(error); + + await handlers.handleMessage({ command: 'getAvailableAgents' } as any); + + expect(mockMessageSender.sendAuthError).toHaveBeenCalledWith( + 'Unable to connect to org', + 'Set a new default org or re-authenticate to continue.' + ); + expect(mockState.setAuthError).toHaveBeenCalledWith(true); + }); + + it('sends authError when INVALID_SESSION_ID occurs', async () => { + const error = new Error('INVALID_SESSION_ID: Session expired or invalid'); + (CoreExtensionService.getDefaultConnection as jest.Mock).mockRejectedValueOnce(error); + + await handlers.handleMessage({ command: 'getAvailableAgents' } as any); + + expect(mockMessageSender.sendAuthError).toHaveBeenCalled(); + expect(mockState.setAuthError).toHaveBeenCalledWith(true); + }); + + it('sends authError when no default org is configured', async () => { + const error = new Error('No default org configured. Set a default org with: sf config set target-org='); + (CoreExtensionService.getDefaultConnection as jest.Mock).mockRejectedValueOnce(error); + + await handlers.handleMessage({ command: 'getAvailableAgents' } as any); + + expect(mockMessageSender.sendAuthError).toHaveBeenCalled(); + expect(mockState.setAuthError).toHaveBeenCalledWith(true); + }); + + it('sends empty agent list for non-connection errors', async () => { + const error = new Error('Some unexpected error'); + (CoreExtensionService.getDefaultConnection as jest.Mock).mockRejectedValueOnce(error); + + await handlers.handleMessage({ command: 'getAvailableAgents' } as any); + + expect(mockMessageSender.sendAvailableAgents).toHaveBeenCalledWith([], undefined); + expect(mockMessageSender.sendAuthError).not.toHaveBeenCalled(); + expect(mockState.setAuthError).not.toHaveBeenCalled(); + }); + + it('sets hasAgents to false on any error', async () => { + const error = new Error('authentication failure'); + (CoreExtensionService.getDefaultConnection as jest.Mock).mockRejectedValueOnce(error); + + await handlers.handleMessage({ command: 'getAvailableAgents' } as any); + + expect(mockState.setHasAgents).toHaveBeenCalledWith(false); + }); + }); }); diff --git a/test/webview/App.test.tsx b/test/webview/App.test.tsx index a0dd73a7..d99dcb49 100644 --- a/test/webview/App.test.tsx +++ b/test/webview/App.test.tsx @@ -15,7 +15,7 @@ */ import '@testing-library/jest-dom'; import React from 'react'; -import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; // Mock vscodeApi before importing App @@ -1145,4 +1145,69 @@ describe('App', () => { }); }); }); + + describe('Auth Error Screen', () => { + it('should show auth error screen when authError message is received', async () => { + render(); + + act(() => { + triggerMessage('authError', { message: 'Unable to connect to org', details: 'Set a new default org or re-authenticate to continue.' }); + }); + + await waitFor(() => { + expect(screen.getByText('Unable to connect to org')).toBeInTheDocument(); + expect(screen.getByText('Set a new default org or re-authenticate to continue.')).toBeInTheDocument(); + expect(screen.getByText('Select Another Org')).toBeInTheDocument(); + }); + }); + + it('should hide agent selector when auth error is shown', async () => { + render(); + + act(() => { + triggerMessage('authError', { message: 'Unable to connect to org' }); + }); + + await waitFor(() => { + expect(screen.queryByTestId('agent-selector')).not.toBeInTheDocument(); + }); + }); + + it('should execute sf.set.default.org when Select Another Org is clicked', async () => { + render(); + + act(() => { + triggerMessage('authError', { message: 'Unable to connect to org' }); + }); + + await waitFor(() => { + expect(screen.getByText('Select Another Org')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Select Another Org')); + + expect(mockVscodeApi.executeCommand).toHaveBeenCalledWith('sf.set.default.org'); + }); + + it('should clear auth error when refreshAgents message is received', async () => { + render(); + + act(() => { + triggerMessage('authError', { message: 'Unable to connect to org' }); + }); + + await waitFor(() => { + expect(screen.getByText('Unable to connect to org')).toBeInTheDocument(); + }); + + act(() => { + triggerMessage('refreshAgents', undefined); + }); + + await waitFor(() => { + expect(screen.queryByText('Unable to connect to org')).not.toBeInTheDocument(); + expect(screen.getByTestId('agent-selector')).toBeInTheDocument(); + }); + }); + }); }); From 88fb6bb4a701a6494e681ca033a90d566fd74784 Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Thu, 21 May 2026 20:44:13 +0200 Subject: [PATCH 3/6] fix: show specific error when Agentforce is not enabled on the org Detects INVALID_TYPE error for BotDefinition (org lacks Agentforce permission) and shows "Agentforce is not enabled" instead of the generic connection error message. --- .../handlers/webviewMessageHandlers.ts | 14 ++++++++++++-- .../handlers/webviewMessageHandlers.test.ts | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/views/agentCombined/handlers/webviewMessageHandlers.ts b/src/views/agentCombined/handlers/webviewMessageHandlers.ts index 26d5278c..d112a17c 100644 --- a/src/views/agentCombined/handlers/webviewMessageHandlers.ts +++ b/src/views/agentCombined/handlers/webviewMessageHandlers.ts @@ -327,7 +327,8 @@ export class WebviewMessageHandlers { const errorMessage = err instanceof Error ? err.message : String(err); const errorName = err instanceof Error ? err.name : ''; const fullError = `${errorName}: ${errorMessage}`; - const isConnectionError = + + const isAuthError = errorName === 'RefreshTokenAuthError' || fullError.includes('RefreshTokenAuthError') || fullError.includes('authentication failure') || @@ -337,7 +338,16 @@ export class WebviewMessageHandlers { fullError.includes('INVALID_SESSION_ID') || fullError.includes('No default org configured'); - if (isConnectionError) { + const isFeatureNotEnabled = + fullError.includes('INVALID_TYPE') && fullError.includes('BotDefinition'); + + if (isFeatureNotEnabled) { + this.messageSender.sendAuthError( + 'Agentforce is not enabled', + 'This org does not have Agentforce enabled. Select an org with Agentforce to continue.' + ); + await this.state.setAuthError(true); + } else if (isAuthError) { this.messageSender.sendAuthError( 'Unable to connect to org', 'Set a new default org or re-authenticate to continue.' diff --git a/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts b/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts index 5334dac7..466d66e8 100644 --- a/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts +++ b/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts @@ -601,6 +601,20 @@ describe('WebviewMessageHandlers', () => { expect(mockState.setAuthError).toHaveBeenCalledWith(true); }); + it('sends authError with feature-not-enabled message when BotDefinition INVALID_TYPE occurs', async () => { + const error = new Error("sObject type 'BotDefinition' is not supported."); + error.name = 'INVALID_TYPE'; + (CoreExtensionService.getDefaultConnection as jest.Mock).mockRejectedValueOnce(error); + + await handlers.handleMessage({ command: 'getAvailableAgents' } as any); + + expect(mockMessageSender.sendAuthError).toHaveBeenCalledWith( + 'Agentforce is not enabled', + 'This org does not have Agentforce enabled. Select an org with Agentforce to continue.' + ); + expect(mockState.setAuthError).toHaveBeenCalledWith(true); + }); + it('sends empty agent list for non-connection errors', async () => { const error = new Error('Some unexpected error'); (CoreExtensionService.getDefaultConnection as jest.Mock).mockRejectedValueOnce(error); From 70bd1a551c58ae0c4527dbaa84082044c780ddc9 Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Thu, 21 May 2026 21:00:29 +0200 Subject: [PATCH 4/6] feat: add Enable Agentforce button with setup URL and fix button layout When Agentforce is not enabled on the org, the error screen now includes an "Enable Agentforce" button that opens the org's setup page. Buttons use horizontal layout matching the placeholder screen pattern. Co-Authored-By: Claude Opus 4.6 --- .../handlers/webviewMessageHandlers.ts | 22 +++++- .../handlers/webviewMessageSender.ts | 4 +- .../handlers/webviewMessageHandlers.test.ts | 69 ++++++++++++++++++- test/webview/App.test.tsx | 24 +++++++ test/webview/services/vscodeApi.test.ts | 11 ++- webview/src/App.tsx | 25 ++++--- .../components/AgentPreview/AgentPreview.css | 7 ++ webview/src/services/vscodeApi.ts | 9 ++- 8 files changed, 153 insertions(+), 18 deletions(-) diff --git a/src/views/agentCombined/handlers/webviewMessageHandlers.ts b/src/views/agentCombined/handlers/webviewMessageHandlers.ts index d112a17c..d3a76a22 100644 --- a/src/views/agentCombined/handlers/webviewMessageHandlers.ts +++ b/src/views/agentCombined/handlers/webviewMessageHandlers.ts @@ -52,6 +52,7 @@ export class WebviewMessageHandlers { openTraceJson: async msg => await this.handleOpenTraceJson(msg), getConfiguration: async msg => await this.handleGetConfiguration(msg), executeCommand: async msg => await this.handleExecuteCommand(msg), + openUrl: async msg => await this.handleOpenUrl(msg), setSelectedAgentId: async msg => await this.handleSetSelectedAgentId(msg), setLiveMode: async msg => await this.handleSetLiveMode(msg), getInitialLiveMode: async () => await this.handleGetInitialLiveMode(), @@ -253,8 +254,10 @@ export class WebviewMessageHandlers { } private async handleGetAvailableAgents(): Promise { + let instanceUrl: string | undefined; try { const conn = await CoreExtensionService.getDefaultConnection(); + instanceUrl = conn.instanceUrl; const project = SfProject.getInstance(); const allAgents = await Agent.listPreviewable(conn, project); @@ -342,9 +345,13 @@ export class WebviewMessageHandlers { fullError.includes('INVALID_TYPE') && fullError.includes('BotDefinition'); if (isFeatureNotEnabled) { + const setupUrl = instanceUrl + ? `${instanceUrl}/lightning/setup/EinsteinCopilot/home` + : undefined; this.messageSender.sendAuthError( 'Agentforce is not enabled', - 'This org does not have Agentforce enabled. Select an org with Agentforce to continue.' + 'This org does not have Agentforce enabled. Select an org with Agentforce to continue.', + setupUrl ); await this.state.setAuthError(true); } else if (isAuthError) { @@ -403,10 +410,19 @@ export class WebviewMessageHandlers { } private async handleExecuteCommand(message: AgentMessage): Promise { - const data = message.data as { commandId?: string } | undefined; + const data = message.data as { commandId?: string; args?: unknown[] } | undefined; const commandId = data?.commandId; if (commandId && typeof commandId === 'string') { - await vscode.commands.executeCommand(commandId); + const args = Array.isArray(data?.args) ? data.args : []; + await vscode.commands.executeCommand(commandId, ...args); + } + } + + private async handleOpenUrl(message: AgentMessage): Promise { + const data = message.data as { url?: string } | undefined; + const url = data?.url; + if (url && typeof url === 'string') { + await vscode.env.openExternal(vscode.Uri.parse(url)); } } diff --git a/src/views/agentCombined/handlers/webviewMessageSender.ts b/src/views/agentCombined/handlers/webviewMessageSender.ts index a53faac7..0f01c3b2 100644 --- a/src/views/agentCombined/handlers/webviewMessageSender.ts +++ b/src/views/agentCombined/handlers/webviewMessageSender.ts @@ -117,10 +117,10 @@ export class WebviewMessageSender { this.postMessage('error', { message: sanitizedMessage, details: sanitizedDetails }); } - sendAuthError(message: string, details?: string): void { + sendAuthError(message: string, details?: string, setupUrl?: string): void { const sanitizedMessage = this.stripHtmlTags(message); const sanitizedDetails = details ? this.stripHtmlTags(details) : undefined; - this.postMessage('authError', { message: sanitizedMessage, details: sanitizedDetails }); + this.postMessage('authError', { message: sanitizedMessage, details: sanitizedDetails, setupUrl }); } sendDebugLogError(message: string): void { diff --git a/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts b/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts index 466d66e8..a3770628 100644 --- a/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts +++ b/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts @@ -19,6 +19,12 @@ jest.mock('vscode', () => ({ commands: { executeCommand: jest.fn() }, + env: { + openExternal: jest.fn().mockResolvedValue(true) + }, + Uri: { + parse: jest.fn((url: string) => ({ toString: () => url })) + }, window: { showInformationMessage: jest.fn(), showErrorMessage: jest.fn() @@ -79,7 +85,9 @@ jest.mock('../../../../src/views/agentCombined/session', () => ({ // Import after mocks import { WebviewMessageHandlers } from '../../../../src/views/agentCombined/handlers/webviewMessageHandlers'; import { CoreExtensionService } from '../../../../src/services/coreExtensionService'; +import { Agent } from '@salesforce/agents'; import { listSessionsForAgent } from '../../../../src/views/agentCombined/session'; +import * as vscode from 'vscode'; describe('WebviewMessageHandlers', () => { let handlers: WebviewMessageHandlers; @@ -601,7 +609,7 @@ describe('WebviewMessageHandlers', () => { expect(mockState.setAuthError).toHaveBeenCalledWith(true); }); - it('sends authError with feature-not-enabled message when BotDefinition INVALID_TYPE occurs', async () => { + it('sends authError with feature-not-enabled message when BotDefinition INVALID_TYPE occurs at connection', async () => { const error = new Error("sObject type 'BotDefinition' is not supported."); error.name = 'INVALID_TYPE'; (CoreExtensionService.getDefaultConnection as jest.Mock).mockRejectedValueOnce(error); @@ -610,7 +618,26 @@ describe('WebviewMessageHandlers', () => { expect(mockMessageSender.sendAuthError).toHaveBeenCalledWith( 'Agentforce is not enabled', - 'This org does not have Agentforce enabled. Select an org with Agentforce to continue.' + 'This org does not have Agentforce enabled. Select an org with Agentforce to continue.', + undefined + ); + expect(mockState.setAuthError).toHaveBeenCalledWith(true); + }); + + it('includes setupUrl when INVALID_TYPE occurs and instanceUrl is available', async () => { + const error = new Error("sObject type 'BotDefinition' is not supported."); + error.name = 'INVALID_TYPE'; + (CoreExtensionService.getDefaultConnection as jest.Mock).mockResolvedValueOnce({ + instanceUrl: 'https://myorg.salesforce.com' + }); + (Agent.listPreviewable as jest.Mock).mockRejectedValueOnce(error); + + await handlers.handleMessage({ command: 'getAvailableAgents' } as any); + + expect(mockMessageSender.sendAuthError).toHaveBeenCalledWith( + 'Agentforce is not enabled', + 'This org does not have Agentforce enabled. Select an org with Agentforce to continue.', + 'https://myorg.salesforce.com/lightning/setup/EinsteinCopilot/home' ); expect(mockState.setAuthError).toHaveBeenCalledWith(true); }); @@ -635,4 +662,42 @@ describe('WebviewMessageHandlers', () => { expect(mockState.setHasAgents).toHaveBeenCalledWith(false); }); }); + + describe('handleOpenUrl', () => { + it('opens external URL via vscode.env.openExternal', async () => { + await handlers.handleMessage({ + command: 'openUrl', + data: { url: 'https://example.salesforce.com/lightning/setup/EinsteinCopilot/home' } + } as any); + + expect(vscode.Uri.parse).toHaveBeenCalledWith( + 'https://example.salesforce.com/lightning/setup/EinsteinCopilot/home' + ); + expect(vscode.env.openExternal).toHaveBeenCalled(); + }); + + it('does nothing when url is missing', async () => { + await handlers.handleMessage({ + command: 'openUrl', + data: {} + } as any); + + expect(vscode.env.openExternal).not.toHaveBeenCalled(); + }); + }); + + describe('handleExecuteCommand', () => { + it('passes args to vscode.commands.executeCommand', async () => { + await handlers.handleMessage({ + command: 'executeCommand', + data: { commandId: 'sf.set.default.org', args: ['--alias', 'test'] } + } as any); + + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + 'sf.set.default.org', + '--alias', + 'test' + ); + }); + }); }); diff --git a/test/webview/App.test.tsx b/test/webview/App.test.tsx index d99dcb49..f2f448d5 100644 --- a/test/webview/App.test.tsx +++ b/test/webview/App.test.tsx @@ -32,6 +32,7 @@ const mockVscodeApi = { clearMessages: jest.fn(), getConfiguration: jest.fn(), executeCommand: jest.fn(), + openUrl: jest.fn(), setSelectedAgentId: jest.fn(), loadAgentHistory: jest.fn(), setLiveMode: jest.fn(), @@ -1209,5 +1210,28 @@ describe('App', () => { expect(screen.getByTestId('agent-selector')).toBeInTheDocument(); }); }); + + it('should show Enable Agentforce button when setupUrl is provided', async () => { + render(); + + act(() => { + triggerMessage('authError', { + message: 'Agentforce is not enabled', + details: 'This org does not have Agentforce enabled. Select an org with Agentforce to continue.', + setupUrl: 'https://myorg.salesforce.com/lightning/setup/EinsteinCopilot/home' + }); + }); + + await waitFor(() => { + expect(screen.getByText('Agentforce is not enabled')).toBeInTheDocument(); + expect(screen.getByText('Enable Agentforce')).toBeInTheDocument(); + expect(screen.getByText('Select Another Org')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Enable Agentforce')); + expect(mockVscodeApi.openUrl).toHaveBeenCalledWith( + 'https://myorg.salesforce.com/lightning/setup/EinsteinCopilot/home' + ); + }); }); }); diff --git a/test/webview/services/vscodeApi.test.ts b/test/webview/services/vscodeApi.test.ts index 0c5209c5..46c478d3 100644 --- a/test/webview/services/vscodeApi.test.ts +++ b/test/webview/services/vscodeApi.test.ts @@ -275,7 +275,16 @@ describe('vscodeApi', () => { expect(mockVSCodeApi.postMessage).toHaveBeenCalledWith({ command: 'executeCommand', - data: { commandId: 'workbench.action.reloadWindow' } + data: { commandId: 'workbench.action.reloadWindow', args: [] } + }); + }); + + it('should send openUrl message', () => { + vscodeApi.openUrl('https://myorg.salesforce.com/lightning/setup/EinsteinCopilot/home'); + + expect(mockVSCodeApi.postMessage).toHaveBeenCalledWith({ + command: 'openUrl', + data: { url: 'https://myorg.salesforce.com/lightning/setup/EinsteinCopilot/home' } }); }); }); diff --git a/webview/src/App.tsx b/webview/src/App.tsx index 61e528a0..920a7f25 100644 --- a/webview/src/App.tsx +++ b/webview/src/App.tsx @@ -41,7 +41,7 @@ const App: React.FC = () => { const [selectedAgentInfo, setSelectedAgentInfo] = useState(null); const [hasAgents, setHasAgents] = useState(false); const [isLoadingAgents, setIsLoadingAgents] = useState(true); - const [authError, setAuthError] = useState<{ message: string; details?: string } | null>(null); + const [authError, setAuthError] = useState<{ message: string; details?: string; setupUrl?: string } | null>(null); const sessionChangeQueueRef = useRef(Promise.resolve()); const displayedAgentIdRef = useRef(''); const desiredAgentIdRef = useRef(''); @@ -155,8 +155,8 @@ const App: React.FC = () => { setActiveTab(tab); }); - const disposeAuthError = vscodeApi.onMessage('authError', (data: { message: string; details?: string }) => { - setAuthError({ message: data.message, details: data.details }); + const disposeAuthError = vscodeApi.onMessage('authError', (data: { message: string; details?: string; setupUrl?: string }) => { + setAuthError({ message: data.message, details: data.details, setupUrl: data.setupUrl }); setIsLoadingAgents(false); }); @@ -519,11 +519,20 @@ const App: React.FC = () => {

{authError.message}

{authError.details &&

{authError.details}

} - +
+ + {authError.setupUrl && ( + + )} +
diff --git a/webview/src/components/AgentPreview/AgentPreview.css b/webview/src/components/AgentPreview/AgentPreview.css index 73a70e1e..e96bc1ee 100644 --- a/webview/src/components/AgentPreview/AgentPreview.css +++ b/webview/src/components/AgentPreview/AgentPreview.css @@ -58,6 +58,13 @@ body.vscode-high-contrast .agent-preview-error-icon { white-space: pre-wrap; } +.agent-preview-error-buttons { + display: flex; + gap: 12px; + flex-wrap: wrap; + justify-content: center; +} + .agent-preview-error .vscode-button { width: auto; min-width: auto; diff --git a/webview/src/services/vscodeApi.ts b/webview/src/services/vscodeApi.ts index bdc76c49..08515642 100644 --- a/webview/src/services/vscodeApi.ts +++ b/webview/src/services/vscodeApi.ts @@ -200,8 +200,13 @@ class VSCodeApiService { } // Execute a VSCode command - executeCommand(commandId: string) { - this.postMessage('executeCommand', { commandId }); + executeCommand(commandId: string, ...args: unknown[]) { + this.postMessage('executeCommand', { commandId, args }); + } + + // Open a URL in the default browser + openUrl(url: string) { + this.postMessage('openUrl', { url }); } // Notify the extension about the selected agent ID From 91099b63dbcbdb044bcfb3c3ec6dd97696457ca4 Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Thu, 21 May 2026 21:08:56 +0200 Subject: [PATCH 5/6] feat: auto-refresh on window focus and update error copy When the auth error screen is showing and VS Code regains focus, automatically re-fetch agents so the user doesn't need to manually reload after enabling Agentforce or re-authenticating. Also updates the "not enabled" message to reflect both available actions. Co-Authored-By: Claude Opus 4.6 --- src/extension.ts | 9 +++++++++ .../agentCombined/handlers/webviewMessageHandlers.ts | 2 +- src/views/agentCombined/state/agentViewState.ts | 8 ++++++++ src/views/agentCombinedViewProvider.ts | 4 ++++ .../handlers/webviewMessageHandlers.test.ts | 4 ++-- test/webview/App.test.tsx | 2 +- 6 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 607ff4a3..36f8f0b5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -180,6 +180,15 @@ const registerAgentCombinedView = (context: vscode.ExtensionContext): vscode.Dis console.error('Could not set up org change listener:', err.message); }); + // Re-fetch agents when window regains focus while in auth error state + disposables.push( + vscode.window.onDidChangeWindowState(async state => { + if (state.focused && provider.hasAuthError) { + await provider.refreshAvailableAgents(); + } + }) + ); + // Shared helper for selecting an agent from a quick pick const showAgentPicker = async ( placeHolder: string diff --git a/src/views/agentCombined/handlers/webviewMessageHandlers.ts b/src/views/agentCombined/handlers/webviewMessageHandlers.ts index d3a76a22..c08ce391 100644 --- a/src/views/agentCombined/handlers/webviewMessageHandlers.ts +++ b/src/views/agentCombined/handlers/webviewMessageHandlers.ts @@ -350,7 +350,7 @@ export class WebviewMessageHandlers { : undefined; this.messageSender.sendAuthError( 'Agentforce is not enabled', - 'This org does not have Agentforce enabled. Select an org with Agentforce to continue.', + 'This org doesn\'t have Agentforce enabled. You can enable it or switch to another org.', setupUrl ); await this.state.setAuthError(true); diff --git a/src/views/agentCombined/state/agentViewState.ts b/src/views/agentCombined/state/agentViewState.ts index fd883c48..518f520f 100644 --- a/src/views/agentCombined/state/agentViewState.ts +++ b/src/views/agentCombined/state/agentViewState.ts @@ -30,6 +30,9 @@ export class AgentViewState { private _currentAgentActiveVersion?: number; private _agentVersionsCache = new Map>(); + // Error state + private _hasAuthError = false; + // Mode state private _isApexDebuggingEnabled = false; private _isLiveMode = false; @@ -255,7 +258,12 @@ export class AgentViewState { await vscode.commands.executeCommand('setContext', 'agentforceDX:hasAgents', hasAgents); } + get hasAuthError(): boolean { + return this._hasAuthError; + } + async setAuthError(hasError: boolean): Promise { + this._hasAuthError = hasError; await vscode.commands.executeCommand('setContext', 'agentforceDX:authError', hasError); } diff --git a/src/views/agentCombinedViewProvider.ts b/src/views/agentCombinedViewProvider.ts index fb3155dc..45c264cc 100644 --- a/src/views/agentCombinedViewProvider.ts +++ b/src/views/agentCombinedViewProvider.ts @@ -180,6 +180,10 @@ export class AgentCombinedViewProvider implements vscode.WebviewViewProvider { /** * Gets the currently selected agent ID */ + public get hasAuthError(): boolean { + return this.state.hasAuthError; + } + public getCurrentAgentId(): string | undefined { return this.state.currentAgentId; } diff --git a/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts b/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts index a3770628..470e4084 100644 --- a/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts +++ b/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts @@ -618,7 +618,7 @@ describe('WebviewMessageHandlers', () => { expect(mockMessageSender.sendAuthError).toHaveBeenCalledWith( 'Agentforce is not enabled', - 'This org does not have Agentforce enabled. Select an org with Agentforce to continue.', + 'This org doesn\'t have Agentforce enabled. You can enable it or switch to another org.', undefined ); expect(mockState.setAuthError).toHaveBeenCalledWith(true); @@ -636,7 +636,7 @@ describe('WebviewMessageHandlers', () => { expect(mockMessageSender.sendAuthError).toHaveBeenCalledWith( 'Agentforce is not enabled', - 'This org does not have Agentforce enabled. Select an org with Agentforce to continue.', + 'This org doesn\'t have Agentforce enabled. You can enable it or switch to another org.', 'https://myorg.salesforce.com/lightning/setup/EinsteinCopilot/home' ); expect(mockState.setAuthError).toHaveBeenCalledWith(true); diff --git a/test/webview/App.test.tsx b/test/webview/App.test.tsx index f2f448d5..b2f6b4ab 100644 --- a/test/webview/App.test.tsx +++ b/test/webview/App.test.tsx @@ -1217,7 +1217,7 @@ describe('App', () => { act(() => { triggerMessage('authError', { message: 'Agentforce is not enabled', - details: 'This org does not have Agentforce enabled. Select an org with Agentforce to continue.', + details: 'This org doesn\'t have Agentforce enabled. You can enable it or switch to another org.', setupUrl: 'https://myorg.salesforce.com/lightning/setup/EinsteinCopilot/home' }); }); From 5db0a6c4b27a8b4b616e6b1fb2f381a5e5e722d3 Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Thu, 21 May 2026 21:13:50 +0200 Subject: [PATCH 6/6] test: add coverage for hasAuthError state and window focus refresh --- .../state/agentViewState.test.ts | 29 +++++++++++++++++++ test/views/agentCombinedViewProvider.test.ts | 6 ++++ 2 files changed, 35 insertions(+) diff --git a/test/views/agentCombined/state/agentViewState.test.ts b/test/views/agentCombined/state/agentViewState.test.ts index 88992e2c..4f3f75e2 100644 --- a/test/views/agentCombined/state/agentViewState.test.ts +++ b/test/views/agentCombined/state/agentViewState.test.ts @@ -131,4 +131,33 @@ describe('AgentViewState', () => { expect(state.previewedSessionId).toBeUndefined(); }); }); + + describe('hasAuthError', () => { + it('defaults to false', () => { + expect(state.hasAuthError).toBe(false); + }); + + it('tracks state when setAuthError is called with true', async () => { + await state.setAuthError(true); + + expect(state.hasAuthError).toBe(true); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + 'setContext', + 'agentforceDX:authError', + true + ); + }); + + it('resets state when setAuthError is called with false', async () => { + await state.setAuthError(true); + await state.setAuthError(false); + + expect(state.hasAuthError).toBe(false); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + 'setContext', + 'agentforceDX:authError', + false + ); + }); + }); }); diff --git a/test/views/agentCombinedViewProvider.test.ts b/test/views/agentCombinedViewProvider.test.ts index e20a388e..9ac449de 100644 --- a/test/views/agentCombinedViewProvider.test.ts +++ b/test/views/agentCombinedViewProvider.test.ts @@ -388,4 +388,10 @@ describe('AgentCombinedViewProvider', () => { }); }); + describe('hasAuthError', () => { + it('should default to false', () => { + expect(provider.hasAuthError).toBe(false); + }); + }); + });