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/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 2a3d917d..c08ce391 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); @@ -312,6 +315,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 +326,43 @@ 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 isAuthError = + 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'); + + const isFeatureNotEnabled = + 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 doesn\'t have Agentforce enabled. You can enable it or switch to another org.', + setupUrl + ); + 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.' + ); + await this.state.setAuthError(true); + } else { + this.messageSender.sendAvailableAgents([], undefined); + } await this.state.setHasAgents(false); } } @@ -370,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 1fadceb8..0f01c3b2 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, setupUrl?: string): void { + const sanitizedMessage = this.stripHtmlTags(message); + const sanitizedDetails = details ? this.stripHtmlTags(details) : undefined; + this.postMessage('authError', { message: sanitizedMessage, details: sanitizedDetails, setupUrl }); + } + 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..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,6 +258,15 @@ 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); + } + getExportDirectory(): string | undefined { return this.context.workspaceState.get(AgentViewState.EXPORT_DIR_KEY); } 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 bb40f37f..470e4084 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; @@ -113,12 +121,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 +550,154 @@ 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 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); + + await handlers.handleMessage({ command: 'getAvailableAgents' } as any); + + expect(mockMessageSender.sendAuthError).toHaveBeenCalledWith( + 'Agentforce is not enabled', + 'This org doesn\'t have Agentforce enabled. You can enable it or switch to another org.', + 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 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); + }); + + 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); + }); + }); + + 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/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); + }); + }); + }); diff --git a/test/webview/App.test.tsx b/test/webview/App.test.tsx index a0dd73a7..b2f6b4ab 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 @@ -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(), @@ -1145,4 +1146,92 @@ 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(); + }); + }); + + it('should show Enable Agentforce button when setupUrl is provided', async () => { + render(); + + act(() => { + triggerMessage('authError', { + message: 'Agentforce is not enabled', + 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' + }); + }); + + 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 8c61c546..920a7f25 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; setupUrl?: 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; setupUrl?: string }) => { + setAuthError({ message: data.message, details: data.details, setupUrl: data.setupUrl }); + 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,36 @@ const App: React.FC = () => { const previewAgentId = desiredAgentId !== '' ? desiredAgentId : displayedAgentId; const pendingAgentId = desiredAgentId !== displayedAgentId ? desiredAgentId : null; + if (authError) { + return ( +
+
+
+
+
+

{authError.message}

+ {authError.details &&

{authError.details}

} +
+ + {authError.setupUrl && ( + + )} +
+
+
+
+
+ ); + } + return (
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