From e6fde9d2949fb10a5dd9714ddbe7cc42c0e81edc Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Tue, 28 Apr 2026 14:02:13 +0200 Subject: [PATCH 1/3] feat: multi-session preview UI behind feature flag Adds a session-list sidebar and per-session editor tabs for agent preview, gated by the `salesforce.agentforceDX.multiSession.enabled` setting (default off). Users can run several agent sessions in parallel, each with its own panel, live-mode toggle, and Apex debug state. The legacy single-sidebar flow remains unchanged when the flag is off. Co-Authored-By: Claude Opus 4.7 --- package.json | 104 ++++++- src/commands/previewAgent.ts | 9 + src/extension.ts | 27 ++ .../handlers/webviewMessageHandlers.ts | 6 +- .../handlers/webviewMessageSender.ts | 17 +- .../agentCombined/session/sessionManager.ts | 4 +- src/views/multiSession/index.ts | 145 +++++++++ src/views/multiSession/panelFactory.ts | 61 ++++ src/views/multiSession/panelHtml.ts | 42 +++ src/views/multiSession/session.ts | 35 +++ src/views/multiSession/sessionCommands.ts | 58 ++++ .../multiSession/sessionListTreeProvider.ts | 104 +++++++ src/views/multiSession/sessionListView.ts | 44 +++ .../multiSession/sessionPanelController.ts | 294 ++++++++++++++++++ src/views/multiSession/sessionRegistry.ts | 168 ++++++++++ src/views/multiSession/sessionScopedState.ts | 41 +++ src/views/multiSession/settings.ts | 24 ++ src/views/multiSession/userPrefs.ts | 25 ++ webview/src/App.tsx | 73 +++-- webview/src/index.css | 39 +++ 20 files changed, 1291 insertions(+), 29 deletions(-) create mode 100644 src/views/multiSession/index.ts create mode 100644 src/views/multiSession/panelFactory.ts create mode 100644 src/views/multiSession/panelHtml.ts create mode 100644 src/views/multiSession/session.ts create mode 100644 src/views/multiSession/sessionCommands.ts create mode 100644 src/views/multiSession/sessionListTreeProvider.ts create mode 100644 src/views/multiSession/sessionListView.ts create mode 100644 src/views/multiSession/sessionPanelController.ts create mode 100644 src/views/multiSession/sessionRegistry.ts create mode 100644 src/views/multiSession/sessionScopedState.ts create mode 100644 src/views/multiSession/settings.ts create mode 100644 src/views/multiSession/userPrefs.ts diff --git a/package.json b/package.json index 5302cdc0..725f4e09 100644 --- a/package.json +++ b/package.json @@ -139,6 +139,11 @@ "type": "boolean", "default": false, "description": "Skip retrieving agent metadata to your DX project when publishing the authoring bundle to your org." + }, + "salesforce.agentforceDX.multiSession.enabled": { + "type": "boolean", + "default": false, + "description": "Enable the multi-session preview UI (preview). Displays active sessions in the sidebar and opens each session in its own editor tab." } } }, @@ -155,7 +160,12 @@ "id": "sf.agent.combined.view", "name": "%agentforce_dx%", "type": "webview", - "when": "sf:project_opened" + "when": "sf:project_opened && !agentforceDX:multiSession" + }, + { + "id": "sf.agent.sessionList.view", + "name": "Agentforce DX Sessions", + "when": "sf:project_opened && agentforceDX:multiSession" } ] }, @@ -168,6 +178,13 @@ } ] }, + "viewsWelcome": [ + { + "view": "sf.agent.sessionList.view", + "contents": "No active agent sessions.\n[+ New session](command:sf.agent.sessionList.newSession)", + "when": "agentforceDX:multiSession" + } + ], "menus": { "sf.agent.combined.view.restartMenu": [ { @@ -247,6 +264,16 @@ "command": "sf.agent.test.view.toggleGeneratedData.off", "when": "view == sf.agent.test.view && !agentforceDX:showGeneratedData", "group": "navigation@4" + }, + { + "command": "sf.agent.sessionList.newSession", + "when": "view == sf.agent.sessionList.view", + "group": "navigation@1" + }, + { + "command": "sf.agent.sessionList.refreshAgents", + "when": "view == sf.agent.sessionList.view", + "group": "navigation@2" } ], "view/item/context": [ @@ -258,6 +285,11 @@ { "command": "sf.agent.test.view.goToTestResults", "when": "view == sf.agent.test.view && viewItem =~ /(agentTest|agentTestGroup)(_Pass|_Skip|\\b)/" + }, + { + "command": "sf.agent.sessionList.close", + "when": "view == sf.agent.sessionList.view && viewItem =~ /^afdxSession:/", + "group": "inline" } ], "editor/context": [ @@ -356,6 +388,38 @@ "command": "salesforcedx-vscode-agents.createAiAuthoringBundle", "when": "sf:project_opened" }, + { + "command": "sf.agent.sessionList.newSession", + "when": "sf:project_opened && agentforceDX:multiSession" + }, + { + "command": "sf.agent.sessionList.refreshAgents", + "when": "false" + }, + { + "command": "sf.agent.sessionList.reveal", + "when": "false" + }, + { + "command": "sf.agent.sessionList.close", + "when": "false" + }, + { + "command": "afdx.session.stop", + "when": "agentforceDX:multiSession" + }, + { + "command": "afdx.session.restart", + "when": "agentforceDX:multiSession" + }, + { + "command": "afdx.session.recompileAndRestart", + "when": "agentforceDX:multiSession" + }, + { + "command": "afdx.session.toggleDebug", + "when": "agentforceDX:multiSession" + }, { "command": "sf.agent.test.view.goToTestResults", "when": "false" @@ -574,6 +638,44 @@ "command": "salesforcedx-vscode-agents.createAiAuthoringBundle", "title": "AFDX: Create Agent", "icon": "$(add)" + }, + { + "command": "sf.agent.sessionList.newSession", + "title": "New Session", + "icon": "$(add)" + }, + { + "command": "sf.agent.sessionList.refreshAgents", + "title": "Refresh Agents", + "icon": "$(refresh)" + }, + { + "command": "sf.agent.sessionList.reveal", + "title": "Open Session" + }, + { + "command": "sf.agent.sessionList.close", + "title": "Close Session", + "icon": "$(close)" + }, + { + "command": "afdx.session.stop", + "title": "Stop Session", + "icon": "$(stop-circle)" + }, + { + "command": "afdx.session.restart", + "title": "Restart Session", + "icon": "$(debug-rerun)" + }, + { + "command": "afdx.session.recompileAndRestart", + "title": "Recompile & Restart" + }, + { + "command": "afdx.session.toggleDebug", + "title": "Toggle Apex Debug", + "icon": "$(bug)" } ] } diff --git a/src/commands/previewAgent.ts b/src/commands/previewAgent.ts index 7763cd39..d57d58c1 100644 --- a/src/commands/previewAgent.ts +++ b/src/commands/previewAgent.ts @@ -6,6 +6,7 @@ import { SfError, SfProject } from '@salesforce/core'; import { AgentCombinedViewProvider } from '../views/agentCombinedViewProvider'; import { Agent, AgentSource } from '@salesforce/agents'; import { Logger } from '../utils/logger'; +import { isMultiSessionEnabled } from '../views/multiSession/settings'; export const registerPreviewAgentCommand = () => { return vscode.commands.registerCommand(Commands.previewAgent, async (uri?: vscode.Uri) => { @@ -26,6 +27,14 @@ export const registerPreviewAgentCommand = () => { // Clear previous output logger.clear(); + // When multi-session UI is enabled, delegate to the panel-based flow so the + // user lands in a dedicated editor tab instead of the single sidebar webview. + if (isMultiSessionEnabled()) { + const targetUri = uri ?? vscode.Uri.file(filePath); + await vscode.commands.executeCommand('sf.agent.sessionList.openFromUri', targetUri); + return; + } + try { // Open the Agent Preview panel const provider = AgentCombinedViewProvider.getInstance(); diff --git a/src/extension.ts b/src/extension.ts index 1ae581d1..8ce2f97d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -25,6 +25,8 @@ import { AgentSource } from '@salesforce/agents'; import { getTestOutlineProvider } from './views/testOutlineProvider'; import { AgentTestRunner } from './views/testRunner'; import { toggleGeneratedDataOn, toggleGeneratedDataOff } from './commands/toggleGeneratedData'; +import { publishMultiSessionContext, onDidChangeMultiSessionEnabled, isMultiSessionEnabled } from './views/multiSession/settings'; +import { registerMultiSessionSurface } from './views/multiSession'; // Export the provider instance for testing purposes let agentCombinedViewProviderInstance: AgentCombinedViewProvider | undefined; @@ -54,6 +56,14 @@ export async function activate(context: vscode.ExtensionContext) { // Set initial value updateSkipRetrieveEnv(); + // Publish multi-session feature-flag context so `when` clauses can gate views/menus. + await publishMultiSessionContext(); + context.subscriptions.push( + onDidChangeMultiSessionEnabled(async () => { + await publishMultiSessionContext(); + }) + ); + // Register commands before initializing `testRunner` const disposables: vscode.Disposable[] = []; disposables.push(commands.registerOpenAgentInOrgCommand()); @@ -68,6 +78,13 @@ export async function activate(context: vscode.ExtensionContext) { const agentCombinedViewDisposable = registerAgentCombinedView(context); context.subscriptions.push(agentCombinedViewDisposable); + // Register the multi-session surface only when the flag is enabled. When the user + // toggles the setting, VS Code needs to reload for contributed views to reflect it, + // but commands/providers gated here are registered lazily at activation. + if (isMultiSessionEnabled()) { + context.subscriptions.push(registerMultiSessionSurface(context)); + } + // Update the test view without blocking activation setTimeout(() => { void getTestOutlineProvider().refresh(); @@ -250,6 +267,11 @@ const registerAgentCombinedView = (context: vscode.ExtensionContext): vscode.Dis // Command for selecting and running an agent const selectAndRunAgent = async () => { + if (isMultiSessionEnabled()) { + await vscode.commands.executeCommand('sf.agent.sessionList.newSession'); + return; + } + const selectedAgent = await showAgentPicker('Select an agent to run'); if (selectedAgent) { @@ -284,6 +306,11 @@ const registerAgentCombinedView = (context: vscode.ExtensionContext): vscode.Dis // Register start agent alias command disposables.push( vscode.commands.registerCommand('sf.agent.startAgent', async () => { + if (isMultiSessionEnabled()) { + await vscode.commands.executeCommand('sf.agent.sessionList.newSession'); + return; + } + const currentAgentId = provider.getCurrentAgentId(); if (currentAgentId) { diff --git a/src/views/agentCombined/handlers/webviewMessageHandlers.ts b/src/views/agentCombined/handlers/webviewMessageHandlers.ts index a76c27b5..30782fa0 100644 --- a/src/views/agentCombined/handlers/webviewMessageHandlers.ts +++ b/src/views/agentCombined/handlers/webviewMessageHandlers.ts @@ -5,7 +5,7 @@ import { CoreExtensionService } from '../../../services/coreExtensionService'; import type { TraceHistoryEntry } from '../../../utils/traceHistory'; import type { AgentMessage } from '../types'; import type { AgentViewState } from '../state'; -import type { WebviewMessageSender } from './webviewMessageSender'; +import type { WebviewHost, WebviewMessageSender } from './webviewMessageSender'; import type { SessionManager } from '../session'; import type { HistoryManager } from '../history'; import type { ApexDebugManager } from '../debugging'; @@ -25,7 +25,7 @@ export class WebviewMessageHandlers { private readonly historyManager: HistoryManager, private readonly apexDebugManager: ApexDebugManager, private readonly context: vscode.ExtensionContext, - private readonly webviewView: vscode.WebviewView + private readonly webviewHost: WebviewHost ) { this.logger = new Logger(CoreExtensionService.getChannelService()); } @@ -138,7 +138,7 @@ export class WebviewMessageHandlers { this.state.currentAgentSource = agentSource; const isLiveMode = data?.isLiveMode ?? false; - await this.sessionManager.startSession(agentId, agentSource, isLiveMode, this.webviewView); + await this.sessionManager.startSession(agentId, agentSource, isLiveMode, this.webviewHost); } private async handleSetApexDebugging(message: AgentMessage): Promise { diff --git a/src/views/agentCombined/handlers/webviewMessageSender.ts b/src/views/agentCombined/handlers/webviewMessageSender.ts index 9f1599a6..8c8f689e 100644 --- a/src/views/agentCombined/handlers/webviewMessageSender.ts +++ b/src/views/agentCombined/handlers/webviewMessageSender.ts @@ -3,23 +3,30 @@ import type { AgentViewState } from '../state/agentViewState'; import type { TraceHistoryEntry } from '../../../utils/traceHistory'; import type { JsonTokenColors } from '../../../utils/themeColors'; +/** + * Any host that exposes a `webview` — both `vscode.WebviewView` and `vscode.WebviewPanel` qualify. + */ +export interface WebviewHost { + webview: vscode.Webview; +} + /** * Handles all outgoing messages to the webview */ export class WebviewMessageSender { - private webviewView?: vscode.WebviewView; + private host?: WebviewHost; constructor(private readonly state: AgentViewState) {} - setWebview(webviewView: vscode.WebviewView): void { - this.webviewView = webviewView; + setWebview(host: WebviewHost): void { + this.host = host; } private postMessage(command: string, data?: unknown): void { - if (!this.webviewView) { + if (!this.host) { return; } - this.webviewView.webview.postMessage({ command, data }); + this.host.webview.postMessage({ command, data }); } // Session messages diff --git a/src/views/agentCombined/session/sessionManager.ts b/src/views/agentCombined/session/sessionManager.ts index 345bf44f..489dbc8e 100644 --- a/src/views/agentCombined/session/sessionManager.ts +++ b/src/views/agentCombined/session/sessionManager.ts @@ -34,9 +34,9 @@ export class SessionManager { agentId: string, agentSource: AgentSource, isLiveMode?: boolean, - webviewView?: any + webviewHost?: unknown ): Promise { - if (!webviewView) { + if (!webviewHost) { throw new Error('Webview is not ready. Please ensure the view is visible.'); } diff --git a/src/views/multiSession/index.ts b/src/views/multiSession/index.ts new file mode 100644 index 00000000..52c8859e --- /dev/null +++ b/src/views/multiSession/index.ts @@ -0,0 +1,145 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import { AgentSource } from '@salesforce/agents'; +import { CoreExtensionService } from '../../services/coreExtensionService'; +import { SfProject } from '@salesforce/core'; +import { Agent } from '@salesforce/agents'; +import { SessionRegistry } from './sessionRegistry'; +import { UserPrefs } from './userPrefs'; +import { PanelFactory } from './panelFactory'; +import { registerSessionListView } from './sessionListView'; +import { registerSessionScopedCommands } from './sessionCommands'; +import { getAgentSource } from '../agentCombined/agent'; + +export function registerMultiSessionSurface(context: vscode.ExtensionContext): vscode.Disposable { + const disposables: vscode.Disposable[] = []; + + const prefs = new UserPrefs(context); + const registry = new SessionRegistry(prefs); + const panelFactory = new PanelFactory(context, registry); + + disposables.push({ dispose: () => registry.dispose() }); + disposables.push({ dispose: () => panelFactory.dispose() }); + + disposables.push(registerSessionListView(context, registry, panelFactory)); + disposables.push(registerSessionScopedCommands(registry, panelFactory)); + + // + New session command (view title + palette) + disposables.push( + vscode.commands.registerCommand('sf.agent.sessionList.newSession', async () => { + const selection = await pickAgent('Select an agent to start'); + if (!selection) { + return; + } + const session = registry.create({ + agentId: selection.id, + agentSource: selection.source, + agentName: selection.displayName + }); + panelFactory.open(session); + }) + ); + + // Refresh agents button in view title. We can't enumerate org agents eagerly without cost, + // so this command just clears any cached data and forces the next picker/open to fetch fresh. + disposables.push( + vscode.commands.registerCommand('sf.agent.sessionList.refreshAgents', async () => { + SfProject.clearInstances(); + void vscode.window.showInformationMessage('Agentforce DX: Agent list refreshed.'); + }) + ); + + // Bridge from the existing `previewAgent` command (right-click .agent file) into a new panel. + disposables.push( + vscode.commands.registerCommand('sf.agent.sessionList.openFromUri', async (uri: vscode.Uri) => { + if (!uri) { + return; + } + const filePath = uri.fsPath; + const aabName = path.basename(path.dirname(filePath)); + const session = registry.create({ + agentId: aabName, + agentSource: AgentSource.SCRIPT, + agentName: aabName + }); + panelFactory.open(session); + }) + ); + + return vscode.Disposable.from(...disposables); +} + +interface AgentPick { + id: string; + source: AgentSource; + displayName: string; +} + +async function pickAgent(placeHolder: string): Promise { + try { + const conn = await CoreExtensionService.getDefaultConnection(); + const project = SfProject.getInstance(); + const agents = await Agent.listPreviewable(conn, project); + + const scriptAgents = agents.filter(a => a.source === AgentSource.SCRIPT); + const publishedAgents = agents.filter(a => a.source === AgentSource.PUBLISHED); + + const items: Array<{ + label: string; + description?: string; + id?: string; + source?: AgentSource; + kind?: vscode.QuickPickItemKind; + displayName?: string; + }> = []; + + if (scriptAgents.length > 0) { + items.push({ label: 'Agent Script', kind: vscode.QuickPickItemKind.Separator }); + for (const agent of scriptAgents) { + const id = agent.aabName ?? agent.id; + if (!id) continue; + const label = (agent.developerName ?? agent.aabName) as string; + items.push({ + label, + description: agent.id ? path.basename(agent.id) : undefined, + id, + source: agent.source, + displayName: label + }); + } + } + + if (publishedAgents.length > 0) { + items.push({ label: 'Published', kind: vscode.QuickPickItemKind.Separator }); + for (const agent of publishedAgents) { + const id = agent.id; + if (!id) continue; + const label = (agent.developerName ?? agent.aabName) as string; + items.push({ + label, + id, + source: agent.source, + displayName: label + }); + } + } + + if (items.length === 0) { + void vscode.window.showErrorMessage('No agents found.'); + return undefined; + } + + const picked = await vscode.window.showQuickPick(items, { placeHolder }); + if (!picked?.id || !picked.source) { + return undefined; + } + return { id: picked.id, source: picked.source, displayName: picked.displayName ?? picked.label }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + void vscode.window.showErrorMessage(`Failed to list agents: ${msg}`); + return undefined; + } +} + +export { SessionRegistry, UserPrefs, PanelFactory }; +export { getAgentSource }; diff --git a/src/views/multiSession/panelFactory.ts b/src/views/multiSession/panelFactory.ts new file mode 100644 index 00000000..64ebbea1 --- /dev/null +++ b/src/views/multiSession/panelFactory.ts @@ -0,0 +1,61 @@ +import * as vscode from 'vscode'; +import type { Session } from './session'; +import type { SessionRegistry } from './sessionRegistry'; +import { SessionPanelController, SESSION_PANEL_VIEW_TYPE } from './sessionPanelController'; + +export class PanelFactory { + private readonly controllers = new Map(); + + constructor( + private readonly context: vscode.ExtensionContext, + private readonly registry: SessionRegistry + ) { + this.context.subscriptions.push( + this.registry.onDidChange(evt => { + if (evt.kind === 'removed') { + this.controllers.delete(evt.session.id); + } + }) + ); + } + + /** + * Creates a new editor-area panel bound to the given session. + * Uses retainContextWhenHidden so the webview keeps its React state when tabs switch. + */ + open(session: Session): SessionPanelController { + const existing = this.controllers.get(session.id); + if (existing) { + existing.reveal(); + return existing; + } + + const title = session.agentName ?? session.agentId; + const panel = vscode.window.createWebviewPanel( + SESSION_PANEL_VIEW_TYPE, + title, + vscode.ViewColumn.Active, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [vscode.Uri.joinPath(this.context.extensionUri, 'webview', 'dist')] + } + ); + + const controller = new SessionPanelController(this.context, this.registry, session, panel); + this.controllers.set(session.id, controller); + this.context.subscriptions.push(panel); + return controller; + } + + get(sessionId: string): SessionPanelController | undefined { + return this.controllers.get(sessionId); + } + + dispose(): void { + for (const controller of this.controllers.values()) { + controller.dispose(); + } + this.controllers.clear(); + } +} diff --git a/src/views/multiSession/panelHtml.ts b/src/views/multiSession/panelHtml.ts new file mode 100644 index 00000000..a4c7e2a1 --- /dev/null +++ b/src/views/multiSession/panelHtml.ts @@ -0,0 +1,42 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; +import { AgentSource } from '@salesforce/agents'; +import type { Session } from './session'; + +export function renderSessionPanelHtml( + context: vscode.ExtensionContext, + session: Session +): string { + const htmlPath = path.join(context.extensionPath, 'webview', 'dist', 'index.html'); + let html = fs.readFileSync(htmlPath, 'utf8'); + + const bootstrap = { + localId: session.id, + agentId: session.agentId, + agentSource: session.agentSource, + agentName: session.agentName ?? null, + liveMode: session.liveMode, + apexDebug: session.apexDebug, + status: session.status, + multiSession: true + }; + + const injected = ` + + `; + + html = html.replace('', `${injected}`); + return html; +} diff --git a/src/views/multiSession/session.ts b/src/views/multiSession/session.ts new file mode 100644 index 00000000..2ec81d61 --- /dev/null +++ b/src/views/multiSession/session.ts @@ -0,0 +1,35 @@ +import { AgentSource } from '@salesforce/agents'; +import type { AgentInstance } from '../agentCombined/types'; + +export type SessionStatus = 'draft' | 'starting' | 'active' | 'inactive' | 'error'; + +export interface Session { + readonly id: string; + agentId: string; + agentSource: AgentSource; + agentName?: string; + status: SessionStatus; + sessionId?: string; + agentInstance?: AgentInstance; + liveMode: boolean; + apexDebug: boolean; + currentPlanId?: string; + currentUserMessage?: string; + sessionStartOperationId: number; + pendingStartAgentId?: string; + pendingStartAgentSource?: AgentSource; + createdAt: number; +} + +export type SessionChangeKind = + | 'created' + | 'updated' + | 'statusChanged' + | 'removed' + | 'focused'; + +export interface SessionChangeEvent { + kind: SessionChangeKind; + session: Session; + previousStatus?: SessionStatus; +} diff --git a/src/views/multiSession/sessionCommands.ts b/src/views/multiSession/sessionCommands.ts new file mode 100644 index 00000000..05d3a70e --- /dev/null +++ b/src/views/multiSession/sessionCommands.ts @@ -0,0 +1,58 @@ +import * as vscode from 'vscode'; +import type { PanelFactory } from './panelFactory'; +import type { SessionRegistry } from './sessionRegistry'; +import type { SessionPanelController } from './sessionPanelController'; + +/** + * Commands that operate on the currently-focused session panel. Each command posts a + * directive to that panel's webview, which already owns the per-session state and routes + * through `WebviewMessageHandlers` — same code path the UI uses. + */ +export function registerSessionScopedCommands( + registry: SessionRegistry, + panelFactory: PanelFactory +): vscode.Disposable { + const disposables: vscode.Disposable[] = []; + + const focused = (): SessionPanelController | undefined => { + const session = registry.getFocused(); + if (!session) { + return undefined; + } + return panelFactory.get(session.id); + }; + + const withFocused = async ( + missingMsg: string, + action: (controller: SessionPanelController) => unknown | Promise + ): Promise => { + const controller = focused(); + if (!controller) { + void vscode.window.showWarningMessage(missingMsg); + return; + } + try { + await action(controller); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + void vscode.window.showErrorMessage(msg); + } + }; + + disposables.push( + vscode.commands.registerCommand('afdx.session.stop', () => + withFocused('Focus an agent session tab to stop it.', c => c.stop()) + ), + vscode.commands.registerCommand('afdx.session.restart', () => + withFocused('Focus an agent session tab to restart it.', c => c.restart()) + ), + vscode.commands.registerCommand('afdx.session.recompileAndRestart', () => + withFocused('Focus an agent session tab to recompile.', c => c.recompileAndRestart()) + ), + vscode.commands.registerCommand('afdx.session.toggleDebug', () => + withFocused('Focus an agent session tab to toggle debug.', c => c.toggleDebug()) + ) + ); + + return vscode.Disposable.from(...disposables); +} diff --git a/src/views/multiSession/sessionListTreeProvider.ts b/src/views/multiSession/sessionListTreeProvider.ts new file mode 100644 index 00000000..f973cd2b --- /dev/null +++ b/src/views/multiSession/sessionListTreeProvider.ts @@ -0,0 +1,104 @@ +import * as vscode from 'vscode'; +import type { Session } from './session'; +import type { SessionRegistry } from './sessionRegistry'; + +export const SESSION_LIST_VIEW_ID = 'sf.agent.sessionList.view'; + +export class SessionListTreeProvider implements vscode.TreeDataProvider { + private readonly emitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this.emitter.event; + private readonly disposables: vscode.Disposable[] = []; + + constructor(private readonly registry: SessionRegistry) { + this.disposables.push( + this.registry.onDidChange(() => { + this.emitter.fire(); + }) + ); + } + + getTreeItem(session: Session): vscode.TreeItem { + const label = session.agentName ?? session.agentId; + const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.None); + item.id = session.id; + item.contextValue = `afdxSession:${session.status}`; + item.description = describeStatus(session); + item.iconPath = iconForStatus(session); + item.tooltip = buildTooltip(session); + item.command = { + command: 'sf.agent.sessionList.reveal', + title: 'Open Session', + arguments: [session.id] + }; + return item; + } + + getChildren(element?: Session): vscode.ProviderResult { + if (element) { + return []; + } + // Only sessions that currently exist (draft, starting, active, inactive tabs still open, error). + return this.registry.list(); + } + + dispose(): void { + for (const d of this.disposables) { + try { + d.dispose(); + } catch { + /* ignore */ + } + } + this.emitter.dispose(); + } +} + +function describeStatus(session: Session): string | undefined { + switch (session.status) { + case 'draft': + return 'not started'; + case 'starting': + return session.liveMode ? 'starting (live)' : 'starting (preview)'; + case 'active': + return session.liveMode ? 'live' : 'preview'; + case 'inactive': + return 'inactive'; + case 'error': + return 'error'; + default: + return undefined; + } +} + +function iconForStatus(session: Session): vscode.ThemeIcon { + switch (session.status) { + case 'active': + return session.liveMode + ? new vscode.ThemeIcon('circle-filled', new vscode.ThemeColor('charts.green')) + : new vscode.ThemeIcon('circle-filled', new vscode.ThemeColor('charts.blue')); + case 'starting': + return new vscode.ThemeIcon('loading~spin'); + case 'error': + return new vscode.ThemeIcon('error', new vscode.ThemeColor('charts.red')); + case 'draft': + case 'inactive': + default: + return new vscode.ThemeIcon('circle-outline'); + } +} + +function buildTooltip(session: Session): string { + const parts = [ + `Agent: ${session.agentName ?? session.agentId}`, + `Source: ${session.agentSource}`, + `Status: ${session.status}`, + `Mode: ${session.liveMode ? 'live' : 'preview'}` + ]; + if (session.apexDebug) { + parts.push('Apex debug: on'); + } + if (session.sessionId) { + parts.push(`Session ID: ${session.sessionId}`); + } + return parts.join('\n'); +} diff --git a/src/views/multiSession/sessionListView.ts b/src/views/multiSession/sessionListView.ts new file mode 100644 index 00000000..8a2ff812 --- /dev/null +++ b/src/views/multiSession/sessionListView.ts @@ -0,0 +1,44 @@ +import * as vscode from 'vscode'; +import type { PanelFactory } from './panelFactory'; +import type { SessionRegistry } from './sessionRegistry'; +import { SESSION_LIST_VIEW_ID, SessionListTreeProvider } from './sessionListTreeProvider'; + +export function registerSessionListView( + _context: vscode.ExtensionContext, + registry: SessionRegistry, + panelFactory: PanelFactory +): vscode.Disposable { + const disposables: vscode.Disposable[] = []; + + const treeProvider = new SessionListTreeProvider(registry); + disposables.push( + vscode.window.registerTreeDataProvider(SESSION_LIST_VIEW_ID, treeProvider), + treeProvider + ); + + disposables.push( + vscode.commands.registerCommand('sf.agent.sessionList.reveal', (sessionId: string) => { + const controller = panelFactory.get(sessionId); + if (controller) { + controller.reveal(); + } + }) + ); + + disposables.push( + vscode.commands.registerCommand('sf.agent.sessionList.close', (itemOrId?: { id?: string } | string) => { + const sessionId = typeof itemOrId === 'string' ? itemOrId : itemOrId?.id; + if (!sessionId) { + return; + } + const controller = panelFactory.get(sessionId); + if (controller) { + controller.dispose(); + } else { + registry.remove(sessionId); + } + }) + ); + + return vscode.Disposable.from(...disposables); +} diff --git a/src/views/multiSession/sessionPanelController.ts b/src/views/multiSession/sessionPanelController.ts new file mode 100644 index 00000000..ba838871 --- /dev/null +++ b/src/views/multiSession/sessionPanelController.ts @@ -0,0 +1,294 @@ +import * as vscode from 'vscode'; +import { AgentSource } from '@salesforce/agents'; +import type { Session } from './session'; +import type { SessionRegistry } from './sessionRegistry'; +import { WebviewMessageSender, WebviewMessageHandlers } from '../agentCombined/handlers'; +import { SessionManager } from '../agentCombined/session'; +import { HistoryManager } from '../agentCombined/history'; +import { ApexDebugManager } from '../agentCombined/debugging'; +import { AgentInitializer } from '../agentCombined/agent'; +import { SessionScopedState } from './sessionScopedState'; +import { renderSessionPanelHtml } from './panelHtml'; + +export const SESSION_PANEL_VIEW_TYPE = 'afdx.sessionPanel'; + +export class SessionPanelController { + readonly panel: vscode.WebviewPanel; + readonly sessionLocalId: string; + + private readonly disposables: vscode.Disposable[] = []; + private readonly state: SessionScopedState; + private readonly messageSender: WebviewMessageSender; + private readonly sessionManager: SessionManager; + private readonly historyManager: HistoryManager; + private readonly apexDebugManager: ApexDebugManager; + private readonly agentInitializer: AgentInitializer; + private readonly messageHandlers: WebviewMessageHandlers; + private disposed = false; + + constructor( + private readonly context: vscode.ExtensionContext, + private readonly registry: SessionRegistry, + session: Session, + panel: vscode.WebviewPanel + ) { + this.panel = panel; + this.sessionLocalId = session.id; + + // Each panel owns its own isolated set of managers, mirroring the single-sidebar setup. + this.state = new SessionScopedState(context); + // Seed state with the session's agent context so history loads and session starts can use it. + this.state.currentAgentId = session.agentId; + this.state.currentAgentSource = session.agentSource; + void this.state.setLiveMode(session.liveMode); + void this.state.setDebugMode(session.apexDebug); + + this.messageSender = new WebviewMessageSender(this.state); + this.messageSender.setWebview(this.wrapHostForOutboundTracking(panel)); + + this.agentInitializer = new AgentInitializer(this.state); + this.historyManager = new HistoryManager(this.state, this.messageSender); + this.apexDebugManager = new ApexDebugManager(this.messageSender); + this.sessionManager = new SessionManager( + this.state, + this.messageSender, + this.agentInitializer, + this.historyManager + ); + + this.messageHandlers = new WebviewMessageHandlers( + this.state, + this.messageSender, + this.sessionManager, + this.historyManager, + this.apexDebugManager, + context, + panel + ); + + panel.webview.options = { + enableScripts: true, + localResourceRoots: [vscode.Uri.joinPath(context.extensionUri, 'webview', 'dist')] + }; + panel.webview.html = renderSessionPanelHtml(context, session); + + this.disposables.push( + panel.webview.onDidReceiveMessage(async message => { + try { + const shouldForward = await this.interceptMessage(message); + if (!shouldForward) { + return; + } + await this.messageHandlers.handleMessage(message); + } catch (err) { + try { + await this.messageHandlers.handleError(err); + } catch (handlerErr) { + console.error('Critical error in session panel message handling:', handlerErr); + } + } + }), + panel.onDidChangeViewState(e => { + if (e.webviewPanel.active) { + this.registry.focus(this.sessionLocalId); + void this.state.republishContext(); + } + }), + panel.onDidDispose(() => this.dispose()) + ); + + this.disposables.push( + this.registry.onDidChange(evt => { + if (evt.session.id !== this.sessionLocalId) { + return; + } + // Reflect apex debug flips pushed by the registry (e.g. conflict releases). + if (evt.kind === 'updated') { + this.syncDebugFromRegistry(); + } + }) + ); + } + + private syncDebugFromRegistry(): void { + const session = this.registry.get(this.sessionLocalId); + if (!session) { + return; + } + if (session.apexDebug !== this.state.isApexDebuggingEnabled) { + void this.state.setDebugMode(session.apexDebug); + if (this.state.agentInstance) { + this.state.agentInstance.preview.setApexDebugging(session.apexDebug); + } + } + } + + /** + * Bridges a handful of messages to the `SessionRegistry` so the sidebar and cross-session + * coordination (apex debug, status) stay in sync with per-panel state changes. + * Returns false if the message has been fully handled here and should not be forwarded + * to the inner handlers. + */ + private async interceptMessage( + message: { command?: string; type?: string; data?: unknown } + ): Promise { + const command = message.command ?? message.type; + if (!command) { + return true; + } + + switch (command) { + case 'startSession': { + const data = (message.data ?? {}) as { + agentId?: string; + agentSource?: AgentSource; + isLiveMode?: boolean; + }; + this.registry.update(this.sessionLocalId, { + liveMode: data.isLiveMode ?? this.registry.get(this.sessionLocalId)?.liveMode ?? false + }); + this.registry.setStatus(this.sessionLocalId, 'starting'); + return true; + } + case 'setApexDebugging': { + const enabled = Boolean(message.data); + if (enabled) { + const result = this.registry.tryEnableApexDebug(this.sessionLocalId); + if (!result.ok) { + const name = result.conflictingSession.agentName ?? result.conflictingSession.agentId; + void vscode.window.showErrorMessage( + `Apex debugging is already enabled on "${name}". Disable it there first.` + ); + const session = this.registry.get(this.sessionLocalId); + if (session) { + this.messageSender.sendConfiguration('apexDebug', session.apexDebug); + } + return false; + } + } else { + this.registry.disableApexDebug(this.sessionLocalId); + } + return true; + } + case 'setLiveMode': { + const data = (message.data ?? {}) as { isLiveMode?: boolean }; + if (typeof data.isLiveMode === 'boolean') { + this.registry.update(this.sessionLocalId, { liveMode: data.isLiveMode }); + } + return true; + } + case 'endSession': { + this.registry.setStatus(this.sessionLocalId, 'inactive'); + return true; + } + default: + return true; + } + } + + reveal(): void { + this.panel.reveal(this.panel.viewColumn ?? vscode.ViewColumn.Active); + } + + async stop(): Promise { + await this.sessionManager.endSession(); + } + + async restart(): Promise { + await this.sessionManager.restartSession(); + } + + async recompileAndRestart(): Promise { + await this.sessionManager.recompileAndRestartSession(); + } + + async toggleDebug(): Promise { + const nextValue = !this.state.isApexDebuggingEnabled; + if (nextValue) { + const result = this.registry.tryEnableApexDebug(this.sessionLocalId); + if (!result.ok) { + const name = result.conflictingSession.agentName ?? result.conflictingSession.agentId; + void vscode.window.showErrorMessage( + `Apex debugging is already enabled on "${name}". Disable it there first.` + ); + return; + } + } else { + this.registry.disableApexDebug(this.sessionLocalId); + } + await this.state.setDebugMode(nextValue); + if (this.state.agentInstance) { + this.state.agentInstance.preview.setApexDebugging(nextValue); + } + } + + /** + * Returns a host whose `postMessage` mirrors outbound status-affecting messages back into the registry. + * The real `vscode.Webview` is still used for all actual messaging. + */ + private wrapHostForOutboundTracking(panel: vscode.WebviewPanel): { webview: vscode.Webview } { + const originalWebview = panel.webview; + const trackStatusFromOutbound = (command: string): void => { + switch (command) { + case 'sessionStarted': + this.registry.setStatus(this.sessionLocalId, 'active'); + break; + case 'sessionEnded': + this.registry.setStatus(this.sessionLocalId, 'inactive'); + break; + case 'compilationError': + case 'error': + this.registry.setStatus(this.sessionLocalId, 'error'); + break; + case 'sessionStarting': + case 'compilationStarting': + case 'simulationStarting': + this.registry.setStatus(this.sessionLocalId, 'starting'); + break; + default: + break; + } + }; + + const proxiedWebview: vscode.Webview = new Proxy(originalWebview, { + get: (target, prop, receiver) => { + if (prop === 'postMessage') { + return (msg: { command?: string }) => { + if (msg?.command) { + trackStatusFromOutbound(msg.command); + } + return target.postMessage(msg); + }; + } + return Reflect.get(target, prop, receiver); + } + }); + + return { webview: proxiedWebview }; + } + + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + + // Best-effort end the SDK session so we don't leak remote preview state. + if (this.state.agentInstance && this.state.sessionId) { + void this.sessionManager.endSession().catch(err => { + console.error('Failed to end session during panel disposal:', err); + }); + } + + for (const d of this.disposables) { + try { + d.dispose(); + } catch { + /* ignore */ + } + } + this.disposables.length = 0; + + this.registry.remove(this.sessionLocalId); + } +} diff --git a/src/views/multiSession/sessionRegistry.ts b/src/views/multiSession/sessionRegistry.ts new file mode 100644 index 00000000..2ff90657 --- /dev/null +++ b/src/views/multiSession/sessionRegistry.ts @@ -0,0 +1,168 @@ +import * as vscode from 'vscode'; +import { AgentSource } from '@salesforce/agents'; +import type { Session, SessionChangeEvent, SessionStatus } from './session'; +import type { UserPrefs } from './userPrefs'; + +export interface CreateSessionInput { + agentId: string; + agentSource: AgentSource; + agentName?: string; +} + +export class SessionRegistry { + private readonly sessions = new Map(); + private readonly emitter = new vscode.EventEmitter(); + private focusedSessionId: string | undefined; + private nextLocalId = 1; + + readonly onDidChange = this.emitter.event; + + constructor(private readonly prefs: UserPrefs) {} + + create(input: CreateSessionInput): Session { + const id = `afdx-session-${this.nextLocalId++}-${Date.now()}`; + const session: Session = { + id, + agentId: input.agentId, + agentSource: input.agentSource, + agentName: input.agentName, + status: 'draft', + liveMode: input.agentSource === AgentSource.PUBLISHED ? true : this.prefs.defaultLiveMode, + apexDebug: this.prefs.defaultApexDebug, + sessionStartOperationId: 0, + createdAt: Date.now() + }; + + // Enforce single-debug-attachment constraint on create. + if (session.apexDebug && this.anyDebugAttached()) { + session.apexDebug = false; + } + + this.sessions.set(id, session); + this.emitter.fire({ kind: 'created', session }); + return session; + } + + get(id: string): Session | undefined { + return this.sessions.get(id); + } + + list(): Session[] { + return Array.from(this.sessions.values()); + } + + listActive(): Session[] { + return this.list().filter(s => s.status === 'starting' || s.status === 'active' || s.status === 'error'); + } + + remove(id: string): void { + const session = this.sessions.get(id); + if (!session) { + return; + } + this.sessions.delete(id); + if (this.focusedSessionId === id) { + this.focusedSessionId = undefined; + } + this.emitter.fire({ kind: 'removed', session }); + } + + setStatus(id: string, status: SessionStatus): void { + const session = this.sessions.get(id); + if (!session) { + return; + } + const previousStatus = session.status; + if (previousStatus === status) { + return; + } + session.status = status; + this.emitter.fire({ kind: 'statusChanged', session, previousStatus }); + } + + update(id: string, patch: Partial): void { + const session = this.sessions.get(id); + if (!session) { + return; + } + Object.assign(session, patch); + this.emitter.fire({ kind: 'updated', session }); + } + + focus(id: string | undefined): void { + if (this.focusedSessionId === id) { + return; + } + this.focusedSessionId = id; + if (id) { + const session = this.sessions.get(id); + if (session) { + this.emitter.fire({ kind: 'focused', session }); + } + } + } + + getFocused(): Session | undefined { + return this.focusedSessionId ? this.sessions.get(this.focusedSessionId) : undefined; + } + + tryEnableApexDebug(id: string): { ok: true } | { ok: false; conflictingSession: Session } { + const session = this.sessions.get(id); + if (!session) { + return { ok: true }; + } + for (const other of this.sessions.values()) { + if (other.id !== id && other.apexDebug) { + return { ok: false, conflictingSession: other }; + } + } + session.apexDebug = true; + this.emitter.fire({ kind: 'updated', session }); + return { ok: true }; + } + + disableApexDebug(id: string): void { + const session = this.sessions.get(id); + if (!session || !session.apexDebug) { + return; + } + session.apexDebug = false; + this.emitter.fire({ kind: 'updated', session }); + } + + beginSessionStart(id: string): number { + const session = this.sessions.get(id); + if (!session) { + return 0; + } + session.sessionStartOperationId += 1; + return session.sessionStartOperationId; + } + + isSessionStartActive(id: string, operationId: number): boolean { + const session = this.sessions.get(id); + return !!session && session.sessionStartOperationId === operationId; + } + + cancelPendingSessionStart(id: string): void { + const session = this.sessions.get(id); + if (!session) { + return; + } + session.sessionStartOperationId += 1; + } + + dispose(): void { + this.emitter.dispose(); + this.sessions.clear(); + } + + private anyDebugAttached(): boolean { + for (const session of this.sessions.values()) { + if (session.apexDebug) { + return true; + } + } + return false; + } +} diff --git a/src/views/multiSession/sessionScopedState.ts b/src/views/multiSession/sessionScopedState.ts new file mode 100644 index 00000000..b546f0c4 --- /dev/null +++ b/src/views/multiSession/sessionScopedState.ts @@ -0,0 +1,41 @@ +import * as vscode from 'vscode'; +import { AgentViewState } from '../agentCombined/state'; + +/** + * Session-scoped view state: same shape as `AgentViewState`, but toggles for live mode and + * Apex debug do NOT mutate globalState. Initial values are seeded from globalState (the + * "sticky default for new tabs"), while subsequent changes stay local to this session. + * + * VS Code's `setContext` commands are still fired for UI affordances gated by `when` clauses; + * the focus-tracking layer is responsible for coalescing those across panels. + */ +export class SessionScopedState extends AgentViewState { + async setDebugMode(enabled: boolean): Promise { + // Duplicate just enough of the base behavior to avoid writing globalState. + (this as unknown as { _isApexDebuggingEnabled: boolean })._isApexDebuggingEnabled = enabled; + await vscode.commands.executeCommand('setContext', 'agentforceDX:debugMode', enabled); + } + + async setLiveMode(isLive: boolean): Promise { + (this as unknown as { _isLiveMode: boolean })._isLiveMode = isLive; + await vscode.commands.executeCommand('setContext', 'agentforceDX:isLiveMode', isLive); + } + + /** + * Re-publishes the subset of context keys that gate view title and menu visibility. + * Called when this panel becomes the focused session, so UI affordances match the + * just-focused tab rather than whichever tab last mutated state. + */ + async republishContext(): Promise { + await vscode.commands.executeCommand('setContext', 'agentforceDX:debugMode', this.isApexDebuggingEnabled); + await vscode.commands.executeCommand('setContext', 'agentforceDX:isLiveMode', this.isLiveMode); + await vscode.commands.executeCommand('setContext', 'agentforceDX:sessionActive', this.isSessionActive); + await vscode.commands.executeCommand('setContext', 'agentforceDX:sessionStarting', this.isSessionStarting); + await vscode.commands.executeCommand( + 'setContext', + 'agentforceDX:isScriptAgent', + this.currentAgentSource === 'script' + ); + await vscode.commands.executeCommand('setContext', 'agentforceDX:agentSelected', Boolean(this.currentAgentId)); + } +} diff --git a/src/views/multiSession/settings.ts b/src/views/multiSession/settings.ts new file mode 100644 index 00000000..5a369705 --- /dev/null +++ b/src/views/multiSession/settings.ts @@ -0,0 +1,24 @@ +import * as vscode from 'vscode'; + +const SETTING_SECTION = 'salesforce.agentforceDX'; +const MULTI_SESSION_KEY = 'multiSession.enabled'; +export const MULTI_SESSION_CONTEXT_KEY = 'agentforceDX:multiSession'; + +export function isMultiSessionEnabled(): boolean { + const config = vscode.workspace.getConfiguration(SETTING_SECTION); + return config.get(MULTI_SESSION_KEY, false); +} + +export async function publishMultiSessionContext(): Promise { + await vscode.commands.executeCommand('setContext', MULTI_SESSION_CONTEXT_KEY, isMultiSessionEnabled()); +} + +export function onDidChangeMultiSessionEnabled( + handler: (enabled: boolean) => void +): vscode.Disposable { + return vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(`${SETTING_SECTION}.${MULTI_SESSION_KEY}`)) { + handler(isMultiSessionEnabled()); + } + }); +} diff --git a/src/views/multiSession/userPrefs.ts b/src/views/multiSession/userPrefs.ts new file mode 100644 index 00000000..1d7b738f --- /dev/null +++ b/src/views/multiSession/userPrefs.ts @@ -0,0 +1,25 @@ +import * as vscode from 'vscode'; + +const LIVE_MODE_KEY = 'agentforceDX.lastLiveMode'; +const DEBUG_MODE_KEY = 'agentforceDX.lastDebugMode'; +const EXPORT_DIR_KEY = 'agentforceDX.lastExportDirectory'; + +export class UserPrefs { + constructor(private readonly context: vscode.ExtensionContext) {} + + get defaultLiveMode(): boolean { + return this.context.globalState.get(LIVE_MODE_KEY, false); + } + + get defaultApexDebug(): boolean { + return this.context.globalState.get(DEBUG_MODE_KEY, false); + } + + getExportDirectory(): string | undefined { + return this.context.workspaceState.get(EXPORT_DIR_KEY); + } + + async setExportDirectory(directory: string): Promise { + await this.context.workspaceState.update(EXPORT_DIR_KEY, directory); + } +} diff --git a/webview/src/App.tsx b/webview/src/App.tsx index e77fc4fc..c942f08d 100644 --- a/webview/src/App.tsx +++ b/webview/src/App.tsx @@ -6,6 +6,23 @@ import TabNavigation from './components/shared/TabNavigation.js'; import { vscodeApi, AgentInfo, AgentSource } from './services/vscodeApi.js'; import './App.css'; +interface SessionBootstrap { + localId: string; + agentId: string; + agentSource: AgentSource; + agentName?: string | null; + liveMode: boolean; + apexDebug: boolean; + status: 'draft' | 'starting' | 'active' | 'inactive' | 'error'; + multiSession: boolean; +} + +declare global { + interface Window { + afdxSessionBootstrap?: SessionBootstrap; + } +} + interface SelectAgentMessage { agentId: string; forceRestart?: boolean; @@ -23,19 +40,30 @@ declare global { } } +const bootstrap = typeof window !== 'undefined' ? window.afdxSessionBootstrap : undefined; +const isSessionMode = Boolean(bootstrap?.multiSession); + const App: React.FC = () => { const [activeTab, setActiveTab] = useState<'preview' | 'tracer'>('preview'); - const [displayedAgentId, setDisplayedAgentIdState] = useState(''); - const [desiredAgentId, setDesiredAgentId] = useState(''); + const [displayedAgentId, setDisplayedAgentIdState] = useState(isSessionMode ? bootstrap!.agentId : ''); + const [desiredAgentId, setDesiredAgentId] = useState(isSessionMode ? bootstrap!.agentId : ''); const [restartTrigger, setRestartTrigger] = useState(0); const [isSessionTransitioning, setIsSessionTransitioning] = useState(false); const [isSessionActive, setIsSessionActive] = useState(false); const [isSessionStarting, setIsSessionStarting] = useState(false); const [hasSessionError, setHasSessionError] = useState(false); - const [isLiveMode, setIsLiveMode] = useState(false); - const [selectedAgentInfo, setSelectedAgentInfo] = useState(null); - const [hasAgents, setHasAgents] = useState(false); - const [isLoadingAgents, setIsLoadingAgents] = useState(true); + const [isLiveMode, setIsLiveMode] = useState(isSessionMode ? Boolean(bootstrap!.liveMode) : false); + const [selectedAgentInfo, setSelectedAgentInfo] = useState( + isSessionMode + ? { + id: bootstrap!.agentId, + name: bootstrap!.agentName ?? bootstrap!.agentId, + type: bootstrap!.agentSource as AgentSource + } + : null + ); + const [hasAgents, setHasAgents] = useState(isSessionMode); + const [isLoadingAgents, setIsLoadingAgents] = useState(!isSessionMode); const sessionChangeQueueRef = useRef(Promise.resolve()); const displayedAgentIdRef = useRef(''); const desiredAgentIdRef = useRef(''); @@ -142,6 +170,11 @@ const App: React.FC = () => { // Request initial live mode state from extension vscodeApi.getInitialLiveMode(); + if (isSessionMode && bootstrap) { + // Pre-select the agent on the extension side so history/traces load for the correct agent. + vscodeApi.setSelectedAgentId(bootstrap.agentId, bootstrap.agentSource); + } + return () => { disposeSelectAgent(); disposeRefreshAgents(); @@ -366,18 +399,22 @@ const App: React.FC = () => {
{!hasSessionError && (
- -
+ {!isSessionMode && ( + <> + +
+ + )} {previewAgentId !== '' && !isSessionStarting && ( )} diff --git a/webview/src/index.css b/webview/src/index.css index b884aea9..fd0028c4 100644 --- a/webview/src/index.css +++ b/webview/src/index.css @@ -16,6 +16,45 @@ body { padding: 0; } +/* Session panel (editor tab) matches the active tab's background — cleaner color than + the broader editor background in most themes. */ +body.afdx-session-mode { + background-color: var(--vscode-tab-activeBackground, var(--vscode-editor-background)); + color: var(--vscode-tab-activeForeground, var(--vscode-editor-foreground)); +} + +/* Strip background colors from container-level components so the body color shows through. + This applies only inside a session panel; the legacy sidebar layout is unaffected. */ +body.afdx-session-mode .app, +body.afdx-session-mode .app-menu, +body.afdx-session-mode .app-content, +body.afdx-session-mode .agent-preview, +body.afdx-session-mode .agent-tracer, +body.afdx-session-mode .agent-selector, +body.afdx-session-mode .form-container, +body.afdx-session-mode .tab-navigation, +body.afdx-session-mode .header, +body.afdx-session-mode .plan-info, +body.afdx-session-mode .session-info { + background-color: transparent; +} + +/* Composer input and step card surfaces: inherit the panel background so they don't show + up as tinted cards against the tab-active color. */ +body.afdx-session-mode .chat-input, +body.afdx-session-mode .step-action, +body.afdx-session-mode .step-user-message, +body.afdx-session-mode .step-response-validation, +body.afdx-session-mode .step-topic, +body.afdx-session-mode .step-topic-selection, +body.afdx-session-mode .step-action-selection, +body.afdx-session-mode .step-agent-response, +body.afdx-session-mode input, +body.afdx-session-mode textarea { + background-color: transparent; + color: inherit; +} + #root { width: 100%; height: 100vh; From 9223e376269490fd881333bb2183de0029fb5be8 Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Tue, 28 Apr 2026 15:00:43 +0200 Subject: [PATCH 2/3] fix: session panel backgrounds use tab-active color Sets the body and top-level container panels (`.app`, `.agent-preview`, `.agent-tracer`, `.app-menu`, `.tab-navigation`, `.form-container`, `.chat-input`) inside a session panel to use `--vscode-tab-activeBackground` so the session tab's content area matches the VS Code tab strip instead of falling back to the sidebar color. Co-Authored-By: Claude Opus 4.7 --- webview/src/index.css | 33 ++++++--------------------------- 1 file changed, 6 insertions(+), 27 deletions(-) diff --git a/webview/src/index.css b/webview/src/index.css index fd0028c4..78f84e50 100644 --- a/webview/src/index.css +++ b/webview/src/index.css @@ -16,43 +16,22 @@ body { padding: 0; } -/* Session panel (editor tab) matches the active tab's background — cleaner color than - the broader editor background in most themes. */ +/* Session panel (editor tab) matches the active tab's background color. */ body.afdx-session-mode { background-color: var(--vscode-tab-activeBackground, var(--vscode-editor-background)); color: var(--vscode-tab-activeForeground, var(--vscode-editor-foreground)); } -/* Strip background colors from container-level components so the body color shows through. - This applies only inside a session panel; the legacy sidebar layout is unaffected. */ +/* Container-level panels paint their own sidebar-colored background by default. Inside + session panels we want them to inherit the tab-active body color instead. */ body.afdx-session-mode .app, -body.afdx-session-mode .app-menu, -body.afdx-session-mode .app-content, body.afdx-session-mode .agent-preview, body.afdx-session-mode .agent-tracer, -body.afdx-session-mode .agent-selector, -body.afdx-session-mode .form-container, +body.afdx-session-mode .app-menu, body.afdx-session-mode .tab-navigation, -body.afdx-session-mode .header, -body.afdx-session-mode .plan-info, -body.afdx-session-mode .session-info { - background-color: transparent; -} - -/* Composer input and step card surfaces: inherit the panel background so they don't show - up as tinted cards against the tab-active color. */ -body.afdx-session-mode .chat-input, -body.afdx-session-mode .step-action, -body.afdx-session-mode .step-user-message, -body.afdx-session-mode .step-response-validation, -body.afdx-session-mode .step-topic, -body.afdx-session-mode .step-topic-selection, -body.afdx-session-mode .step-action-selection, -body.afdx-session-mode .step-agent-response, -body.afdx-session-mode input, -body.afdx-session-mode textarea { +body.afdx-session-mode .form-container, +body.afdx-session-mode .chat-input { background-color: transparent; - color: inherit; } #root { From 84652c77236c371b2ec333be57dd4d6b02cd1e03 Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Tue, 28 Apr 2026 16:39:42 +0200 Subject: [PATCH 3/3] feat: activate agent version from multi-session sidebar Adds a versions icon to the session list view title and an editor/title action on session panels so users can activate or deactivate a published agent's version in the multi-session UI. The sidebar button prompts for the agent first; the panel button targets the focused session. --- package.json | 45 +++++++++ src/views/multiSession/index.ts | 92 ++++++++++++++++++ src/views/multiSession/sessionCommands.ts | 3 + .../multiSession/sessionPanelController.ts | 93 ++++++++++++++++++- 4 files changed, 232 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 725f4e09..dec9f38c 100644 --- a/package.json +++ b/package.json @@ -270,10 +270,37 @@ "when": "view == sf.agent.sessionList.view", "group": "navigation@1" }, + { + "command": "sf.agent.sessionList.activateVersion", + "when": "view == sf.agent.sessionList.view", + "group": "navigation@2" + }, { "command": "sf.agent.sessionList.refreshAgents", "when": "view == sf.agent.sessionList.view", + "group": "navigation@3" + } + ], + "editor/title": [ + { + "command": "afdx.session.activateVersion", + "when": "activeWebviewPanelId == 'afdx.sessionPanel' && agentforceDX:multiSession && agentforceDX:agentSelected && !agentforceDX:isScriptAgent && !agentforceDX:sessionStarting", + "group": "navigation@0" + }, + { + "command": "afdx.session.stop", + "when": "activeWebviewPanelId == 'afdx.sessionPanel' && agentforceDX:multiSession && (agentforceDX:sessionActive || agentforceDX:sessionStarting)", + "group": "navigation@1" + }, + { + "command": "afdx.session.restart", + "when": "activeWebviewPanelId == 'afdx.sessionPanel' && agentforceDX:multiSession && agentforceDX:sessionActive && agentforceDX:isScriptAgent", "group": "navigation@2" + }, + { + "command": "afdx.session.toggleDebug", + "when": "activeWebviewPanelId == 'afdx.sessionPanel' && agentforceDX:multiSession && agentforceDX:sessionActive && agentforceDX:isLiveMode", + "group": "navigation@3" } ], "view/item/context": [ @@ -404,6 +431,10 @@ "command": "sf.agent.sessionList.close", "when": "false" }, + { + "command": "sf.agent.sessionList.activateVersion", + "when": "sf:project_opened && agentforceDX:multiSession" + }, { "command": "afdx.session.stop", "when": "agentforceDX:multiSession" @@ -420,6 +451,10 @@ "command": "afdx.session.toggleDebug", "when": "agentforceDX:multiSession" }, + { + "command": "afdx.session.activateVersion", + "when": "agentforceDX:multiSession" + }, { "command": "sf.agent.test.view.goToTestResults", "when": "false" @@ -676,6 +711,16 @@ "command": "afdx.session.toggleDebug", "title": "Toggle Apex Debug", "icon": "$(bug)" + }, + { + "command": "afdx.session.activateVersion", + "title": "Activate Version (Focused Session)", + "icon": "$(versions)" + }, + { + "command": "sf.agent.sessionList.activateVersion", + "title": "Activate Agent Version", + "icon": "$(versions)" } ] } diff --git a/src/views/multiSession/index.ts b/src/views/multiSession/index.ts index 52c8859e..357354fd 100644 --- a/src/views/multiSession/index.ts +++ b/src/views/multiSession/index.ts @@ -10,6 +10,7 @@ import { PanelFactory } from './panelFactory'; import { registerSessionListView } from './sessionListView'; import { registerSessionScopedCommands } from './sessionCommands'; import { getAgentSource } from '../agentCombined/agent'; +import { buildVersionPickerItems } from '../../commands/activateAgent'; export function registerMultiSessionSurface(context: vscode.ExtensionContext): vscode.Disposable { const disposables: vscode.Disposable[] = []; @@ -49,6 +50,72 @@ export function registerMultiSessionSurface(context: vscode.ExtensionContext): v }) ); + // Activate a published agent's version. Picks the agent first (published only), then a version. + disposables.push( + vscode.commands.registerCommand('sf.agent.sessionList.activateVersion', async () => { + const agentId = await pickPublishedAgent('Select a published agent'); + if (!agentId) { + return; + } + try { + const conn = await CoreExtensionService.getDefaultConnection(); + const project = SfProject.getInstance(); + const agent = await Agent.init({ connection: conn, project, apiNameOrId: agentId }); + const meta = await agent.getBotMetadata(); + const versions = meta.BotVersions.records + .filter((v: { IsDeleted?: boolean }) => !v.IsDeleted) + .map((v: { VersionNumber: number; Status: string }) => ({ + VersionNumber: v.VersionNumber, + Status: v.Status + })); + if (versions.length === 0) { + void vscode.window.showErrorMessage('No versions found for this agent.'); + return; + } + + const picked = await vscode.window.showQuickPick( + buildVersionPickerItems(versions, { includeDeactivate: true }), + { placeHolder: 'Select a version to activate' } + ); + if (!picked) { + return; + } + + if (picked.action === 'deactivate') { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Deactivating agent...', + cancellable: false + }, + async () => { + await agent.deactivate(); + void vscode.window.showInformationMessage('Agent deactivated.'); + } + ); + return; + } + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Activating version ${picked.versionNumber}...`, + cancellable: false + }, + async () => { + const result = await agent.activate(picked.versionNumber!); + void vscode.window.showInformationMessage( + `Version ${result.VersionNumber} activated.` + ); + } + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + void vscode.window.showErrorMessage(`Failed to activate version: ${msg}`); + } + }) + ); + // Bridge from the existing `previewAgent` command (right-click .agent file) into a new panel. disposables.push( vscode.commands.registerCommand('sf.agent.sessionList.openFromUri', async (uri: vscode.Uri) => { @@ -75,6 +142,31 @@ interface AgentPick { displayName: string; } +async function pickPublishedAgent(placeHolder: string): Promise { + try { + const conn = await CoreExtensionService.getDefaultConnection(); + const project = SfProject.getInstance(); + const agents = await Agent.listPreviewable(conn, project); + const published = agents.filter(a => a.source === AgentSource.PUBLISHED && a.id); + + if (published.length === 0) { + void vscode.window.showErrorMessage('No published agents found.'); + return undefined; + } + + const items = published.map(agent => ({ + label: (agent.developerName ?? agent.aabName) as string, + id: agent.id! + })); + const picked = await vscode.window.showQuickPick(items, { placeHolder }); + return picked?.id; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + void vscode.window.showErrorMessage(`Failed to list agents: ${msg}`); + return undefined; + } +} + async function pickAgent(placeHolder: string): Promise { try { const conn = await CoreExtensionService.getDefaultConnection(); diff --git a/src/views/multiSession/sessionCommands.ts b/src/views/multiSession/sessionCommands.ts index 05d3a70e..957b0b9c 100644 --- a/src/views/multiSession/sessionCommands.ts +++ b/src/views/multiSession/sessionCommands.ts @@ -51,6 +51,9 @@ export function registerSessionScopedCommands( ), vscode.commands.registerCommand('afdx.session.toggleDebug', () => withFocused('Focus an agent session tab to toggle debug.', c => c.toggleDebug()) + ), + vscode.commands.registerCommand('afdx.session.activateVersion', () => + withFocused('Focus an agent session tab to activate a version.', c => c.activateVersion()) ) ); diff --git a/src/views/multiSession/sessionPanelController.ts b/src/views/multiSession/sessionPanelController.ts index ba838871..071427b3 100644 --- a/src/views/multiSession/sessionPanelController.ts +++ b/src/views/multiSession/sessionPanelController.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; -import { AgentSource } from '@salesforce/agents'; +import { Agent, AgentSource } from '@salesforce/agents'; +import { SfProject } from '@salesforce/core'; import type { Session } from './session'; import type { SessionRegistry } from './sessionRegistry'; import { WebviewMessageSender, WebviewMessageHandlers } from '../agentCombined/handlers'; @@ -9,6 +10,8 @@ import { ApexDebugManager } from '../agentCombined/debugging'; import { AgentInitializer } from '../agentCombined/agent'; import { SessionScopedState } from './sessionScopedState'; import { renderSessionPanelHtml } from './panelHtml'; +import { CoreExtensionService } from '../../services/coreExtensionService'; +import { buildVersionPickerItems } from '../../commands/activateAgent'; export const SESSION_PANEL_VIEW_TYPE = 'afdx.sessionPanel'; @@ -42,6 +45,7 @@ export class SessionPanelController { this.state.currentAgentSource = session.agentSource; void this.state.setLiveMode(session.liveMode); void this.state.setDebugMode(session.apexDebug); + void this.state.setAgentSelected(true); this.messageSender = new WebviewMessageSender(this.state); this.messageSender.setWebview(this.wrapHostForOutboundTracking(panel)); @@ -202,6 +206,93 @@ export class SessionPanelController { await this.sessionManager.recompileAndRestartSession(); } + async activateVersion(): Promise { + const session = this.registry.get(this.sessionLocalId); + if (!session) { + return; + } + if (session.agentSource !== AgentSource.PUBLISHED) { + void vscode.window.showErrorMessage('Version activation is only available for published agents.'); + return; + } + + const agentId = session.agentId; + + let versions = this.state.agentVersionsCache.get(agentId); + if (!versions || versions.length === 0) { + try { + const conn = await CoreExtensionService.getDefaultConnection(); + const project = SfProject.getInstance(); + const agent = await Agent.init({ connection: conn, project, apiNameOrId: agentId }); + const meta = await agent.getBotMetadata(); + versions = meta.BotVersions.records + .filter((v: { IsDeleted?: boolean }) => !v.IsDeleted) + .map((v: { VersionNumber: number; Status: string }) => ({ + VersionNumber: v.VersionNumber, + Status: v.Status + })); + this.state.agentVersionsCache.set(agentId, versions); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + void vscode.window.showErrorMessage(`Failed to load versions: ${msg}`); + return; + } + } + + if (!versions || versions.length === 0) { + void vscode.window.showErrorMessage('No versions found for this agent.'); + return; + } + + const picked = await vscode.window.showQuickPick( + buildVersionPickerItems(versions, { includeDeactivate: true }), + { placeHolder: 'Select a version to activate' } + ); + if (!picked) { + return; + } + + if (picked.action === 'deactivate') { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Deactivating agent...', + cancellable: false + }, + async () => { + const conn = await CoreExtensionService.getDefaultConnection(); + const project = SfProject.getInstance(); + const agent = await Agent.init({ connection: conn, project, apiNameOrId: agentId }); + await agent.deactivate(); + for (const v of versions!) { + v.Status = 'Inactive'; + } + void vscode.window.showInformationMessage('Agent deactivated.'); + } + ); + return; + } + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Activating version ${picked.versionNumber}...`, + cancellable: false + }, + async () => { + const conn = await CoreExtensionService.getDefaultConnection(); + const project = SfProject.getInstance(); + const agent = await Agent.init({ connection: conn, project, apiNameOrId: agentId }); + const result = await agent.activate(picked.versionNumber!); + const activatedVersion = result.VersionNumber as number; + for (const v of versions!) { + v.Status = v.VersionNumber === activatedVersion ? 'Active' : 'Inactive'; + } + void vscode.window.showInformationMessage(`Version ${activatedVersion} activated.`); + } + ); + } + async toggleDebug(): Promise { const nextValue = !this.state.isApexDebuggingEnabled; if (nextValue) {