Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
02a0bb6
feat: add History tab to resume prior agent sessions from disk
marcelinollano May 12, 2026
6a63fd0
chore: regenerate package-lock files
marcelinollano May 12, 2026
7414fa1
test: cover History tab session resume flow
marcelinollano May 12, 2026
02ef685
Merge remote-tracking branch 'origin/main' into ml/W-22434464-navigat…
marcelinollano May 14, 2026
57afca6
style: align session history message column with fixed-width badges
marcelinollano May 15, 2026
dd6922b
feat: load history sessions without starting; add Resume and Clear
marcelinollano May 15, 2026
af52e7b
test: cover preview-from-history flow and toolbar reorg
marcelinollano May 15, 2026
3132b8f
fix: stub @salesforce/core in coreExtensionService tests
marcelinollano May 15, 2026
3d20b67
Merge remote-tracking branch 'origin/main' into ml/W-22434464-navigat…
marcelinollano May 15, 2026
1067a34
fix: stabilize stopping-session-to-preview transition
marcelinollano May 15, 2026
21b05fa
test: cover stopping-transition ordering, hasLoadedSession context, t…
marcelinollano May 15, 2026
3fc2cb0
style: tweak history placeholder copy and tracer filter placeholder
marcelinollano May 15, 2026
a682923
Merge remote-tracking branch 'origin/main' into ml/W-22434464-navigat…
marcelinollano May 16, 2026
21d59c7
fix: keep stop label and surface resume after session ends
marcelinollano May 16, 2026
bc1167a
fix: skip resumable preview on restart-driven session end
marcelinollano May 17, 2026
4153c8b
style: rename toolbar action to Clear Chat Session
marcelinollano May 17, 2026
88ddf24
feat: render GuardrailsStep in tracer with shield icon
marcelinollano May 17, 2026
68e5ec4
test: cover endSession restarting flag and setConversation flows
marcelinollano May 17, 2026
4d3966f
Merge remote-tracking branch 'origin/main' into ml/W-22434464-navigat…
marcelinollano May 17, 2026
8cba68e
feat: gate toolbar and chat UI during session stop transitions
marcelinollano May 18, 2026
55ca0a0
fix: capture prior session identity before overwriting agent source
marcelinollano May 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 23 additions & 18 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -180,47 +180,47 @@
"view/title": [
{
"command": "sf.agent.combined.view.exportConversation",
"when": "view == sf.agent.combined.view && agentforceDX:agentSelected && !agentforceDX:sessionStarting && !agentforceDX:sessionError && agentforceDX:hasConversationData",
"group": "navigation@-1"
},
{
"command": "salesforcedx-vscode-agents.createAiAuthoringBundle",
"when": "view == sf.agent.combined.view && !agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:canResetAgentView && !agentforceDX:agentSelected",
"group": "navigation@-1"
"when": "view == sf.agent.combined.view && 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:canResetAgentView && !agentforceDX:agentSelected",
"group": "navigation@0"
"when": "view == sf.agent.combined.view && !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",
"group": "navigation@0"
"when": "view == sf.agent.combined.view && 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",
"group": "navigation@4"
},
{
"command": "sf.agent.combined.view.refreshAgents",
"when": "view == sf.agent.combined.view && !agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:canResetAgentView",
"group": "navigation@1"
"when": "view == sf.agent.combined.view && !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:agentSelected && agentforceDX:canResetAgentView",
"when": "view == sf.agent.combined.view && !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:agentSelected && agentforceDX:isScriptAgent",
"group": "navigation@0"
"when": "view == sf.agent.combined.view && 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:debugMode && agentforceDX:isLiveMode",
"when": "view == sf.agent.combined.view && 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:debugMode && agentforceDX:isLiveMode",
"when": "view == sf.agent.combined.view && agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:sessionStopping && agentforceDX:debugMode && agentforceDX:isLiveMode",
"group": "navigation@-2"
},
{
Expand Down Expand Up @@ -465,6 +465,11 @@
"title": "Refresh Agent List",
"icon": "$(refresh)"
},
{
"command": "sf.agent.combined.view.clearLoadedSession",
"title": "Clear Chat Session",
"icon": "$(clear-all)"
},
{
"command": "sf.agent.combined.view.resetAgentView",
"title": "Reset",
Expand Down
6 changes: 6 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,12 @@ const registerAgentCombinedView = (context: vscode.ExtensionContext): vscode.Dis
})
);

disposables.push(
vscode.commands.registerCommand('sf.agent.combined.view.clearLoadedSession', () => {
provider.clearLoadedSession();
})
);

disposables.push(
vscode.commands.registerCommand('sf.agent.combined.view.resetAgentView', async () => {
const currentAgentId = provider.getCurrentAgentId();
Expand Down
191 changes: 182 additions & 9 deletions src/views/agentCombined/handlers/webviewMessageHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { HistoryManager } from '../history';
import type { ApexDebugManager } from '../debugging';
import { Logger } from '../../../utils/logger';
import { getAgentSource } from '../agent';
import { listSessionsForAgent } from '../session';

/**
* Handles all incoming messages from the webview
Expand Down Expand Up @@ -44,7 +45,7 @@ export class WebviewMessageHandlers {
startSession: async msg => await this.handleStartSession(msg),
setApexDebugging: async msg => await this.handleSetApexDebugging(msg),
sendChatMessage: async msg => await this.handleSendChatMessage(msg),
endSession: async () => await this.handleEndSession(),
endSession: async msg => await this.handleEndSession(msg),
loadAgentHistory: async msg => await this.handleLoadAgentHistory(msg),
getAvailableAgents: async () => await this.handleGetAvailableAgents(),
getTraceData: async () => await this.handleGetTraceData(),
Expand All @@ -54,6 +55,9 @@ export class WebviewMessageHandlers {
setSelectedAgentId: async msg => await this.handleSetSelectedAgentId(msg),
setLiveMode: async msg => await this.handleSetLiveMode(msg),
getInitialLiveMode: async () => await this.handleGetInitialLiveMode(),
listSessions: async msg => await this.handleListSessions(msg),
previewSession: async msg => await this.handlePreviewSession(msg),
clearPreviewedSession: async () => await this.handleClearPreviewedSession(),
// Test-specific commands for integration tests
clearMessages: async () => {
// Clear messages in the webview - no-op on extension side
Expand Down Expand Up @@ -136,6 +140,16 @@ export class WebviewMessageHandlers {
this.state.currentAgentSource = agentSource;

const isLiveMode = data?.isLiveMode ?? false;

// If a session is currently being previewed via the History tab, the Start
// button should resume that session rather than create a new one.
const previewedSessionId = this.state.previewedSessionId;
if (previewedSessionId && agentId === this.state.currentAgentId) {
this.state.previewedSessionId = undefined;
await this.sessionManager.resumeSession(agentId, agentSource, previewedSessionId, isLiveMode, this.webviewView);
return;
}

await this.sessionManager.startSession(agentId, agentSource, isLiveMode, this.webviewView);
}

Expand Down Expand Up @@ -214,14 +228,18 @@ export class WebviewMessageHandlers {
}
}

private async handleEndSession(): Promise<void> {
await this.sessionManager.endSession(async () => {
const agentId = this.state.pendingStartAgentId ?? this.state.currentAgentId;
if (agentId) {
const agentSource = this.state.pendingStartAgentSource ?? (await getAgentSource(agentId));
await this.historyManager.showHistoryOrPlaceholder(agentId, agentSource);
}
});
private async handleEndSession(message?: AgentMessage): Promise<void> {
const data = message?.data as { restarting?: boolean } | undefined;
await this.sessionManager.endSession(
async () => {
const agentId = this.state.pendingStartAgentId ?? this.state.currentAgentId;
if (agentId) {
const agentSource = this.state.pendingStartAgentSource ?? (await getAgentSource(agentId));
await this.historyManager.showHistoryOrPlaceholder(agentId, agentSource);
}
},
{ restarting: data?.restarting === true }
);
}

private async handleLoadAgentHistory(message: AgentMessage): Promise<void> {
Expand Down Expand Up @@ -312,6 +330,17 @@ export class WebviewMessageHandlers {
private async handleGetTraceData(): Promise<void> {
try {
if (this.state.currentAgentId && this.state.currentAgentSource) {
// When the user is previewing a specific historical session, load
// traces for that session (not falling back to "most recent"). Use
// the trace-only path so we don't re-render the chat.
if (this.state.previewedSessionId && !this.state.isSessionActive) {
await this.historyManager.loadAndSendTracesForSession(
this.state.currentAgentId,
this.state.currentAgentSource,
this.state.previewedSessionId
);
return;
}
await this.historyManager.loadAndSendTraceHistory(this.state.currentAgentId, this.state.currentAgentSource);
return;
}
Expand Down Expand Up @@ -351,6 +380,9 @@ export class WebviewMessageHandlers {
private async handleSetSelectedAgentId(message: AgentMessage): Promise<void> {
const data = message.data as { agentId?: string; agentSource?: AgentSource } | undefined;
const agentId = data?.agentId;
if (agentId !== this.state.currentAgentId) {
this.state.previewedSessionId = undefined;
}
if (agentId && typeof agentId === 'string' && agentId !== '') {
this.state.currentAgentId = agentId;
// Use passed agentSource if available to avoid expensive listPreviewable call
Expand Down Expand Up @@ -381,6 +413,147 @@ export class WebviewMessageHandlers {
this.messageSender.sendLiveMode(this.state.isLiveMode);
}

private async handleListSessions(message: AgentMessage): Promise<void> {
const data = message.data as { agentId?: string; agentSource?: AgentSource } | undefined;
const agentId = data?.agentId ?? this.state.currentAgentId;
if (!agentId || typeof agentId !== 'string') {
this.messageSender.sendSessionList('', []);
return;
}
try {
const agentSource = data?.agentSource ?? this.state.currentAgentSource ?? (await getAgentSource(agentId));
const sessions = await listSessionsForAgent(agentId, agentSource);
this.messageSender.sendSessionList(agentId, sessions);
} catch (err) {
console.error('Error listing sessions:', err);
this.messageSender.sendSessionList(agentId, []);
}
}

/**
* Loads a prior session's transcript + traces into the views without starting it.
* Records previewedSessionId so the next "start" click resumes this session
* instead of creating a new one.
*/
private async handlePreviewSession(message: AgentMessage): Promise<void> {
const data = message.data as
| { agentId?: string; agentSource?: AgentSource; sessionId?: string; sessionType?: 'simulated' | 'live' | 'published' }
| undefined;
const agentId = data?.agentId ?? this.state.currentAgentId;
const sessionId = data?.sessionId;

if (!agentId || typeof agentId !== 'string') {
throw new Error(`Invalid agent ID: ${agentId}. Expected a string.`);
}
if (!sessionId || typeof sessionId !== 'string') {
throw new Error(`Invalid session ID: ${sessionId}. Expected a string.`);
}

// No-op if this session is already the active live session.
if (
this.state.isSessionActive &&
this.state.sessionId === sessionId &&
this.state.sessionAgentId === agentId
) {
return;
}

let agentSource = data?.agentSource ?? this.state.currentAgentSource;
if (!agentSource) {
agentSource = await getAgentSource(agentId);
}

// Capture prior session identity BEFORE overwriting currentAgentSource.
// Without this, previousSource reads the new source and the wrong
// preview.end() argument is used to tear down the running session when
// the previewed session has a different source than the running one.
const previousAgent = this.state.agentInstance;
const previousSessionId = this.state.sessionId;
const previousSource = this.state.currentAgentSource;
const hadActiveSession = !!(previousAgent && previousSessionId);

this.state.currentAgentSource = agentSource;
this.state.currentAgentId = agentId;

// If a session is active, fully end it before showing the previewed
// conversation. We surface a loading state via sessionStarting so the input
// is disabled while the SDK round-trip completes (a few seconds for
// live/published sessions).
if (hadActiveSession) {
this.state.cancelPendingSessionStart();
// Flip context flags immediately so toolbar actions tied to sessionActive
// (debug, stop, etc.) hide right away during the stopping transition.
// The SDK round-trip below can take seconds and we don't want stale
// session-active toolbar buttons hanging around.
await this.state.setSessionActive(false);
await this.state.setSessionStarting(true);
await this.state.setSessionStopping(true);
// Send sessionStarting FIRST so the webview's isSessionStartingRef flips
// to true before the empty setConversation arrives — App.tsx uses that
// ref to distinguish a stopping-transition clear (preserve Resume label)
// from a toolbar Clear action (drop preview flag).
this.messageSender.sendSessionStarting('Stopping session...');
this.messageSender.sendSetConversation([], true, null);
this.messageSender.sendTraceHistory(agentId, []);
try {
if (previousSource === AgentSource.SCRIPT) {
await previousAgent!.preview.end();
} else {
await previousAgent!.preview.end('UserRequest');
}
} catch (err) {
console.warn('Error ending previous session before preview:', err);
}
try {
await previousAgent!.restoreConnection();
} catch (err) {
console.warn('Error restoring connection:', err);
}
this.state.clearSessionState();
// We deliberately keep isSessionStarting=true through the disk read below
// so the input stays disabled. Cleared after the preview is loaded.

// Re-establish current agent context cleared by clearSessionState so the
// previewed conversation is associated correctly.
this.state.currentAgentId = agentId;
this.state.currentAgentSource = agentSource;
} else if (this.state.isSessionStarting) {
// Cancel any in-flight start that hasn't produced an agent instance yet.
await this.sessionManager.endSession();
}

this.state.previewedSessionId = sessionId;

// Read the previewed session from disk and push it to the webview. The
// setConversation message includes previewSessionInfo so the start button
// flips to "Resume".
await this.historyManager.loadAndSendSessionPreview(agentId, agentSource, sessionId, data?.sessionType);

if (hadActiveSession) {
// Now that the previewed conversation is on screen, clear the
// starting/active state and emit sessionEnded so the input becomes
// editable and the start button shows "Resume".
await this.state.setSessionStarting(false);
await this.state.setSessionStopping(false);
this.messageSender.sendSessionEnded();
}
}

/**
* Drops the currently displayed conversation/traces so the user can start a
* fresh session from an empty chat. Does not touch on-disk session data.
*/
private async handleClearPreviewedSession(): Promise<void> {
this.state.previewedSessionId = undefined;
this.state.currentPlanId = undefined;
await this.state.setConversationDataAvailable(false);
this.messageSender.sendSetConversation([], true, null);
if (this.state.currentAgentId) {
this.messageSender.sendTraceHistory(this.state.currentAgentId, []);
}
this.messageSender.sendTraceData({ plan: [], planId: '', sessionId: '' });
}

async fetchAndSendActiveVersion(agentId: string): Promise<void> {
const conn = await CoreExtensionService.getDefaultConnection();
const project = SfProject.getInstance();
Expand Down
24 changes: 18 additions & 6 deletions src/views/agentCombined/handlers/webviewMessageSender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as vscode from 'vscode';
import type { AgentViewState } from '../state/agentViewState';
import type { TraceHistoryEntry } from '../../../utils/traceHistory';
import type { JsonTokenColors } from '../../../utils/themeColors';
import type { SessionListEntry } from '../session';

/**
* Handles all outgoing messages to the webview
Expand All @@ -27,12 +28,18 @@ export class WebviewMessageSender {
this.postMessage('sessionStarting', { message: message || 'Starting session...' });
}

sendSessionStarted(welcomeMessage?: string): void {
this.postMessage('sessionStarted', welcomeMessage);
sendSessionStarted(welcomeMessage?: string, sessionId?: string, skipWelcome?: boolean): void {
if (sessionId || skipWelcome) {
this.postMessage('sessionStarted', { welcomeMessage, sessionId, skipWelcome });
} else {
this.postMessage('sessionStarted', welcomeMessage);
}
}

sendSessionEnded(): void {
this.postMessage('sessionEnded', {});
sendSessionEnded(
previewSessionInfo?: { sessionId: string; sessionType?: 'simulated' | 'live' | 'published' }
): void {
this.postMessage('sessionEnded', { previewSessionInfo });
}

// Compilation messages
Expand Down Expand Up @@ -81,9 +88,10 @@ export class WebviewMessageSender {

sendSetConversation(
messages: Array<{ id: string; type: string; content: string; timestamp: number }>,
showPlaceholder: boolean
showPlaceholder: boolean,
previewSessionInfo?: { sessionId: string; sessionType?: 'simulated' | 'live' | 'published' } | null
): void {
this.postMessage('setConversation', { messages, showPlaceholder });
this.postMessage('setConversation', { messages, showPlaceholder, previewSessionInfo });
}

sendTraceHistory(agentId: string, entries: TraceHistoryEntry[]): void {
Expand All @@ -98,6 +106,10 @@ export class WebviewMessageSender {
this.postMessage('noHistoryFound', { agentId });
}

sendSessionList(agentId: string, sessions: SessionListEntry[]): void {
this.postMessage('sessionList', { agentId, sessions });
}

// Error messages
async sendError(message: string, details?: string): Promise<void> {
const sanitizedMessage = this.stripHtmlTags(message);
Expand Down
Loading
Loading