From 02a0bb6cf151a5b673a6e02283bc2edab3866979 Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Wed, 13 May 2026 00:38:14 +0100 Subject: [PATCH 01/17] feat: add History tab to resume prior agent sessions from disk Surfaces a new History tab in the agent webview that lists prior cached sessions read from .sfdx/agents//sessions/, the same on-disk format the sf CLI plugin uses. Clicking a row reattaches the agent via SDK resumeSession(sessionId), restores the transcript and traces, and hands control back to the Preview tab without restarting if the chosen session is already active. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../handlers/webviewMessageHandlers.ts | 54 +++++ .../handlers/webviewMessageSender.ts | 13 +- src/views/agentCombined/session/index.ts | 2 + .../session/sessionHistoryService.ts | 133 ++++++++++++ .../agentCombined/session/sessionManager.ts | 107 +++++++++- webview/src/App.tsx | 50 ++++- .../components/AgentPreview/AgentPreview.tsx | 2 +- .../SessionHistory/SessionHistory.css | 114 ++++++++++ .../SessionHistory/SessionHistory.tsx | 198 ++++++++++++++++++ .../src/components/shared/TabNavigation.css | 9 + .../src/components/shared/TabNavigation.tsx | 18 +- webview/src/services/vscodeApi.ts | 25 +++ 12 files changed, 713 insertions(+), 12 deletions(-) create mode 100644 src/views/agentCombined/session/sessionHistoryService.ts create mode 100644 webview/src/components/SessionHistory/SessionHistory.css create mode 100644 webview/src/components/SessionHistory/SessionHistory.tsx diff --git a/src/views/agentCombined/handlers/webviewMessageHandlers.ts b/src/views/agentCombined/handlers/webviewMessageHandlers.ts index a76c27b5..23c1774f 100644 --- a/src/views/agentCombined/handlers/webviewMessageHandlers.ts +++ b/src/views/agentCombined/handlers/webviewMessageHandlers.ts @@ -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 @@ -54,6 +55,8 @@ 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), + resumeSession: async msg => await this.handleResumeSession(msg), // Test-specific commands for integration tests clearMessages: async () => { // Clear messages in the webview - no-op on extension side @@ -383,6 +386,57 @@ export class WebviewMessageHandlers { this.messageSender.sendLiveMode(this.state.isLiveMode); } + private async handleListSessions(message: AgentMessage): Promise { + 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, []); + } + } + + private async handleResumeSession(message: AgentMessage): Promise { + const data = message.data as + | { agentId?: string; agentSource?: AgentSource; sessionId?: string; isLiveMode?: boolean } + | 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.`); + } + + let agentSource = data?.agentSource ?? this.state.currentAgentSource; + if (!agentSource) { + agentSource = await getAgentSource(agentId); + } + this.state.currentAgentSource = agentSource; + + const isLiveMode = data?.isLiveMode ?? this.state.isLiveMode ?? false; + + // If the requested session is already the active one, no need to restart + if ( + this.state.isSessionActive && + this.state.sessionId === sessionId && + this.state.sessionAgentId === agentId + ) { + return; + } + + await this.sessionManager.resumeSession(agentId, agentSource, sessionId, isLiveMode, this.webviewView); + } + async fetchAndSendActiveVersion(agentId: string): Promise { const conn = await CoreExtensionService.getDefaultConnection(); const project = SfProject.getInstance(); diff --git a/src/views/agentCombined/handlers/webviewMessageSender.ts b/src/views/agentCombined/handlers/webviewMessageSender.ts index 9f1599a6..2bc2f69a 100644 --- a/src/views/agentCombined/handlers/webviewMessageSender.ts +++ b/src/views/agentCombined/handlers/webviewMessageSender.ts @@ -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 @@ -27,8 +28,12 @@ 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 { @@ -98,6 +103,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 { const sanitizedMessage = this.stripHtmlTags(message); diff --git a/src/views/agentCombined/session/index.ts b/src/views/agentCombined/session/index.ts index 56618ab7..fad0791a 100644 --- a/src/views/agentCombined/session/index.ts +++ b/src/views/agentCombined/session/index.ts @@ -1,2 +1,4 @@ export { SessionManager } from './sessionManager'; export { createSessionStartGuards } from './sessionStartGuards'; +export { listSessionsForAgent } from './sessionHistoryService'; +export type { SessionListEntry } from './sessionHistoryService'; diff --git a/src/views/agentCombined/session/sessionHistoryService.ts b/src/views/agentCombined/session/sessionHistoryService.ts new file mode 100644 index 00000000..323a7341 --- /dev/null +++ b/src/views/agentCombined/session/sessionHistoryService.ts @@ -0,0 +1,133 @@ +import * as path from 'path'; +import { promises as fs } from 'fs'; +import { AgentSource } from '@salesforce/agents'; +import { getAllHistory } from '@salesforce/agents/lib/utils'; +import { SfProject } from '@salesforce/core'; +import { getAgentStorageKey } from '../agent/agentUtils'; + +export type SessionListEntry = { + sessionId: string; + timestamp?: string; + sessionType?: 'simulated' | 'live' | 'published'; + firstUserMessage?: string; +}; + +const resolveProjectLocalSfdx = async (): Promise => { + try { + const project = await SfProject.resolve(); + return path.join(project.getPath(), '.sfdx'); + } catch { + return path.join(process.cwd(), '.sfdx'); + } +}; + +/** + * Reads per-session metadata from `.sfdx/agents//sessions//`, + * which is the shared on-disk format used by the sf CLI plugin. + */ +const readSessionMeta = async ( + sessionDir: string, + storageKey: string +): Promise<{ timestamp?: string; sessionType?: SessionListEntry['sessionType'] }> => { + const result: { timestamp?: string; sessionType?: SessionListEntry['sessionType'] } = {}; + + try { + const raw = await fs.readFile(path.join(sessionDir, 'session-meta.json'), 'utf8'); + const meta = JSON.parse(raw); + if (typeof meta.timestamp === 'string') { + result.timestamp = meta.timestamp; + } + if (meta.sessionType === 'simulated' || meta.sessionType === 'live' || meta.sessionType === 'published') { + result.sessionType = meta.sessionType; + } + } catch { + // No cache marker; fall through to metadata.json + } + + let metadataMockMode: string | undefined; + if (!result.timestamp || !result.sessionType) { + try { + const raw = await fs.readFile(path.join(sessionDir, 'metadata.json'), 'utf8'); + const meta = JSON.parse(raw); + if (!result.timestamp && typeof meta.startTime === 'string') { + result.timestamp = meta.startTime; + } + if (typeof meta.mockMode === 'string') { + metadataMockMode = meta.mockMode; + } + } catch { + // No metadata.json; fall through to mtime + } + } + + if (!result.sessionType) { + if (metadataMockMode === 'Live Test') { + result.sessionType = 'live'; + } else if (metadataMockMode === 'Mock') { + result.sessionType = 'simulated'; + } else if (storageKey.startsWith('0X') && (storageKey.length === 15 || storageKey.length === 18)) { + result.sessionType = 'published'; + } + } + + if (!result.timestamp) { + try { + const stats = await fs.stat(sessionDir); + result.timestamp = stats.mtime.toISOString(); + } catch { + // ignore + } + } + + return result; +}; + +/** + * Lists prior sessions for a single agent, newest first. + * Reads directly from `.sfdx/agents//sessions/` so the list + * matches what the sf CLI plugin sees on disk. + */ +export async function listSessionsForAgent( + agentId: string, + agentSource: AgentSource +): Promise { + const storageKey = getAgentStorageKey(agentId, agentSource); + const base = await resolveProjectLocalSfdx(); + const sessionsDir = path.join(base, 'agents', storageKey, 'sessions'); + + let dirents; + try { + dirents = await fs.readdir(sessionsDir, { withFileTypes: true }); + } catch { + return []; + } + + const enriched = await Promise.all( + dirents + .filter(d => d.isDirectory()) + .map(async dirent => { + const sessionId = dirent.name; + const sessionDir = path.join(sessionsDir, sessionId); + const { timestamp, sessionType } = await readSessionMeta(sessionDir, storageKey); + + let firstUserMessage: string | undefined; + try { + const history = await getAllHistory(storageKey, sessionId); + const firstUser = history.transcript.find(t => t.role === 'user' && t.text); + firstUserMessage = firstUser?.text; + } catch { + // Best-effort; leave undefined + } + + return { sessionId, timestamp, sessionType, firstUserMessage }; + }) + ); + + enriched.sort((a, b) => { + const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0; + const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0; + return tb - ta; + }); + + return enriched; +} diff --git a/src/views/agentCombined/session/sessionManager.ts b/src/views/agentCombined/session/sessionManager.ts index 9d2d1a8c..51650451 100644 --- a/src/views/agentCombined/session/sessionManager.ts +++ b/src/views/agentCombined/session/sessionManager.ts @@ -93,7 +93,7 @@ export class SessionManager { // Send session started message const agentMessage = session.messages.find((msg: any) => msg.type === 'Inform'); - this.messageSender.sendSessionStarted(agentMessage?.message); + this.messageSender.sendSessionStarted(agentMessage?.message, this.state.sessionId); this.state.pendingStartAgentId = undefined; this.state.pendingStartAgentSource = undefined; await this.state.setConversationDataAvailable(true); @@ -123,6 +123,109 @@ export class SessionManager { } } + /** + * Resumes a previously cached session by loading its state from disk. + * Does NOT call preview.start() — keeps the same sessionId. + */ + async resumeSession( + agentId: string, + agentSource: AgentSource, + sessionId: string, + isLiveMode?: boolean, + webviewView?: any + ): Promise { + if (!webviewView) { + throw new Error('Webview is not ready. Please ensure the view is visible.'); + } + + const sessionStartId = this.state.beginSessionStart(); + const { ensureActive, isActive } = createSessionStartGuards(this.state, sessionStartId); + + try { + // Single beat: sessionActive=false + sessionStarting=true, message cleared. + // This avoids a flicker through the "no session" toolbar state. + await this.beginRestart(this.state.isLiveMode ? 'Resuming live test...' : 'Resuming session...'); + ensureActive(); + + // Tear down any prior SDK session in place. The agent instance is + // discarded (replaced below) so the previous session can be safely ended. + if (this.state.agentInstance && this.state.sessionId) { + try { + if (this.state.currentAgentSource === AgentSource.SCRIPT) { + await this.state.agentInstance.preview.end(); + } else { + await this.state.agentInstance.preview.end('UserRequest'); + } + } catch (error) { + console.warn('Error ending previous session before resume:', error); + } + try { + await this.state.agentInstance.restoreConnection(); + } catch (error) { + console.warn('Error restoring connection before resume:', error); + } + this.state.clearSessionState(); + ensureActive(); + } + + const conn = await CoreExtensionService.getDefaultConnection(); + ensureActive(); + + this.state.currentAgentId = agentId; + const project = SfProject.getInstance(); + + this.state.pendingStartAgentId = agentId; + this.state.pendingStartAgentSource = agentSource; + + if (agentSource === AgentSource.SCRIPT) { + await this.initializeScriptAgent(agentId, conn, project, isLiveMode, isActive, ensureActive); + } else { + await this.initializePublishedAgent(agentId, conn, project, ensureActive); + } + + if (!this.state.agentInstance) { + throw new Error('Failed to initialize agent instance.'); + } + + await this.state.agentInstance.resumeSession(sessionId); + ensureActive(); + this.state.sessionId = sessionId; + this.state.sessionAgentId = agentId; + this.logger.debug(`Resumed session for agent ${this.state.currentAgentName}. SessionId: ${sessionId}`); + + // Load conversation history first. The webview's conversationHistory + // handler temporarily flips sessionActive=false; sessionStarted (sent + // below) flips it back true so the input becomes editable. + await this.historyManager.loadAndSendConversationHistory(agentId, agentSource); + + await this.state.setSessionActive(true); + ensureActive(); + await this.state.setSessionStarting(false); + ensureActive(); + + // Send sessionStarted before traces so the tracer's reset-on-start + // doesn't wipe the trace history we're about to send. + this.messageSender.sendSessionStarted(undefined, this.state.sessionId, true); + this.state.pendingStartAgentId = undefined; + this.state.pendingStartAgentSource = undefined; + + await this.historyManager.loadAndSendTraceHistory(agentId, agentSource); + + await this.state.setConversationDataAvailable(true); + } catch (err) { + if (err instanceof SessionStartCancelledError || !isActive()) { + return; + } + + const sfError = SfError.wrap(err); + this.logger.error('Error resuming session', sfError); + await this.state.setSessionStarting(false); + await this.messageSender.sendError(`Failed to resume session: ${sfError.message}`); + await this.state.setResetAgentViewAvailable(true); + await this.state.setSessionErrorState(true); + } + } + /** * Ends the current agent session */ @@ -303,7 +406,7 @@ export class SessionManager { ensureActive?.(); const agentMessage = session.messages.find((msg: any) => msg.type === 'Inform'); - this.messageSender.sendSessionStarted(agentMessage?.message); + this.messageSender.sendSessionStarted(agentMessage?.message, this.state.sessionId); await this.state.setConversationDataAvailable(true); this.logger.debug(logMessage); diff --git a/webview/src/App.tsx b/webview/src/App.tsx index e77fc4fc..e32dd8e5 100644 --- a/webview/src/App.tsx +++ b/webview/src/App.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import AgentPreview, { AgentPreviewRef } from './components/AgentPreview/AgentPreview.js'; 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 { vscodeApi, AgentInfo, AgentSource } from './services/vscodeApi.js'; import './App.css'; @@ -24,7 +25,7 @@ declare global { } const App: React.FC = () => { - const [activeTab, setActiveTab] = useState<'preview' | 'tracer'>('preview'); + const [activeTab, setActiveTab] = useState<'preview' | 'tracer' | 'history'>('preview'); const [displayedAgentId, setDisplayedAgentIdState] = useState(''); const [desiredAgentId, setDesiredAgentId] = useState(''); const [restartTrigger, setRestartTrigger] = useState(0); @@ -32,6 +33,7 @@ const App: React.FC = () => { const [isSessionActive, setIsSessionActive] = useState(false); const [isSessionStarting, setIsSessionStarting] = useState(false); const [hasSessionError, setHasSessionError] = useState(false); + const activeSessionIdRef = useRef(undefined); const [isLiveMode, setIsLiveMode] = useState(false); const [selectedAgentInfo, setSelectedAgentInfo] = useState(null); const [hasAgents, setHasAgents] = useState(false); @@ -133,7 +135,7 @@ const App: React.FC = () => { vscodeApi.getTraceData(); }); - const disposeTestSwitchTab = vscodeApi.onMessage('testSwitchTab', (data: { tab: 'preview' | 'tracer' }) => { + const disposeTestSwitchTab = vscodeApi.onMessage('testSwitchTab', (data: { tab: 'preview' | 'tracer' | 'history' }) => { const tab = data?.tab || 'preview'; console.log('[Webview Test] testSwitchTab received:', tab); setActiveTab(tab); @@ -154,7 +156,7 @@ const App: React.FC = () => { }; }, []); - const handleTabChange = (tab: 'preview' | 'tracer') => { + const handleTabChange = (tab: 'preview' | 'tracer' | 'history') => { setActiveTab(tab); }; @@ -174,6 +176,13 @@ const App: React.FC = () => { } }, [selectedAgentInfo, activeTab, desiredAgentId]); + // Switch to preview tab when the agent changes while viewing history + useEffect(() => { + if (activeTab === 'history') { + setActiveTab('preview'); + } + }, [desiredAgentId]); + const handleGoToPreview = useCallback(() => { // If session is not active and we have a desired agent, start the session if (!isSessionActive && !isSessionStarting && desiredAgentId) { @@ -209,10 +218,13 @@ const App: React.FC = () => { }, []); useEffect(() => { - const disposeSessionStarted = vscodeApi.onMessage('sessionStarted', () => { + const disposeSessionStarted = vscodeApi.onMessage('sessionStarted', (data: any) => { sessionActiveRef.current = true; setIsSessionActive(true); setIsSessionStarting(false); + if (data && typeof data === 'object' && typeof data.sessionId === 'string') { + activeSessionIdRef.current = data.sessionId; + } const resolver = sessionStartResolversRef.current.shift(); if (resolver) { resolver(true); @@ -223,6 +235,7 @@ const App: React.FC = () => { sessionActiveRef.current = false; setIsSessionActive(false); setIsSessionStarting(false); + activeSessionIdRef.current = undefined; const resolver = sessionEndResolversRef.current.shift(); if (resolver) { resolver(); @@ -379,7 +392,12 @@ const App: React.FC = () => { />
{previewAgentId !== '' && !isSessionStarting && ( - + )}
)} @@ -408,6 +426,28 @@ const App: React.FC = () => { onLiveModeChange={handleLiveModeChange} /> +
+ { + const isAlreadyActive = sessionActiveRef.current && activeSessionIdRef.current === sessionId; + if (!isAlreadyActive) { + sessionActiveRef.current = false; + setIsSessionActive(false); + setIsSessionStarting(true); + vscodeApi.emitLocal('sessionStarting', { message: 'Resuming session...' }); + } + setActiveTab('preview'); + }} + onGoToPreview={handleGoToPreview} + onLiveModeChange={handleLiveModeChange} + /> +
); diff --git a/webview/src/components/AgentPreview/AgentPreview.tsx b/webview/src/components/AgentPreview/AgentPreview.tsx index e3d80ef2..65d62ed6 100644 --- a/webview/src/components/AgentPreview/AgentPreview.tsx +++ b/webview/src/components/AgentPreview/AgentPreview.tsx @@ -205,7 +205,7 @@ const AgentPreview = forwardRef( sessionErrorTimestampRef.current = 0; sessionActiveStateRef.current = true; - if (data) { + if (data && !data.skipWelcome) { setMessages(prev => { const newMessages = [...prev]; diff --git a/webview/src/components/SessionHistory/SessionHistory.css b/webview/src/components/SessionHistory/SessionHistory.css new file mode 100644 index 00000000..d6fb9953 --- /dev/null +++ b/webview/src/components/SessionHistory/SessionHistory.css @@ -0,0 +1,114 @@ +.session-history-list { + list-style: none; + margin: 0; + padding: 0; + overflow-y: auto; + height: 100%; +} + + +.session-history-item-button { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 18px; + background: none; + border: none; + text-align: left; + color: var(--vscode-foreground); + font-family: inherit; + font-size: 13px; + cursor: pointer; + transition: background-color 0.1s ease; + white-space: nowrap; + overflow: hidden; +} + +.session-history-item-button:hover, +.session-history-item-button:focus { + background-color: var(--vscode-list-hoverBackground); + outline: none; +} + +.session-history-item-message { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--vscode-foreground); + font-weight: 500; +} + +.session-history-item-timestamp { + font-size: 11px; + color: var(--vscode-descriptionForeground); + flex-shrink: 0; + margin-left: auto; + padding-left: 8px; +} + +.session-history-item-type { + padding: 2px 6px; + border-radius: 4px; + font-size: 11px; + line-height: 1.2; + flex-shrink: 0; + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); +} + +.session-history-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + color: var(--vscode-descriptionForeground); + height: 100%; + padding: 40px 40px 104px 40px; + max-width: 500px; + margin: 0 auto; +} + +.session-history-placeholder-icon { + margin-bottom: 30px; + opacity: 0.2; + width: 50px; + height: 50px; + background-image: url('../../assets/clock-light.svg'); + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} + +body.vscode-dark .session-history-placeholder-icon, +body.vscode-high-contrast .session-history-placeholder-icon { + background-image: url('../../assets/clock-dark.svg'); +} + +.session-history-placeholder p { + margin: 0 0 30px 0; + color: var(--vscode-foreground); + font-size: 14px; + font-weight: 500; + line-height: 1.5; + text-wrap: balance; +} + +.session-history-placeholder .vscode-button, +.session-history-placeholder .vscode-split-button { + width: auto; + min-width: auto; +} + +.session-history-placeholder .vscode-button__start svg, +.session-history-placeholder .vscode-split-button__start svg { + width: 10px; + height: 10px; +} + +.session-history-placeholder .session-history-send-icon .vscode-button__start svg { + width: 16px; + height: 16px; +} diff --git a/webview/src/components/SessionHistory/SessionHistory.tsx b/webview/src/components/SessionHistory/SessionHistory.tsx new file mode 100644 index 00000000..4c37159d --- /dev/null +++ b/webview/src/components/SessionHistory/SessionHistory.tsx @@ -0,0 +1,198 @@ +import React, { useEffect, useState } from 'react'; +import { vscodeApi, AgentInfo, AgentSource, SessionListEntry } from '../../services/vscodeApi.js'; +import { Button } from '../shared/Button.js'; +import { SplitButton } from '../shared/SplitButton.js'; +import './SessionHistory.css'; + +interface SessionHistoryProps { + agentId: string; + agentSource?: AgentSource; + isActive: boolean; + isSessionActive?: boolean; + isLiveMode: boolean; + selectedAgentInfo?: AgentInfo | null; + onResume: (sessionId: string) => void; + onGoToPreview?: () => void; + onLiveModeChange?: (isLive: boolean) => void; +} + +const SESSION_TYPE_LABELS: Record, string> = { + simulated: 'Simulation', + live: 'Live Test', + published: 'Live Test' +}; + +const formatTimestamp = (timestamp?: string): string => { + if (!timestamp) { + return ''; + } + try { + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return timestamp; + } + const now = new Date(); + const sameYear = date.getFullYear() === now.getFullYear(); + const datePart = date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + ...(sameYear ? {} : { year: '2-digit' }) + }); + const timePart = date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); + return `${datePart}, ${timePart}`; + } catch { + return timestamp; + } +}; + +const playIcon = ( + + + +); + +const sendIcon = ( + + + +); + +const SessionHistory: React.FC = ({ + agentId, + agentSource, + isActive, + isSessionActive = false, + isLiveMode, + selectedAgentInfo = null, + onResume, + onGoToPreview, + onLiveModeChange +}) => { + const [sessions, setSessions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [currentAgentId, setCurrentAgentId] = useState(agentId); + + useEffect(() => { + const dispose = vscodeApi.onMessage('sessionList', (data: { agentId: string; sessions: SessionListEntry[] }) => { + setIsLoading(false); + if (data?.agentId === currentAgentId) { + setSessions(data.sessions || []); + } + }); + return dispose; + }, [currentAgentId]); + + useEffect(() => { + if (!isActive || !agentId) { + return; + } + setCurrentAgentId(agentId); + setIsLoading(true); + setSessions([]); + vscodeApi.listSessions(agentId, agentSource); + }, [isActive, agentId, agentSource]); + + const handleResume = (sessionId: string) => { + if (!agentId) { + return; + } + vscodeApi.resumeSession(agentId, sessionId, { isLiveMode, agentSource }); + onResume(sessionId); + }; + + const renderPlaceholder = (message: string, showButton: boolean) => { + const isPublishedAgent = selectedAgentInfo?.type === AgentSource.PUBLISHED; + const buttonText = isSessionActive ? 'Send a message' : isLiveMode ? 'Start Live Test' : 'Start Simulation'; + const handleModeSelect = (value: string) => { + onLiveModeChange?.(value === 'live'); + }; + + return ( +
+
+

{message}

+ {showButton && + onGoToPreview && + (isSessionActive ? ( + + ) : isPublishedAgent ? ( + + ) : ( + + {buttonText} + + ))} +
+ ); + }; + + if (!agentId) { + return renderPlaceholder('Select an agent to see its previous sessions here.', false); + } + + if (isLoading) { + return renderPlaceholder('Loading sessions...', false); + } + + if (sessions.length === 0) { + return renderPlaceholder( + 'History lists your prior conversations with this agent so you can pick one back up where you left off without losing context.', + true + ); + } + + return ( +
    + {sessions.map(session => { + const label = session.firstUserMessage?.trim() || '(No messages sent)'; + return ( +
  • + +
  • + ); + })} +
+ ); +}; + +export default SessionHistory; diff --git a/webview/src/components/shared/TabNavigation.css b/webview/src/components/shared/TabNavigation.css index ba0c8810..09a91a6f 100644 --- a/webview/src/components/shared/TabNavigation.css +++ b/webview/src/components/shared/TabNavigation.css @@ -67,6 +67,10 @@ background-image: url('../../assets/tree-light.svg'); } +.tab-icon-clock { + background-image: url('../../assets/clock-light.svg'); +} + body.vscode-dark .tab-icon-comment, body.vscode-high-contrast:not(.vscode-high-contrast-light) .tab-icon-comment { background-image: url('../../assets/comment-dark.svg'); @@ -77,6 +81,11 @@ body.vscode-high-contrast:not(.vscode-high-contrast-light) .tab-icon-tree { background-image: url('../../assets/tree-dark.svg'); } +body.vscode-dark .tab-icon-clock, +body.vscode-high-contrast:not(.vscode-high-contrast-light) .tab-icon-clock { + background-image: url('../../assets/clock-dark.svg'); +} + .tab-navigation-close { display: flex; align-items: center; diff --git a/webview/src/components/shared/TabNavigation.tsx b/webview/src/components/shared/TabNavigation.tsx index d2ef8e53..af82d5e4 100644 --- a/webview/src/components/shared/TabNavigation.tsx +++ b/webview/src/components/shared/TabNavigation.tsx @@ -8,9 +8,10 @@ export interface Tab { } interface TabNavigationProps { - activeTab: number | 'preview' | 'tracer'; + activeTab: number | 'preview' | 'tracer' | 'history'; onTabChange: (tab: any) => void; showTracerTab?: boolean; + showHistoryTab?: boolean; tabs?: Tab[]; onClose?: () => void; } @@ -19,6 +20,7 @@ const TabNavigation: React.FC = ({ activeTab, onTabChange, showTracerTab = false, + showHistoryTab = false, tabs, onClose }) => { @@ -50,7 +52,7 @@ const TabNavigation: React.FC = ({ React.useEffect(() => { updateIndicator(); - }, [updateIndicator, showTracerTab]); + }, [updateIndicator, showTracerTab, showHistoryTab]); // Update indicator on window resize (for responsive behavior) React.useEffect(() => { @@ -114,6 +116,18 @@ const TabNavigation: React.FC = ({ )} + {showHistoryTab && ( + + )} )} {indicatorStyle.width > 0 && ( diff --git a/webview/src/services/vscodeApi.ts b/webview/src/services/vscodeApi.ts index e7a375cb..5f754633 100644 --- a/webview/src/services/vscodeApi.ts +++ b/webview/src/services/vscodeApi.ts @@ -40,6 +40,13 @@ export interface AgentInfo { activeVersion?: number; } +export interface SessionListEntry { + sessionId: string; + timestamp?: string; + sessionType?: 'simulated' | 'live' | 'published'; + firstUserMessage?: string; +} + export interface TraceHistoryMessageEntry { storageKey: string; agentId: string; @@ -140,6 +147,15 @@ class VSCodeApiService { this.vscode?.postMessage({ command, data }); } + // Dispatch a synthetic message to local listeners only (not sent to the extension). + // Useful for optimistic UI updates that should mirror an extension-driven event. + emitLocal(command: string, data?: any) { + const handlers = this.messageHandlers.get(command); + if (handlers) { + handlers.forEach(handler => handler(data)); + } + } + // Agent session management startSession(agentId: string, options?: { isLiveMode?: boolean; agentSource?: string }) { this.postMessage('startSession', { agentId, ...options }); @@ -213,6 +229,15 @@ class VSCodeApiService { this.postMessage('openTraceJson', { entry }); } + // Session history + listSessions(agentId: string, agentSource?: AgentSource) { + this.postMessage('listSessions', { agentId, agentSource }); + } + + resumeSession(agentId: string, sessionId: string, options?: { isLiveMode?: boolean; agentSource?: AgentSource }) { + this.postMessage('resumeSession', { agentId, sessionId, ...options }); + } + // Test support - send test response messages postTestMessage(command: string, data?: any) { this.postMessage(command, data); From 6a63fd07c82238469bc527316c9e18ec553fd69c Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Wed, 13 May 2026 00:41:14 +0100 Subject: [PATCH 02/17] chore: regenerate package-lock files Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 26 ++++++++++---------------- webview/package-lock.json | 28 +++++++++------------------- 2 files changed, 19 insertions(+), 35 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0baea115..25c45088 100644 --- a/package-lock.json +++ b/package-lock.json @@ -297,7 +297,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -898,7 +897,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -922,7 +920,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2778,7 +2775,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3001,7 +2999,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3013,7 +3010,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4186,7 +4182,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5108,7 +5103,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-serializer": { "version": "2.0.0", @@ -6836,7 +6832,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -7929,7 +7924,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -8375,6 +8369,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -9838,6 +9833,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -9853,6 +9849,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -10106,7 +10103,6 @@ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -10120,7 +10116,6 @@ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -10134,7 +10129,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/read": { "version": "1.0.7", @@ -11583,8 +11579,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tunnel": { "version": "0.0.6", @@ -11649,7 +11644,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/webview/package-lock.json b/webview/package-lock.json index f1324810..e90ba002 100644 --- a/webview/package-lock.json +++ b/webview/package-lock.json @@ -104,7 +104,6 @@ "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -720,7 +719,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -744,7 +742,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2576,7 +2573,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2745,7 +2743,6 @@ "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2757,7 +2754,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2842,7 +2838,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3302,7 +3297,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3592,7 +3586,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -3952,7 +3945,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/eastasianwidth": { "version": "0.2.0", @@ -4083,7 +4077,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5033,7 +5026,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -6029,7 +6021,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -6218,6 +6209,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6788,6 +6780,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6803,6 +6796,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -6864,7 +6858,6 @@ "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -6878,7 +6871,6 @@ "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -6892,7 +6884,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.17.0", @@ -6995,7 +6988,6 @@ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -7581,7 +7573,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7708,7 +7699,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", From 7414fa18397d127e7318fc86cec4b06a92ed2522 Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Wed, 13 May 2026 00:59:59 +0100 Subject: [PATCH 03/17] test: cover History tab session resume flow Co-Authored-By: Claude Opus 4.7 (1M context) --- .../handlers/webviewMessageHandlers.test.ts | 92 ++++++++++- .../session/sessionHistoryService.test.ts | 124 ++++++++++++++ test/views/sessionManager.test.ts | 108 +++++++++++-- test/webview/SessionHistory.test.tsx | 151 ++++++++++++++++++ 4 files changed, 464 insertions(+), 11 deletions(-) create mode 100644 test/views/agentCombined/session/sessionHistoryService.test.ts create mode 100644 test/webview/SessionHistory.test.tsx diff --git a/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts b/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts index f0662503..53a11971 100644 --- a/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts +++ b/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts @@ -71,9 +71,15 @@ jest.mock('../../../../src/views/agentCombined/agent/agentUtils', () => ({ getAgentSource: jest.fn() })); +// Mock sessionHistoryService (the session/index reexports it) +jest.mock('../../../../src/views/agentCombined/session', () => ({ + listSessionsForAgent: jest.fn() +})); + // Import after mocks import { WebviewMessageHandlers } from '../../../../src/views/agentCombined/handlers/webviewMessageHandlers'; import { CoreExtensionService } from '../../../../src/services/coreExtensionService'; +import { listSessionsForAgent } from '../../../../src/views/agentCombined/session'; describe('WebviewMessageHandlers', () => { let handlers: WebviewMessageHandlers; @@ -107,12 +113,14 @@ describe('WebviewMessageHandlers', () => { mockMessageSender = { sendError: jest.fn().mockResolvedValue(undefined), - sendClearMessages: jest.fn() + sendClearMessages: jest.fn(), + sendSessionList: jest.fn() }; mockSessionManager = { startSession: jest.fn(), - endSession: jest.fn() + endSession: jest.fn(), + resumeSession: jest.fn().mockResolvedValue(undefined) }; mockHistoryManager = { @@ -244,4 +252,84 @@ describe('WebviewMessageHandlers', () => { ); }); }); + + describe('listSessions', () => { + it('posts the session list back to the webview', async () => { + const sessions = [ + { sessionId: 'a', timestamp: '2026-05-10T00:00:00Z', sessionType: 'live', firstUserMessage: 'hi' } + ]; + (listSessionsForAgent as jest.Mock).mockResolvedValue(sessions); + + await handlers.handleMessage({ + command: 'listSessions', + data: { agentId: 'agent-1', agentSource: 'script' } + } as any); + + expect(listSessionsForAgent).toHaveBeenCalledWith('agent-1', 'script'); + expect(mockMessageSender.sendSessionList).toHaveBeenCalledWith('agent-1', sessions); + }); + + it('returns an empty list when no agentId is supplied or known', async () => { + await handlers.handleMessage({ command: 'listSessions', data: {} } as any); + + expect(mockMessageSender.sendSessionList).toHaveBeenCalledWith('', []); + expect(listSessionsForAgent).not.toHaveBeenCalled(); + }); + + it('falls back to an empty list when listing throws', async () => { + (listSessionsForAgent as jest.Mock).mockRejectedValue(new Error('boom')); + const originalError = console.error; + console.error = jest.fn(); + + await handlers.handleMessage({ + command: 'listSessions', + data: { agentId: 'agent-1', agentSource: 'script' } + } as any); + + expect(mockMessageSender.sendSessionList).toHaveBeenCalledWith('agent-1', []); + console.error = originalError; + }); + }); + + describe('resumeSession', () => { + it('forwards to sessionManager.resumeSession with resolved agentSource', async () => { + mockState.currentAgentSource = 'script'; + + await handlers.handleMessage({ + command: 'resumeSession', + data: { agentId: 'agent-1', sessionId: 'sess-1', isLiveMode: false } + } as any); + + expect(mockSessionManager.resumeSession).toHaveBeenCalledWith( + 'agent-1', + 'script', + 'sess-1', + false, + mockWebviewView + ); + }); + + it('short-circuits when the requested session is already active for the same agent', async () => { + mockState.isSessionActive = true; + mockState.sessionId = 'sess-1'; + mockState.sessionAgentId = 'agent-1'; + mockState.currentAgentSource = 'script'; + + await handlers.handleMessage({ + command: 'resumeSession', + data: { agentId: 'agent-1', sessionId: 'sess-1' } + } as any); + + expect(mockSessionManager.resumeSession).not.toHaveBeenCalled(); + }); + + it('throws when sessionId is missing', async () => { + await expect( + handlers.handleMessage({ + command: 'resumeSession', + data: { agentId: 'agent-1' } + } as any) + ).rejects.toThrow(/Invalid session ID/); + }); + }); }); diff --git a/test/views/agentCombined/session/sessionHistoryService.test.ts b/test/views/agentCombined/session/sessionHistoryService.test.ts new file mode 100644 index 00000000..c1eda398 --- /dev/null +++ b/test/views/agentCombined/session/sessionHistoryService.test.ts @@ -0,0 +1,124 @@ +/* + * Copyright 2025, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ + +jest.mock('@salesforce/agents', () => ({ + AgentSource: { SCRIPT: 'script', PUBLISHED: 'published' } +})); + +const mockGetAllHistory = jest.fn(); +jest.mock('@salesforce/agents/lib/utils', () => ({ + getAllHistory: (...args: unknown[]) => mockGetAllHistory(...args) +})); + +jest.mock('@salesforce/core', () => ({ + SfProject: { + getInstance: () => ({ getPath: () => '/mock/project' }), + resolve: jest.fn().mockResolvedValue({ getPath: () => '/mock/project' }) + } +})); + +const mockReaddir = jest.fn(); +const mockReadFile = jest.fn(); +const mockStat = jest.fn(); +jest.mock('fs', () => ({ + promises: { + readdir: (...args: unknown[]) => mockReaddir(...args), + readFile: (...args: unknown[]) => mockReadFile(...args), + stat: (...args: unknown[]) => mockStat(...args) + } +})); + +import { AgentSource } from '@salesforce/agents'; +import { listSessionsForAgent } from '../../../../src/views/agentCombined/session/sessionHistoryService'; + +const dirent = (name: string, isDir = true) => ({ name, isDirectory: () => isDir }); + +describe('sessionHistoryService.listSessionsForAgent', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockReadFile.mockReset(); + mockReaddir.mockReset(); + mockStat.mockReset(); + mockGetAllHistory.mockResolvedValue({ transcript: [] }); + }); + + it('returns [] when sessions directory is missing', async () => { + mockReaddir.mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + const result = await listSessionsForAgent('MyAgent', AgentSource.SCRIPT); + + expect(result).toEqual([]); + }); + + it('uses session-meta.json when present', async () => { + mockReaddir.mockResolvedValueOnce([dirent('sess-1')]); + mockReadFile.mockImplementation((p: string) => { + if (p.endsWith('session-meta.json')) { + return Promise.resolve(JSON.stringify({ timestamp: '2026-05-10T10:00:00Z', sessionType: 'live' })); + } + return Promise.reject(new Error('not found')); + }); + mockGetAllHistory.mockResolvedValueOnce({ + transcript: [{ role: 'user', text: 'Hello there' }] + }); + + const [entry] = await listSessionsForAgent('MyAgent', AgentSource.SCRIPT); + + expect(entry).toEqual({ + sessionId: 'sess-1', + timestamp: '2026-05-10T10:00:00Z', + sessionType: 'live', + firstUserMessage: 'Hello there' + }); + }); + + it('falls back to metadata.json mockMode when session-meta is missing', async () => { + mockReaddir.mockResolvedValueOnce([dirent('sess-2')]); + mockReadFile.mockImplementation((p: string) => { + if (p.endsWith('metadata.json')) { + return Promise.resolve(JSON.stringify({ startTime: '2026-05-09T08:00:00Z', mockMode: 'Mock' })); + } + return Promise.reject(new Error('not found')); + }); + + const [entry] = await listSessionsForAgent('MyAgent', AgentSource.SCRIPT); + + expect(entry.timestamp).toBe('2026-05-09T08:00:00Z'); + expect(entry.sessionType).toBe('simulated'); + }); + + it('infers published sessionType from a Bot ID storage key', async () => { + mockReaddir.mockResolvedValueOnce([dirent('sess-3')]); + mockReadFile.mockRejectedValue(new Error('no metadata files')); + mockStat.mockResolvedValueOnce({ mtime: new Date('2026-05-08T08:00:00Z') }); + + const [entry] = await listSessionsForAgent('0XxFakeBot12345', AgentSource.PUBLISHED); + + expect(entry.sessionType).toBe('published'); + expect(entry.timestamp).toBe('2026-05-08T08:00:00.000Z'); + }); + + it('sorts results newest first', async () => { + mockReaddir.mockResolvedValueOnce([dirent('older'), dirent('newer')]); + mockReadFile.mockImplementation((p: string) => { + if (p.includes('older') && p.endsWith('session-meta.json')) { + return Promise.resolve(JSON.stringify({ timestamp: '2026-01-01T00:00:00Z' })); + } + if (p.includes('newer') && p.endsWith('session-meta.json')) { + return Promise.resolve(JSON.stringify({ timestamp: '2026-05-01T00:00:00Z' })); + } + return Promise.reject(new Error('not found')); + }); + + const result = await listSessionsForAgent('MyAgent', AgentSource.SCRIPT); + + expect(result.map(r => r.sessionId)).toEqual(['newer', 'older']); + }); +}); diff --git a/test/views/sessionManager.test.ts b/test/views/sessionManager.test.ts index 50fe91fe..2425a6c5 100644 --- a/test/views/sessionManager.test.ts +++ b/test/views/sessionManager.test.ts @@ -42,15 +42,21 @@ jest.mock('@salesforce/agents', () => ({ createPreviewSessionCache: jest.fn().mockResolvedValue(undefined) })); -// Mock @salesforce/core -jest.mock('@salesforce/core', () => ({ - SfProject: { - getInstance: () => ({ - getPath: () => '/mock/project' - }) - }, - SfError: Error -})); +// Mock @salesforce/core. SfError is aliased to Error so `instanceof SfError` +// passes for plain Errors, matching pre-existing tests, while still exposing +// SfError.wrap which the resumeSession path uses. +jest.mock('@salesforce/core', () => { + const SfErrorMock = Error as any; + SfErrorMock.wrap = (err: unknown) => (err instanceof Error ? err : new Error(String(err))); + return { + SfProject: { + getInstance: () => ({ + getPath: () => '/mock/project' + }) + }, + SfError: SfErrorMock + }; +}); // Mock CoreExtensionService jest.mock('../../src/services/coreExtensionService', () => ({ @@ -406,4 +412,88 @@ describe('SessionManager', () => { }); }); }); + + describe('resumeSession', () => { + const buildMockAgentInstance = (overrides: any = {}) => ({ + name: 'TestAgent', + preview: { + end: jest.fn().mockResolvedValue(undefined) + }, + restoreConnection: jest.fn().mockResolvedValue(undefined), + resumeSession: jest.fn().mockResolvedValue(undefined), + ...overrides + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockState.currentAgentName = 'TestAgent'; + mockState.currentAgentId = 'test-agent-id'; + mockState.currentAgentSource = AgentSource.SCRIPT; + }); + + it('reattaches the agent to the supplied sessionId via SDK resumeSession', async () => { + const instance = buildMockAgentInstance(); + mockAgentInitializer.initializeScriptAgent.mockImplementation(async () => { + mockState.agentInstance = instance; + return instance; + }); + + await sessionManager.resumeSession('test-agent-id', AgentSource.SCRIPT, 'sess-prior', false, {}); + + expect(instance.resumeSession).toHaveBeenCalledWith('sess-prior'); + expect(mockState.sessionId).toBe('sess-prior'); + expect(mockState.sessionAgentId).toBe('test-agent-id'); + }); + + it('sends sessionStarted with skipWelcome=true', async () => { + const instance = buildMockAgentInstance(); + mockAgentInitializer.initializeScriptAgent.mockImplementation(async () => { + mockState.agentInstance = instance; + return instance; + }); + + await sessionManager.resumeSession('test-agent-id', AgentSource.SCRIPT, 'sess-prior', false, {}); + + expect(mockMessageSender.sendSessionStarted).toHaveBeenCalledWith(undefined, 'sess-prior', true); + }); + + it('ends the previous SDK session before reinitializing', async () => { + const previousInstance = buildMockAgentInstance(); + mockState.agentInstance = previousInstance; + mockState.sessionId = 'sess-old'; + const newInstance = buildMockAgentInstance(); + mockAgentInitializer.initializeScriptAgent.mockImplementation(async () => { + mockState.agentInstance = newInstance; + return newInstance; + }); + + await sessionManager.resumeSession('test-agent-id', AgentSource.SCRIPT, 'sess-prior', false, {}); + + expect(previousInstance.preview.end).toHaveBeenCalled(); + expect(mockState.clearSessionState).toHaveBeenCalled(); + expect(newInstance.resumeSession).toHaveBeenCalledWith('sess-prior'); + }); + + it('surfaces resume errors via sendError and sets error state', async () => { + const instance = buildMockAgentInstance({ + resumeSession: jest.fn().mockRejectedValue(new Error('disk read failed')) + }); + mockAgentInitializer.initializeScriptAgent.mockImplementation(async () => { + mockState.agentInstance = instance; + return instance; + }); + + await sessionManager.resumeSession('test-agent-id', AgentSource.SCRIPT, 'sess-prior', false, {}); + + expect(mockMessageSender.sendError).toHaveBeenCalledWith(expect.stringContaining('Failed to resume session')); + expect(mockState.setResetAgentViewAvailable).toHaveBeenCalledWith(true); + expect(mockState.setSessionErrorState).toHaveBeenCalledWith(true); + }); + + it('throws when no webview is provided', async () => { + await expect( + sessionManager.resumeSession('test-agent-id', AgentSource.SCRIPT, 'sess-prior', false, undefined as any) + ).rejects.toThrow(/Webview is not ready/); + }); + }); }); diff --git a/test/webview/SessionHistory.test.tsx b/test/webview/SessionHistory.test.tsx new file mode 100644 index 00000000..3684a62f --- /dev/null +++ b/test/webview/SessionHistory.test.tsx @@ -0,0 +1,151 @@ +/* + * Copyright 2025, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ + +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +(window as any).AgentSource = { + SCRIPT: 'script', + PUBLISHED: 'published' +}; + +jest.mock('../../webview/src/services/vscodeApi', () => ({ + vscodeApi: { + onMessage: jest.fn(), + listSessions: jest.fn(), + resumeSession: jest.fn() + }, + AgentSource: { + SCRIPT: 'script', + PUBLISHED: 'published' + } +})); + +import SessionHistory from '../../webview/src/components/SessionHistory/SessionHistory'; +import { vscodeApi } from '../../webview/src/services/vscodeApi'; + +describe('SessionHistory', () => { + let messageHandlers: Map void>; + + beforeEach(() => { + jest.clearAllMocks(); + messageHandlers = new Map(); + (vscodeApi.onMessage as jest.Mock).mockImplementation((command: string, handler: any) => { + messageHandlers.set(command, handler); + return () => messageHandlers.delete(command); + }); + }); + + const emitSessionList = (agentId: string, sessions: any[]) => { + act(() => { + messageHandlers.get('sessionList')?.({ agentId, sessions }); + }); + }; + + it('renders the placeholder when no agent is selected', () => { + render( + + ); + + expect(screen.getByText(/Select an agent/i)).toBeInTheDocument(); + expect(vscodeApi.listSessions).not.toHaveBeenCalled(); + }); + + it('requests the session list when activated for an agent', () => { + render( + + ); + + expect(vscodeApi.listSessions).toHaveBeenCalledWith('agent-1', 'script'); + }); + + it('renders the empty state with a start button when there are no sessions', () => { + const onGoToPreview = jest.fn(); + render( + + ); + + emitSessionList('agent-1', []); + + expect(screen.getByText(/History lists your prior conversations/i)).toBeInTheDocument(); + expect(screen.getByText(/Start Simulation/i)).toBeInTheDocument(); + }); + + it('renders rows with first message, formatted timestamp and badge', () => { + render( + + ); + + emitSessionList('agent-1', [ + { + sessionId: 'sess-1', + timestamp: '2026-05-10T15:30:00Z', + sessionType: 'simulated', + firstUserMessage: 'Hello agent' + } + ]); + + expect(screen.getByText('Hello agent')).toBeInTheDocument(); + expect(screen.getByText('Simulation')).toBeInTheDocument(); + }); + + it('clicking a row resumes the session and notifies the parent', async () => { + const onResume = jest.fn(); + render( + + ); + + emitSessionList('agent-1', [ + { + sessionId: 'sess-1', + timestamp: '2026-05-10T15:30:00Z', + sessionType: 'simulated', + firstUserMessage: 'Hello agent' + } + ]); + + const user = userEvent.setup(); + await user.click(screen.getByText('Hello agent').closest('button')!); + + expect(vscodeApi.resumeSession).toHaveBeenCalledWith( + 'agent-1', + 'sess-1', + expect.objectContaining({ isLiveMode: false, agentSource: 'script' }) + ); + expect(onResume).toHaveBeenCalledWith('sess-1'); + }); +}); From 57afca6dd3ff79609eec652fa9c5edbdcc63d1c5 Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Fri, 15 May 2026 12:51:29 +0100 Subject: [PATCH 04/17] style: align session history message column with fixed-width badges --- webview/src/components/SessionHistory/SessionHistory.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/webview/src/components/SessionHistory/SessionHistory.css b/webview/src/components/SessionHistory/SessionHistory.css index d6fb9953..53ad847c 100644 --- a/webview/src/components/SessionHistory/SessionHistory.css +++ b/webview/src/components/SessionHistory/SessionHistory.css @@ -54,6 +54,8 @@ font-size: 11px; line-height: 1.2; flex-shrink: 0; + min-width: 70px; + text-align: center; background-color: var(--vscode-badge-background); color: var(--vscode-badge-foreground); } From dd6922b29cd3cc95e5b67d088ff39cee9ee098e9 Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Fri, 15 May 2026 22:26:48 +0100 Subject: [PATCH 05/17] feat: load history sessions without starting; add Resume and Clear Clicking a History row now loads that session's transcript and traces into the chat without starting a session. The Start button flips to Resume, the live-mode toggle syncs to the previewed session's type, and a new Clear Loaded Session toolbar action drops the loaded conversation. If a session is already active when a row is clicked, the SDK session is ended first with a Stopping... spinner, then the previewed conversation is rendered. Also moves the toolbar order so Refresh sits leftmost, followed by Activate, Save Chat History, and Clear, and removes the Create Agent button from the empty agent toolbar. --- package.json | 23 ++-- src/extension.ts | 6 + .../handlers/webviewMessageHandlers.ts | 127 ++++++++++++++++-- .../handlers/webviewMessageSender.ts | 5 +- .../agentCombined/history/historyManager.ts | 120 ++++++++++++++++- .../agentCombined/state/agentViewState.ts | 13 ++ src/views/agentCombinedViewProvider.ts | 15 +++ webview/src/App.tsx | 45 +++++-- .../components/AgentPreview/AgentPreview.tsx | 11 +- .../components/AgentPreview/AgentSelector.css | 8 +- .../components/AgentPreview/AgentSelector.tsx | 105 ++++++++------- .../SessionHistory/SessionHistory.tsx | 16 ++- webview/src/services/vscodeApi.ts | 12 +- 13 files changed, 411 insertions(+), 95 deletions(-) diff --git a/package.json b/package.json index 12db262c..5a45d66d 100644 --- a/package.json +++ b/package.json @@ -181,27 +181,27 @@ { "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" + "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" + "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" + "group": "navigation@2" + }, + { + "command": "sf.agent.combined.view.clearLoadedSession", + "when": "view == sf.agent.combined.view && agentforceDX:hasLoadedSession && !agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:canResetAgentView", + "group": "navigation@4" }, { "command": "sf.agent.combined.view.refreshAgents", "when": "view == sf.agent.combined.view && !agentforceDX:sessionActive && !agentforceDX:sessionStarting && !agentforceDX:canResetAgentView", - "group": "navigation@1" + "group": "navigation@0" }, { "command": "sf.agent.combined.view.resetAgentView", @@ -465,6 +465,11 @@ "title": "Refresh Agent List", "icon": "$(refresh)" }, + { + "command": "sf.agent.combined.view.clearLoadedSession", + "title": "Clear Loaded Session", + "icon": "$(clear-all)" + }, { "command": "sf.agent.combined.view.resetAgentView", "title": "Reset", diff --git a/src/extension.ts b/src/extension.ts index 1ae581d1..a8010215 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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(); diff --git a/src/views/agentCombined/handlers/webviewMessageHandlers.ts b/src/views/agentCombined/handlers/webviewMessageHandlers.ts index bd8bc1f5..c2e2250f 100644 --- a/src/views/agentCombined/handlers/webviewMessageHandlers.ts +++ b/src/views/agentCombined/handlers/webviewMessageHandlers.ts @@ -56,7 +56,8 @@ export class WebviewMessageHandlers { setLiveMode: async msg => await this.handleSetLiveMode(msg), getInitialLiveMode: async () => await this.handleGetInitialLiveMode(), listSessions: async msg => await this.handleListSessions(msg), - resumeSession: async msg => await this.handleResumeSession(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 @@ -139,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); } @@ -315,6 +326,17 @@ export class WebviewMessageHandlers { private async handleGetTraceData(): Promise { 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; } @@ -354,6 +376,9 @@ export class WebviewMessageHandlers { private async handleSetSelectedAgentId(message: AgentMessage): Promise { 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 @@ -401,9 +426,14 @@ export class WebviewMessageHandlers { } } - private async handleResumeSession(message: AgentMessage): Promise { + /** + * 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 { const data = message.data as - | { agentId?: string; agentSource?: AgentSource; sessionId?: string; isLiveMode?: boolean } + | { agentId?: string; agentSource?: AgentSource; sessionId?: string; sessionType?: 'simulated' | 'live' | 'published' } | undefined; const agentId = data?.agentId ?? this.state.currentAgentId; const sessionId = data?.sessionId; @@ -415,24 +445,97 @@ export class WebviewMessageHandlers { 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); } 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). + const previousAgent = this.state.agentInstance; + const previousSessionId = this.state.sessionId; + const previousSource = this.state.currentAgentSource; + const hadActiveSession = !!(previousAgent && previousSessionId); + if (hadActiveSession) { + this.state.cancelPendingSessionStart(); + await this.state.setSessionStarting(true); + // Clear chat + tracer immediately so the previous conversation isn't + // visible while we wait on the SDK round-trip. App.tsx ignores empty + // setConversation messages for the purpose of isPreviewingSession, so + // the optimistic Resume label set on click is preserved. + this.messageSender.sendSetConversation([], true, null); + this.messageSender.sendTraceHistory(agentId, []); + this.messageSender.sendSessionStarting('Stopping current session...'); + 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(); + await this.state.setSessionActive(false); + // We deliberately keep isSessionStarting=true through the disk read below + // so the input stays disabled. Cleared after the preview is loaded. - const isLiveMode = data?.isLiveMode ?? this.state.isLiveMode ?? false; + // 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(); + } - // If the requested session is already the active one, no need to restart - if ( - this.state.isSessionActive && - this.state.sessionId === sessionId && - this.state.sessionAgentId === agentId - ) { - return; + 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); + this.messageSender.sendSessionEnded(); } + } - await this.sessionManager.resumeSession(agentId, agentSource, sessionId, isLiveMode, this.webviewView); + /** + * 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 { + 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 { diff --git a/src/views/agentCombined/handlers/webviewMessageSender.ts b/src/views/agentCombined/handlers/webviewMessageSender.ts index 2bc2f69a..455764fd 100644 --- a/src/views/agentCombined/handlers/webviewMessageSender.ts +++ b/src/views/agentCombined/handlers/webviewMessageSender.ts @@ -86,9 +86,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 { diff --git a/src/views/agentCombined/history/historyManager.ts b/src/views/agentCombined/history/historyManager.ts index f2a34151..fcf6ebd2 100644 --- a/src/views/agentCombined/history/historyManager.ts +++ b/src/views/agentCombined/history/historyManager.ts @@ -6,6 +6,7 @@ import * as vscode from 'vscode'; import type { AgentViewState } from '../state/agentViewState'; import type { WebviewMessageSender } from '../handlers/webviewMessageSender'; import { getAgentStorageKey } from '../agent/agentUtils'; +import { listSessionsForAgent, type SessionListEntry } from '../session/sessionHistoryService'; /** * Manages conversation and trace history @@ -153,10 +154,13 @@ export class HistoryManager { */ async showHistoryOrPlaceholder(agentId: string, agentSource: AgentSource): Promise { try { - // Load both histories in parallel - const [traceEntries, transcriptEntries] = await Promise.all([ + // Load histories + the cached-session list in parallel. + // The session list is used to identify the most-recent sessionId/type so + // the webview can display Resume vs Start on the start button. + const [traceEntries, transcriptEntries, sessions] = await Promise.all([ this.loadTraceHistoryData(agentId, agentSource), - this.loadConversationHistoryData(agentId, agentSource) + this.loadConversationHistoryData(agentId, agentSource), + listSessionsForAgent(agentId, agentSource).catch(() => [] as SessionListEntry[]) ]); // Send trace history @@ -166,7 +170,13 @@ export class HistoryManager { const historyMessages = this.convertTranscriptToMessages(transcriptEntries); const hasHistory = historyMessages.length > 0; await this.state.setConversationDataAvailable(hasHistory); - this.messageSender.sendSetConversation(historyMessages, !hasHistory); + + // If a conversation is loaded, mark it as resumable. Newest session is first. + const previewSessionInfo = hasHistory && sessions.length > 0 + ? { sessionId: sessions[0].sessionId, sessionType: sessions[0].sessionType } + : null; + this.state.previewedSessionId = previewSessionInfo?.sessionId; + this.messageSender.sendSetConversation(historyMessages, !hasHistory, previewSessionInfo); // Send current or latest trace data if (traceEntries.length > 0) { @@ -179,7 +189,107 @@ export class HistoryManager { } catch (err) { console.error('Error loading history:', err); await this.state.setConversationDataAvailable(false); - this.messageSender.sendSetConversation([], true); + this.messageSender.sendSetConversation([], true, null); + } + } + + /** + * Loads only the trace history for a specific sessionId (from disk) and pushes + * it to the webview. Used when the tracer tab is opened while previewing a + * session — we don't want to re-render the chat, only ensure the tracer has + * the right traces. + */ + async loadAndSendTracesForSession( + agentId: string, + agentSource: AgentSource, + sessionId: string + ): Promise { + const agentStorageKey = getAgentStorageKey(agentId, agentSource); + let traces: any[] = []; + try { + const history = await getAllHistory(agentStorageKey, sessionId); + traces = history.traces || []; + } catch (err) { + console.error('Could not load traces for session:', err); + } + + const sortedTraces = [...traces].sort((a: any, b: any) => { + const timeA = this.getTraceStartTime(a) ?? Infinity; + const timeB = this.getTraceStartTime(b) ?? Infinity; + return timeA - timeB; + }); + const traceEntries: TraceHistoryEntry[] = sortedTraces.map((trace: any, index) => { + const planId = trace.planId || `plan-${index}`; + const startTime = this.getTraceStartTime(trace); + const timestamp = startTime ? new Date(startTime).toISOString() : new Date().toISOString(); + return { + storageKey: agentStorageKey, + agentId, + sessionId: trace.sessionId || sessionId, + planId, + userMessage: this.extractUserMessageFromTrace(trace), + timestamp, + trace + }; + }); + + this.messageSender.sendTraceHistory(agentId, traceEntries); + if (traceEntries.length > 0) { + this.messageSender.sendTraceData(traceEntries[traceEntries.length - 1].trace); + } else { + this.messageSender.sendTraceData({ plan: [], planId: '', sessionId: '' }); + } + } + + /** + * Loads transcript and traces for a specific sessionId (from disk) and pushes + * them to the webview, without starting a session. Used by the History tab + * to preview a prior session before the user clicks Start. + */ + async loadAndSendSessionPreview( + agentId: string, + agentSource: AgentSource, + sessionId: string, + sessionType?: 'simulated' | 'live' | 'published' + ): Promise { + const agentStorageKey = getAgentStorageKey(agentId, agentSource); + let transcript: any[] = []; + let traces: any[] = []; + try { + const history = await getAllHistory(agentStorageKey, sessionId); + transcript = history.transcript || []; + traces = history.traces || []; + } catch (err) { + console.error('Could not load session preview:', err); + } + + const sortedTraces = [...traces].sort((a: any, b: any) => { + const timeA = this.getTraceStartTime(a) ?? Infinity; + const timeB = this.getTraceStartTime(b) ?? Infinity; + return timeA - timeB; + }); + const traceEntries: TraceHistoryEntry[] = sortedTraces.map((trace: any, index) => { + const planId = trace.planId || `plan-${index}`; + const startTime = this.getTraceStartTime(trace); + const timestamp = startTime ? new Date(startTime).toISOString() : new Date().toISOString(); + return { + storageKey: agentStorageKey, + agentId, + sessionId: trace.sessionId || sessionId, + planId, + userMessage: this.extractUserMessageFromTrace(trace), + timestamp, + trace + }; + }); + + const messages = this.convertTranscriptToMessages(transcript); + const hasMessages = messages.length > 0; + await this.state.setConversationDataAvailable(hasMessages); + this.messageSender.sendSetConversation(messages, !hasMessages, { sessionId, sessionType }); + this.messageSender.sendTraceHistory(agentId, traceEntries); + if (traceEntries.length > 0) { + this.messageSender.sendTraceData(traceEntries[traceEntries.length - 1].trace); } } diff --git a/src/views/agentCombined/state/agentViewState.ts b/src/views/agentCombined/state/agentViewState.ts index 2b479f22..2d56fd3b 100644 --- a/src/views/agentCombined/state/agentViewState.ts +++ b/src/views/agentCombined/state/agentViewState.ts @@ -22,6 +22,9 @@ export class AgentViewState { private _pendingStartAgentId?: string; private _pendingStartAgentSource?: AgentSource; private _pendingSelectAgentId?: string; + // The sessionId currently shown via History tab preview (read-only, not yet started). + // When the user clicks Start, the start path resumes this session instead of creating a new one. + private _previewedSessionId?: string; // Version state private _currentAgentActiveVersion?: number; @@ -173,6 +176,15 @@ export class AgentViewState { this._pendingSelectAgentId = value; } + get previewedSessionId(): string | undefined { + return this._previewedSessionId; + } + + set previewedSessionId(value: string | undefined) { + this._previewedSessionId = value; + void vscode.commands.executeCommand('setContext', 'agentforceDX:hasLoadedSession', !!value); + } + // State update methods async setSessionActive(active: boolean): Promise { this._isSessionActive = active; @@ -259,5 +271,6 @@ export class AgentViewState { this._pendingStartAgentId = undefined; this._pendingStartAgentSource = undefined; this._pendingSelectAgentId = undefined; + this._previewedSessionId = undefined; } } diff --git a/src/views/agentCombinedViewProvider.ts b/src/views/agentCombinedViewProvider.ts index 772d7a78..fb3155dc 100644 --- a/src/views/agentCombinedViewProvider.ts +++ b/src/views/agentCombinedViewProvider.ts @@ -295,6 +295,21 @@ export class AgentCombinedViewProvider implements vscode.WebviewViewProvider { /** * Resets the current agent view */ + /** + * Drops the currently displayed (resumable) conversation/traces so the user can + * start a fresh session from an empty chat. Does not touch on-disk session data. + */ + public clearLoadedSession(): void { + this.state.previewedSessionId = undefined; + this.state.currentPlanId = undefined; + void 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: '' }); + } + public async resetCurrentAgentView(): Promise { if (!this.webviewView) { throw new Error('Agent view is not ready to reset.'); diff --git a/webview/src/App.tsx b/webview/src/App.tsx index 99f25266..bc83117e 100644 --- a/webview/src/App.tsx +++ b/webview/src/App.tsx @@ -33,6 +33,7 @@ const App: React.FC = () => { const [isSessionActive, setIsSessionActive] = useState(false); const [isSessionStarting, setIsSessionStarting] = useState(false); const [hasSessionError, setHasSessionError] = useState(false); + const [isPreviewingSession, setIsPreviewingSession] = useState(false); const activeSessionIdRef = useRef(undefined); const [isLiveMode, setIsLiveMode] = useState(false); const [selectedAgentInfo, setSelectedAgentInfo] = useState(null); @@ -169,6 +170,33 @@ const App: React.FC = () => { vscodeApi.setLiveMode(isLive); }, []); + // Track when the backend pushes a "loaded conversation" so the start button + // can switch between Resume and Start. The setConversation message includes + // previewSessionInfo when a prior session's transcript is being shown. + useEffect(() => { + return vscodeApi.onMessage('setConversation', (data: any) => { + const info = data?.previewSessionInfo; + const messages = Array.isArray(data?.messages) ? data.messages : []; + if (info && typeof info.sessionId === 'string') { + setIsPreviewingSession(true); + if (info.sessionType === 'simulated') { + setIsLiveMode(false); + } else if (info.sessionType === 'live' || info.sessionType === 'published') { + setIsLiveMode(true); + } + } else if (messages.length > 0) { + // A conversation with messages but no preview info means a non-resumable + // load (e.g. legacy auto-load). Drop the preview flag. + setIsPreviewingSession(false); + } + // An empty setConversation with no preview info is a transitional clear + // (e.g. while stopping a session before showing a preview). Leave the + // preview flag alone — the next setConversation with previewSessionInfo + // will set it to true. + }); + }, []); + + // Switch to preview tab when a published agent is selected (tracer not supported) // or when no agent is selected useEffect(() => { @@ -202,6 +230,7 @@ const App: React.FC = () => { const handleAgentChange = useCallback((agentId: string, agentSource?: AgentSource) => { setDesiredAgentId(agentId); + setIsPreviewingSession(false); // Notify the extension about the selected agent // Pass agentSource to avoid expensive re-fetch on the backend // History is now loaded atomically by setSelectedAgentId handler @@ -225,6 +254,7 @@ const App: React.FC = () => { sessionActiveRef.current = true; setIsSessionActive(true); setIsSessionStarting(false); + setIsPreviewingSession(false); if (data && typeof data === 'object' && typeof data.sessionId === 'string') { activeSessionIdRef.current = data.sessionId; } @@ -238,6 +268,9 @@ const App: React.FC = () => { sessionActiveRef.current = false; setIsSessionActive(false); setIsSessionStarting(false); + // Note: don't clear isPreviewingSession here — the setConversation + // listener owns that flag. When sessionEnded fires after a history-row + // click, a preview is still loaded and Resume should remain available. activeSessionIdRef.current = undefined; const resolver = sessionEndResolversRef.current.shift(); if (resolver) { @@ -407,6 +440,7 @@ const App: React.FC = () => { onSelectedAgentInfoChange={setSelectedAgentInfo} onStopSession={handleStopSession} onAgentsAvailabilityChange={handleAgentsAvailabilityChange} + isPreviewingSession={isPreviewingSession} />
{previewAgentId !== '' && !isSessionStarting && ( @@ -436,6 +470,7 @@ const App: React.FC = () => {
{ isSessionActive={isSessionActive} isLiveMode={isLiveMode} selectedAgentInfo={selectedAgentInfo} - onResume={(sessionId: string) => { - const isAlreadyActive = sessionActiveRef.current && activeSessionIdRef.current === sessionId; - if (!isAlreadyActive) { - sessionActiveRef.current = false; - setIsSessionActive(false); - setIsSessionStarting(true); - vscodeApi.emitLocal('sessionStarting', { message: 'Resuming session...' }); - } + onResume={() => { + setIsPreviewingSession(true); setActiveTab('preview'); }} onGoToPreview={handleGoToPreview} diff --git a/webview/src/components/AgentPreview/AgentPreview.tsx b/webview/src/components/AgentPreview/AgentPreview.tsx index 741783e9..53e8abd6 100644 --- a/webview/src/components/AgentPreview/AgentPreview.tsx +++ b/webview/src/components/AgentPreview/AgentPreview.tsx @@ -231,9 +231,10 @@ const AgentPreview = forwardRef( }); disposers.push(disposeSessionStarted); - const disposeSessionStarting = vscodeApi.onMessage('sessionStarting', () => { + const disposeSessionStarting = vscodeApi.onMessage('sessionStarting', (data: any) => { const currentSelectedAgentId = selectedAgentIdRef.current; const currentPendingAgentId = pendingAgentIdRef.current; + const explicitMessage = typeof data?.message === 'string' ? data.message : undefined; setSessionActive(false); setAgentConnected(false); @@ -248,11 +249,13 @@ const AgentPreview = forwardRef( if (currentPendingAgentId && currentPendingAgentId === currentSelectedAgentId) { setIsLoading(true); - setLoadingMessage('Connecting to agent...'); + setLoadingMessage(explicitMessage ?? 'Connecting to agent...'); } else if (!currentPendingAgentId) { setIsLoading(true); - setLoadingMessage('Loading agent...'); - setMessages([]); + setLoadingMessage(explicitMessage ?? 'Loading agent...'); + if (!explicitMessage) { + setMessages([]); + } } else { setIsLoading(false); } diff --git a/webview/src/components/AgentPreview/AgentSelector.css b/webview/src/components/AgentPreview/AgentSelector.css index dd41be0a..3898a71c 100644 --- a/webview/src/components/AgentPreview/AgentSelector.css +++ b/webview/src/components/AgentPreview/AgentSelector.css @@ -9,10 +9,16 @@ } /* Full width start button */ -.agent-selector__start-button { +.agent-selector__actions { + display: flex; width: 100%; } +.agent-selector__start-button { + flex: 1 1 auto; + min-width: 0; +} + .agent-selector__start-button .vscode-button__start, .agent-selector__start-button .vscode-split-button__start { margin-inline-end: 6px; diff --git a/webview/src/components/AgentPreview/AgentSelector.tsx b/webview/src/components/AgentPreview/AgentSelector.tsx index e6e8ff9d..ff18b2d2 100644 --- a/webview/src/components/AgentPreview/AgentSelector.tsx +++ b/webview/src/components/AgentPreview/AgentSelector.tsx @@ -14,6 +14,7 @@ interface AgentSelectorProps { onSelectedAgentInfoChange?: (agentInfo: AgentInfo | null) => void; onStopSession?: () => void; onAgentsAvailabilityChange?: (hasAgents: boolean, isLoading: boolean) => void; + isPreviewingSession?: boolean; } export interface StartClickParams { @@ -55,7 +56,8 @@ const AgentSelector: React.FC = ({ initialLiveMode = false, onSelectedAgentInfoChange, onStopSession, - onAgentsAvailabilityChange + onAgentsAvailabilityChange, + isPreviewingSession = false }) => { const [agents, setAgents] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -285,53 +287,60 @@ const AgentSelector: React.FC = ({
)} - {selectedAgent && - (selectedAgentInfo?.type === AgentSource.PUBLISHED ? ( - - ) : shouldShowStop ? ( - - ) : ( - - {shouldShowStop - ? isLiveMode - ? 'Stop Live Test' - : 'Stop Simulation' - : isLiveMode - ? 'Start Live Test' - : 'Start Simulation'} - - ))} + {selectedAgent && ( +
+ {selectedAgentInfo?.type === AgentSource.PUBLISHED ? ( + + ) : shouldShowStop ? ( + + ) : ( + + {shouldShowStop + ? isLiveMode + ? 'Stop Live Test' + : 'Stop Simulation' + : isPreviewingSession + ? isLiveMode + ? 'Resume Live Test' + : 'Resume Simulation' + : isLiveMode + ? 'Start Live Test' + : 'Start Simulation'} + + )} +
+ )} ); }; diff --git a/webview/src/components/SessionHistory/SessionHistory.tsx b/webview/src/components/SessionHistory/SessionHistory.tsx index 4c37159d..d142ee51 100644 --- a/webview/src/components/SessionHistory/SessionHistory.tsx +++ b/webview/src/components/SessionHistory/SessionHistory.tsx @@ -98,12 +98,20 @@ const SessionHistory: React.FC = ({ vscodeApi.listSessions(agentId, agentSource); }, [isActive, agentId, agentSource]); - const handleResume = (sessionId: string) => { + const handleResume = (session: SessionListEntry) => { if (!agentId) { return; } - vscodeApi.resumeSession(agentId, sessionId, { isLiveMode, agentSource }); - onResume(sessionId); + // Sync the Start button mode to the previewed session's type so clicking + // Start resumes in the same mode the session ran in originally. + if (onLiveModeChange && session.sessionType) { + const wasLive = session.sessionType === 'live' || session.sessionType === 'published'; + if (wasLive !== isLiveMode) { + onLiveModeChange(wasLive); + } + } + vscodeApi.previewSession(agentId, session.sessionId, { agentSource, sessionType: session.sessionType }); + onResume(session.sessionId); }; const renderPlaceholder = (message: string, showButton: boolean) => { @@ -177,7 +185,7 @@ const SessionHistory: React.FC = ({ + + ); + }; +}); + import App from '../../webview/src/App'; describe('App session lifecycle', () => { @@ -91,6 +113,7 @@ describe('App session lifecycle', () => { beforeEach(() => { selectorPropsRef.current = undefined; previewPropsRef.current = undefined; + sessionHistoryPropsRef.current = undefined; messageHandlers = new Map(); jest.clearAllMocks(); @@ -198,6 +221,61 @@ describe('App session lifecycle', () => { }); }); + it('flips into preview mode when sessionEnded carries previewSessionInfo', async () => { + // After Stop, the backend marks the just-ended session as previewable. + // The toolbar must show Resume + Clear without any chat reload, so we + // should NOT see a setConversation message wiping the existing + // messages — only sessionEnded with previewSessionInfo. + render(); + trigger('selectAgent', { agentId: 'agent1' }); + trigger('sessionStarted', { content: 'hi' }); + await waitFor(() => expect(selectorPropsRef.current?.isSessionActive).toBe(true)); + + await click('stop-button'); + expect(selectorPropsRef.current?.isStopPending).toBe(true); + + trigger('sessionEnded', { + previewSessionInfo: { sessionId: 'sess-just-ended', sessionType: 'simulated' } + }); + + await waitFor(() => { + expect(selectorPropsRef.current?.isPreviewingSession).toBe(true); + expect(selectorPropsRef.current?.isSessionActive).toBe(false); + expect(selectorPropsRef.current?.isStopPending).toBe(false); + }); + }); + + it('does not flip into preview mode when sessionEnded has no previewSessionInfo', async () => { + render(); + trigger('selectAgent', { agentId: 'agent1' }); + trigger('sessionStarted', { content: 'hi' }); + await waitFor(() => expect(selectorPropsRef.current?.isSessionActive).toBe(true)); + + await click('stop-button'); + trigger('sessionEnded'); + + await waitFor(() => { + expect(selectorPropsRef.current?.isStopPending).toBe(false); + }); + expect(selectorPropsRef.current?.isPreviewingSession).toBe(false); + }); + + it('syncs isLiveMode from previewSessionInfo on sessionEnded', async () => { + render(); + trigger('selectAgent', { agentId: 'agent1' }); + trigger('sessionStarted', { content: 'hi' }); + await waitFor(() => expect(selectorPropsRef.current?.isSessionActive).toBe(true)); + + // Live session ends → live mode true. + trigger('sessionEnded', { + previewSessionInfo: { sessionId: 'sess-1', sessionType: 'live' } + }); + + await waitFor(() => { + expect(selectorPropsRef.current?.initialLiveMode).toBe(true); + }); + }); + it('does not trigger AgentPreview loader during Stop (no transition flag on Preview)', async () => { render(); trigger('selectAgent', { agentId: 'agent1' }); @@ -316,4 +394,58 @@ describe('App session lifecycle', () => { }); }); }); + + describe('history row click during active session', () => { + it('keeps isStopPending across the stopping transition until the preview lands', async () => { + render(); + trigger('selectAgent', { agentId: 'agent1' }); + trigger('sessionStarted', { content: 'hi' }); + await waitFor(() => expect(selectorPropsRef.current?.isSessionActive).toBe(true)); + + // Switch to history tab and simulate clicking a row (the mock fires + // onPreviewStart synchronously to mirror the real component's flow). + await act(async () => { + sessionHistoryPropsRef.current?.onPreviewStart?.(); + }); + + // The toolbar should now be in the optimistic stop state. + expect(selectorPropsRef.current?.isStopPending).toBe(true); + expect(selectorPropsRef.current?.isSessionTransitioning).toBe(true); + + // Backend fires sessionStarting('Stopping current session...') as part + // of the preview transition. isStopPending must NOT be cleared here. + trigger('sessionStarting', { message: 'Stopping current session...' }); + expect(selectorPropsRef.current?.isStopPending).toBe(true); + + // The preview lands; isStopPending clears so the button can settle on + // its Resume label. + trigger('setConversation', { + messages: [{ role: 'user', content: 'old' }], + previewSessionInfo: { sessionId: 'sess-1', sessionType: 'simulated' } + }); + + await waitFor(() => { + expect(selectorPropsRef.current?.isStopPending).toBe(false); + expect(selectorPropsRef.current?.isPreviewingSession).toBe(true); + }); + }); + + it('clears isStopPending on a regular sessionStarting (real start path)', async () => { + render(); + trigger('selectAgent', { agentId: 'agent1' }); + trigger('sessionStarted', { content: 'hi' }); + await waitFor(() => expect(selectorPropsRef.current?.isSessionActive).toBe(true)); + + await act(async () => { + sessionHistoryPropsRef.current?.onPreviewStart?.(); + }); + expect(selectorPropsRef.current?.isStopPending).toBe(true); + + // A regular start (not a stopping transition) should clear stop-pending. + trigger('sessionStarting', { message: 'Starting session...' }); + await waitFor(() => { + expect(selectorPropsRef.current?.isStopPending).toBe(false); + }); + }); + }); }); diff --git a/test/webview/SessionHistory.test.tsx b/test/webview/SessionHistory.test.tsx index e55cf105..9efcb976 100644 --- a/test/webview/SessionHistory.test.tsx +++ b/test/webview/SessionHistory.test.tsx @@ -148,4 +148,95 @@ describe('SessionHistory', () => { ); expect(onResume).toHaveBeenCalledWith('sess-1'); }); + + it('notifies parent of preview start when a row is clicked while a session is active', async () => { + const onPreviewStart = jest.fn(); + render( + + ); + + emitSessionList('agent-1', [ + { + sessionId: 'sess-1', + timestamp: '2026-05-10T15:30:00Z', + sessionType: 'simulated', + firstUserMessage: 'Hello agent' + } + ]); + + const user = userEvent.setup(); + await user.click(screen.getByText('Hello agent').closest('button')!); + + expect(onPreviewStart).toHaveBeenCalledTimes(1); + }); + + it('does not flip isLiveMode when a row is clicked (App syncs it on setConversation)', async () => { + // Eagerly flipping isLiveMode would change the toolbar Stop label to the + // new session's mode while the old session is still being stopped, which + // looks wrong. Mode should sync from previewSessionInfo when the preview + // actually lands, not from the row click. + const onLiveModeChange = jest.fn(); + render( + + ); + + emitSessionList('agent-1', [ + { + sessionId: 'sess-1', + timestamp: '2026-05-10T15:30:00Z', + sessionType: 'simulated', + firstUserMessage: 'Hello agent' + } + ]); + + const user = userEvent.setup(); + await user.click(screen.getByText('Hello agent').closest('button')!); + + expect(onLiveModeChange).not.toHaveBeenCalled(); + }); + + it('does not notify onPreviewStart when no session is active', async () => { + const onPreviewStart = jest.fn(); + render( + + ); + + emitSessionList('agent-1', [ + { + sessionId: 'sess-1', + timestamp: '2026-05-10T15:30:00Z', + sessionType: 'simulated', + firstUserMessage: 'Hello agent' + } + ]); + + const user = userEvent.setup(); + await user.click(screen.getByText('Hello agent').closest('button')!); + + expect(onPreviewStart).not.toHaveBeenCalled(); + }); }); diff --git a/webview/src/App.tsx b/webview/src/App.tsx index 09afe014..c56afd24 100644 --- a/webview/src/App.tsx +++ b/webview/src/App.tsx @@ -191,6 +191,10 @@ const App: React.FC = () => { const messages = Array.isArray(data?.messages) ? data.messages : []; if (info && typeof info.sessionId === 'string') { setIsPreviewingSession(true); + // Preview has landed; the stopping transition started by a history + // row click is over. Clear the stop-pending flag so the button can + // settle on its Resume label. + setIsStopPending(false); if (info.sessionType === 'simulated') { setIsLiveMode(false); } else if (info.sessionType === 'live' || info.sessionType === 'published') { @@ -298,15 +302,27 @@ const App: React.FC = () => { } }); - const disposeSessionEnded = vscodeApi.onMessage('sessionEnded', () => { + const disposeSessionEnded = vscodeApi.onMessage('sessionEnded', (data: any) => { sessionActiveRef.current = false; isSessionStartingRef.current = false; setIsSessionActive(false); setIsSessionStarting(false); - setIsStopPending(false); - // Note: don't clear isPreviewingSession here. The setConversation + // If the backend marked the just-ended session as previewable, flip + // into preview mode so the toolbar shows Resume + Clear without + // touching the chat messages already on screen. + const info = data?.previewSessionInfo; + if (info && typeof info.sessionId === 'string') { + setIsPreviewingSession(true); + if (info.sessionType === 'simulated') { + setIsLiveMode(false); + } else if (info.sessionType === 'live' || info.sessionType === 'published') { + setIsLiveMode(true); + } + } + // Else: don't clear isPreviewingSession here. The setConversation // listener owns that flag. When sessionEnded fires after a history-row // click, a preview is still loaded and Resume should remain available. + setIsStopPending(false); activeSessionIdRef.current = undefined; const resolver = sessionEndResolversRef.current.shift(); if (resolver) { @@ -314,7 +330,7 @@ const App: React.FC = () => { } }); - const disposeSessionStarting = vscodeApi.onMessage('sessionStarting', () => { + const disposeSessionStarting = vscodeApi.onMessage('sessionStarting', (data: any) => { sessionActiveRef.current = false; // Update the ref synchronously so the setConversation listener (which // may fire on the same message-bus tick) sees the new value before @@ -322,7 +338,13 @@ const App: React.FC = () => { isSessionStartingRef.current = true; setIsSessionActive(false); setIsSessionStarting(true); - setIsStopPending(false); + // Don't clear isStopPending during a stopping-for-preview transition, + // or the toolbar button briefly flips to Start before the preview + // lands and reveals Resume. + const isStoppingTransition = typeof data?.message === 'string' && /stopping/i.test(data.message); + if (!isStoppingTransition) { + setIsStopPending(false); + } // Switch to preview tab when starting a new session setActiveTab('preview'); }); @@ -485,6 +507,7 @@ const App: React.FC = () => { isSessionActive={isSessionActive} isSessionStarting={isSessionStarting} isSessionTransitioning={isSessionTransitioning || isStopPending} + isStopPending={isStopPending} onLiveModeChange={handleLiveModeChange} initialLiveMode={isLiveMode} onSelectedAgentInfoChange={setSelectedAgentInfo} @@ -544,6 +567,12 @@ const App: React.FC = () => { setIsPreviewingSession(true); setActiveTab('preview'); }} + onPreviewStart={() => { + // History row clicked while a session is active. Mirror the + // optimistic Stop flow so the toolbar button keeps its Stop + // label across the stopping transition until the preview lands. + setIsStopPending(true); + }} onGoToPreview={handleGoToPreview} onLiveModeChange={handleLiveModeChange} /> diff --git a/webview/src/components/AgentPreview/AgentSelector.tsx b/webview/src/components/AgentPreview/AgentSelector.tsx index 42079e63..c79cedf9 100644 --- a/webview/src/components/AgentPreview/AgentSelector.tsx +++ b/webview/src/components/AgentPreview/AgentSelector.tsx @@ -10,6 +10,7 @@ interface AgentSelectorProps { isSessionActive?: boolean; isSessionStarting?: boolean; isSessionTransitioning?: boolean; + isStopPending?: boolean; onLiveModeChange?: (isLive: boolean) => void; initialLiveMode?: boolean; onSelectedAgentInfoChange?: (agentInfo: AgentInfo | null) => void; @@ -55,6 +56,7 @@ const AgentSelector: React.FC = ({ isSessionActive = false, isSessionStarting = false, isSessionTransitioning = false, + isStopPending = false, onLiveModeChange, initialLiveMode = false, onSelectedAgentInfoChange, @@ -66,7 +68,7 @@ const AgentSelector: React.FC = ({ const [agents, setAgents] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isLiveMode, setIsLiveMode] = useState(initialLiveMode); - const shouldShowStop = isSessionActive; + const shouldShowStop = isSessionActive || isStopPending; const stopIcon = ( void; + onPreviewStart?: () => void; onGoToPreview?: () => void; onLiveModeChange?: (isLive: boolean) => void; } @@ -71,6 +72,7 @@ const SessionHistory: React.FC = ({ isLiveMode, selectedAgentInfo = null, onResume, + onPreviewStart, onGoToPreview, onLiveModeChange }) => { @@ -102,13 +104,16 @@ const SessionHistory: React.FC = ({ if (!agentId) { return; } - // Sync the Start button mode to the previewed session's type so clicking - // Start resumes in the same mode the session ran in originally. - if (onLiveModeChange && session.sessionType) { - const wasLive = session.sessionType === 'live' || session.sessionType === 'published'; - if (wasLive !== isLiveMode) { - onLiveModeChange(wasLive); - } + // Note: don't flip isLiveMode here. App's setConversation handler syncs + // the mode from previewSessionInfo.sessionType when the preview lands, + // so the toolbar button keeps showing the old session's mode through the + // stopping transition and flips to the new mode at the same moment the + // Resume label appears. + // If a session is active, the backend will run a stopping transition + // before the preview lands. Notify the parent so the toolbar's Stop + // label stays put across the transition instead of flipping to Start. + if (isSessionActive) { + onPreviewStart?.(); } vscodeApi.previewSession(agentId, session.sessionId, { agentSource, sessionType: session.sessionType }); onResume(session.sessionId); From bc1167a8f7826ba8e54fadfc870f30479a43f057 Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Sun, 17 May 2026 23:27:48 +0100 Subject: [PATCH 12/17] fix: skip resumable preview on restart-driven session end Mode switch and agent change end the current session before starting a new one. Without a restart signal, the just-ended session was marked previewable, causing the next startSession to route through resumeSession and resume the prior session instead of starting fresh in the new mode. --- .../handlers/webviewMessageHandlers.ts | 22 ++++---- .../agentCombined/session/sessionManager.ts | 10 +++- test/views/sessionManager.test.ts | 50 +++++++++++++++++++ test/webview/App.sessionLifecycle.test.tsx | 41 +++++++++++++++ webview/src/App.tsx | 5 +- webview/src/services/vscodeApi.ts | 4 +- 6 files changed, 118 insertions(+), 14 deletions(-) diff --git a/src/views/agentCombined/handlers/webviewMessageHandlers.ts b/src/views/agentCombined/handlers/webviewMessageHandlers.ts index 9bd0315d..7776de6c 100644 --- a/src/views/agentCombined/handlers/webviewMessageHandlers.ts +++ b/src/views/agentCombined/handlers/webviewMessageHandlers.ts @@ -45,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(), @@ -228,14 +228,18 @@ export class WebviewMessageHandlers { } } - private async handleEndSession(): Promise { - 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 { + 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 { diff --git a/src/views/agentCombined/session/sessionManager.ts b/src/views/agentCombined/session/sessionManager.ts index 2e90e259..85350a27 100644 --- a/src/views/agentCombined/session/sessionManager.ts +++ b/src/views/agentCombined/session/sessionManager.ts @@ -229,7 +229,10 @@ export class SessionManager { /** * Ends the current agent session */ - async endSession(restoreViewCallback?: () => Promise): Promise { + async endSession( + restoreViewCallback?: () => Promise, + options?: { restarting?: boolean } + ): Promise { this.state.cancelPendingSessionStart(); const sessionWasStarting = this.state.isSessionStarting; @@ -267,11 +270,14 @@ export class SessionManager { this.logger.debug(`Simulation ended. AgentName: ${agentName}, SessionId: ${sessionId}`); - if (hadRunningSession && sessionId && endedSessionType) { + if (hadRunningSession && sessionId && endedSessionType && !options?.restarting) { // Mark the just-ended session as previewable so the hasLoadedSession // context flips on (showing the Clear toolbar action) and the webview // toolbar shows Resume. The chat is left untouched; the conversation // already on screen is the previewed session's transcript. + // Skipped when this end is part of a restart (mode switch / agent + // change): the upcoming startSession would route through resume, + // which is not what the user asked for. this.state.previewedSessionId = sessionId; this.messageSender.sendSessionEnded({ sessionId, sessionType: endedSessionType }); } else if (hadRunningSession || sessionWasStarting) { diff --git a/test/views/sessionManager.test.ts b/test/views/sessionManager.test.ts index 00cdaf3c..19a80dc3 100644 --- a/test/views/sessionManager.test.ts +++ b/test/views/sessionManager.test.ts @@ -282,6 +282,56 @@ describe('SessionManager', () => { expect(restoreView).toHaveBeenCalled(); }); + + it('records previewedSessionId so the next start routes through resume', async () => { + // This locks in the Resume-after-Stop integration: endSession must + // leave previewedSessionId set on the shared state so the message + // handler's startSession path picks it up and calls resumeSession + // instead of starting a fresh session. + mockState.currentAgentId = 'agent-1'; + mockState.currentAgentSource = AgentSource.SCRIPT; + mockState.sessionAgentId = 'agent-1'; + mockState.agentInstance = { restoreConnection: jest.fn().mockResolvedValue(undefined) }; + mockState.sessionId = 'session-just-finished'; + mockState.isSessionStarting = false; + mockState.isLiveMode = false; + + await sessionManager.endSession(); + + expect(mockState.previewedSessionId).toBe('session-just-finished'); + }); + + it('does not mark the session previewable when called with restarting=true', async () => { + // Mode switch / agent change paths route through endSession({restarting:true}) + // because the next startSession is going to start a fresh session. + // If we marked the session previewable, startSession would route + // through resumeSession instead. + mockState.currentAgentId = 'agent-1'; + mockState.currentAgentSource = AgentSource.SCRIPT; + mockState.sessionAgentId = 'agent-1'; + mockState.agentInstance = { restoreConnection: jest.fn().mockResolvedValue(undefined) }; + mockState.sessionId = 'session-restarting'; + mockState.isSessionStarting = false; + mockState.previewedSessionId = undefined; + + await sessionManager.endSession(undefined, { restarting: true }); + + expect(mockState.previewedSessionId).toBeUndefined(); + // Plain sessionEnded with no preview info. + expect(mockMessageSender.sendSessionEnded).toHaveBeenCalledWith(); + }); + + it('does not record previewedSessionId when only a starting session was cancelled', async () => { + // Edge case: user clicks Stop while still starting; nothing to resume. + mockState.agentInstance = undefined; + mockState.sessionId = undefined; + mockState.isSessionStarting = true; + mockState.previewedSessionId = undefined; + + await sessionManager.endSession(); + + expect(mockState.previewedSessionId).toBeUndefined(); + }); }); describe('restartSession', () => { diff --git a/test/webview/App.sessionLifecycle.test.tsx b/test/webview/App.sessionLifecycle.test.tsx index da716e90..f0e9f2ef 100644 --- a/test/webview/App.sessionLifecycle.test.tsx +++ b/test/webview/App.sessionLifecycle.test.tsx @@ -448,4 +448,45 @@ describe('App session lifecycle', () => { }); }); }); + + describe('restart vs stop intent', () => { + it('signals restarting=true on endSession when forceRestart triggers a restart', async () => { + // When the user clicks Start while a session is already active (e.g. + // mode switch on an active session), the App's queue calls endSession + // before startSession. The endSession must include restarting:true so + // the backend skips marking the session previewable; otherwise the + // upcoming startSession would route through resume. + render(); + trigger('selectAgent', { agentId: 'agent1' }); + trigger('sessionStarted', { content: 'hi' }); + await waitFor(() => expect(selectorPropsRef.current?.isSessionActive).toBe(true)); + + mockVscodeApi.endSession.mockClear(); + await click('start-button'); + + // Allow the restart queue to flush. + trigger('sessionEnded'); + await waitFor(() => { + expect(mockVscodeApi.endSession).toHaveBeenCalledWith({ restarting: true }); + }); + }); + + it('does not signal restarting on user-initiated Stop', async () => { + // The toolbar Stop button calls endSession directly (no restart). The + // backend should preserve the previewable behavior so Resume appears. + render(); + trigger('selectAgent', { agentId: 'agent1' }); + trigger('sessionStarted', { content: 'hi' }); + await waitFor(() => expect(selectorPropsRef.current?.isSessionActive).toBe(true)); + + mockVscodeApi.endSession.mockClear(); + await click('stop-button'); + + // The mocked AgentSelector calls onStopSession only (does not call + // vscodeApi.endSession directly in this test mock); only the App's + // restart queue invokes vscodeApi.endSession. Verify it was NOT called + // here, which proves the Stop path does not enqueue a restart. + expect(mockVscodeApi.endSession).not.toHaveBeenCalled(); + }); + }); }); diff --git a/webview/src/App.tsx b/webview/src/App.tsx index c56afd24..46bc8c5b 100644 --- a/webview/src/App.tsx +++ b/webview/src/App.tsx @@ -453,7 +453,10 @@ const App: React.FC = () => { if (hasExistingAgent && (changingAgents || shouldForceRestart) && sessionActiveRef.current) { const waitForEnd = waitForSessionEnd(); - vscodeApi.endSession(); + // Tell the backend this end is part of a restart (mode switch or + // agent change) so it skips marking the session as previewable. + // Otherwise the upcoming startSession would route through resume. + vscodeApi.endSession({ restarting: true }); await waitForEnd; } diff --git a/webview/src/services/vscodeApi.ts b/webview/src/services/vscodeApi.ts index f25973f3..bdc76c49 100644 --- a/webview/src/services/vscodeApi.ts +++ b/webview/src/services/vscodeApi.ts @@ -161,8 +161,8 @@ class VSCodeApiService { this.postMessage('startSession', { agentId, ...options }); } - endSession() { - this.postMessage('endSession'); + endSession(options?: { restarting?: boolean }) { + this.postMessage('endSession', options); } sendChatMessage(message: string) { From 4153c8b5c6b56ec2e6ae02eb8e1334e3d280d6b2 Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Sun, 17 May 2026 23:54:29 +0100 Subject: [PATCH 13/17] style: rename toolbar action to Clear Chat Session --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8eb1fc01..ddad58b2 100644 --- a/package.json +++ b/package.json @@ -467,7 +467,7 @@ }, { "command": "sf.agent.combined.view.clearLoadedSession", - "title": "Clear Loaded Session", + "title": "Clear Chat Session", "icon": "$(clear-all)" }, { From 88ddf24c6b404f553d33118641aa582dd3c0d0ad Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Mon, 18 May 2026 00:07:57 +0100 Subject: [PATCH 14/17] feat: render GuardrailsStep in tracer with shield icon --- test/webview/AgentTracer.helpers.test.tsx | 62 +++++++++++++++++++ .../components/AgentTracer/AgentTracer.tsx | 28 ++++++++- webview/src/components/shared/Timeline.tsx | 14 ++++- 3 files changed, 100 insertions(+), 4 deletions(-) diff --git a/test/webview/AgentTracer.helpers.test.tsx b/test/webview/AgentTracer.helpers.test.tsx index bb1a1170..8248c05c 100644 --- a/test/webview/AgentTracer.helpers.test.tsx +++ b/test/webview/AgentTracer.helpers.test.tsx @@ -675,6 +675,68 @@ describe('AgentTracer helpers', () => { expect(result).toContain('"output"'); }); + it('renders GuardrailsStep with display name and adherence level', () => { + const trace = { + type: 'PlanSuccessResponse', + planId: 'plan', + sessionId: 'session', + plan: [ + { + type: 'GuardrailsStep', + generatedResponse: 'Sure thing!', + instructionAdherence: 'HIGH\n\nThis response adheres to the assigned instructions.' + } + ] + }; + + const items = buildTimelineItems(trace, () => undefined); + expect(items).toHaveLength(1); + expect(items[0].label).toBe('Guardrails Check'); + expect(items[0].description).toBe('HIGH'); + }); + + it('includes generatedResponse and instructionAdherence in GuardrailsStep data', () => { + const trace = { + type: 'PlanSuccessResponse', + planId: 'plan', + sessionId: 'session', + plan: [ + { + type: 'GuardrailsStep', + generatedResponse: 'Hi there.', + instructionAdherence: 'HIGH\n\nReason here.' + } + ] + }; + + const result = getStepData(trace, 0); + expect(result).toContain('"generatedResponse"'); + expect(result).toContain('Hi there.'); + expect(result).toContain('"instructionAdherence"'); + }); + + it('makes GuardrailsStep clickable so the detail panel opens', () => { + const trace = { + type: 'PlanSuccessResponse', + planId: 'plan', + sessionId: 'session', + plan: [ + { + type: 'GuardrailsStep', + generatedResponse: 'Hi there.', + instructionAdherence: 'HIGH' + } + ] + }; + + const indices: number[] = []; + const items = buildTimelineItems(trace, index => indices.push(index)); + expect(items).toHaveLength(1); + expect(items[0].onClick).toBeDefined(); + items[0].onClick?.(); + expect(indices).toEqual([0]); + }); + it('makes FunctionStep clickable when function data exists', () => { const trace = { type: 'PlanSuccessResponse', diff --git a/webview/src/components/AgentTracer/AgentTracer.tsx b/webview/src/components/AgentTracer/AgentTracer.tsx index f9a87d7e..bf9c415b 100644 --- a/webview/src/components/AgentTracer/AgentTracer.tsx +++ b/webview/src/components/AgentTracer/AgentTracer.tsx @@ -217,7 +217,8 @@ const STEP_DISPLAY_NAMES: Record = { OutputEvaluationStep: 'Output Evaluation', PlannerResponseStep: 'Agent Response', UpdateTopicStep: 'Subagent Selected', - FunctionStep: 'Action Executed' + FunctionStep: 'Action Executed', + GuardrailsStep: 'Guardrails Check' }; // Map step types to icons (VS Code icons) @@ -235,7 +236,8 @@ const STEP_ICONS: Record = { OutputEvaluationStep: 'search', PlannerResponseStep: 'agent', UpdateTopicStep: 'tag', - FunctionStep: 'action' + FunctionStep: 'action', + GuardrailsStep: 'shield' }; // Get the description/subtitle for a step based on its type @@ -322,6 +324,18 @@ const getStepDescription = (step: any): string | undefined => { case 'FunctionStep': return step.function?.name || undefined; + case 'GuardrailsStep': { + // instructionAdherence is a multi-line string starting with a level + // like "HIGH" / "MEDIUM" / "LOW", followed by an explanation. Surface + // just the level on the timeline. + const adherence = step.instructionAdherence; + if (typeof adherence === 'string' && adherence.length > 0) { + const firstLine = adherence.split('\n')[0].trim(); + return firstLine || undefined; + } + return undefined; + } + default: return undefined; } @@ -366,7 +380,9 @@ export const buildTimelineItems = ( } const description = getStepDescription(step); - const hasData = step && (step.data || step.message || step.reason || step.function); + const hasData = + step && + (step.data || step.message || step.reason || step.function || step.generatedResponse || step.instructionAdherence); return { status, @@ -424,6 +440,12 @@ export const getStepData = (traceData: PlanSuccessResponse | null, selectedStepI if (step.function) { displayData.function = step.function; } + if (step.generatedResponse) { + displayData.generatedResponse = step.generatedResponse; + } + if (step.instructionAdherence) { + displayData.instructionAdherence = step.instructionAdherence; + } if (Object.keys(displayData).length > 0) { return JSON.stringify(displayData, null, 2); diff --git a/webview/src/components/shared/Timeline.tsx b/webview/src/components/shared/Timeline.tsx index ab499403..7ab5dc3a 100644 --- a/webview/src/components/shared/Timeline.tsx +++ b/webview/src/components/shared/Timeline.tsx @@ -22,7 +22,8 @@ export type TimelineIconName = | 'check-all' | 'error' | 'agent' - | 'action'; + | 'action' + | 'shield'; export interface TimelineItemProps { /** @@ -245,6 +246,17 @@ const getIconByName = (name: TimelineIconName): React.ReactNode => { /> ); + case 'shield': + return ( + + + + ); default: return null; } From 68e5ec4e2a00471f56401524253120d0b3764239 Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Mon, 18 May 2026 00:15:24 +0100 Subject: [PATCH 15/17] test: cover endSession restarting flag and setConversation flows --- .../handlers/webviewMessageHandlers.test.ts | 34 ++++++ test/webview/App.sessionLifecycle.test.tsx | 100 ++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts b/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts index 7c45462c..a842efa7 100644 --- a/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts +++ b/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts @@ -346,6 +346,40 @@ describe('WebviewMessageHandlers', () => { }); }); + describe('endSession message wiring', () => { + it('forwards restarting=true from the message data to sessionManager.endSession', async () => { + // The App.tsx restart queue posts endSession({ restarting: true }) when + // a mode switch or agent change drives a stop-then-start. The handler + // must propagate that flag so sessionManager skips marking the session + // previewable; otherwise the upcoming startSession routes through + // resume. + await handlers.handleMessage({ + command: 'endSession', + data: { restarting: true } + } as any); + + expect(mockSessionManager.endSession).toHaveBeenCalledTimes(1); + const [, options] = mockSessionManager.endSession.mock.calls[0]; + expect(options).toEqual({ restarting: true }); + }); + + it('passes restarting=false when the message has no data (user-initiated Stop)', async () => { + await handlers.handleMessage({ command: 'endSession' } as any); + + expect(mockSessionManager.endSession).toHaveBeenCalledTimes(1); + const [, options] = mockSessionManager.endSession.mock.calls[0]; + expect(options).toEqual({ restarting: false }); + }); + + it('passes restarting=false when the data omits the flag', async () => { + await handlers.handleMessage({ command: 'endSession', data: {} } as any); + + expect(mockSessionManager.endSession).toHaveBeenCalledTimes(1); + const [, options] = mockSessionManager.endSession.mock.calls[0]; + expect(options).toEqual({ restarting: false }); + }); + }); + describe('startSession with previewed session', () => { it('routes through resumeSession when a previewed session is set for the same agent', async () => { mockState.currentAgentId = 'agent-1'; diff --git a/test/webview/App.sessionLifecycle.test.tsx b/test/webview/App.sessionLifecycle.test.tsx index f0e9f2ef..b5eefcd8 100644 --- a/test/webview/App.sessionLifecycle.test.tsx +++ b/test/webview/App.sessionLifecycle.test.tsx @@ -489,4 +489,104 @@ describe('App session lifecycle', () => { expect(mockVscodeApi.endSession).not.toHaveBeenCalled(); }); }); + + describe('setConversation handler', () => { + it('flips into preview mode when setConversation includes previewSessionInfo', async () => { + render(); + trigger('selectAgent', { agentId: 'agent1' }); + + trigger('setConversation', { + messages: [{ role: 'user', content: 'old' }], + previewSessionInfo: { sessionId: 'sess-1', sessionType: 'simulated' } + }); + + await waitFor(() => { + expect(selectorPropsRef.current?.isPreviewingSession).toBe(true); + expect(selectorPropsRef.current?.initialLiveMode).toBe(false); + }); + }); + + it('syncs isLiveMode=true for live and published session types', async () => { + render(); + trigger('selectAgent', { agentId: 'agent1' }); + + trigger('setConversation', { + messages: [], + previewSessionInfo: { sessionId: 'sess-1', sessionType: 'live' } + }); + + await waitFor(() => { + expect(selectorPropsRef.current?.initialLiveMode).toBe(true); + }); + + trigger('setConversation', { + messages: [], + previewSessionInfo: { sessionId: 'sess-2', sessionType: 'published' } + }); + + await waitFor(() => { + expect(selectorPropsRef.current?.initialLiveMode).toBe(true); + }); + }); + + it('drops preview flag for a non-resumable load (messages but no preview info)', async () => { + // Simulate a session already in preview mode. + render(); + trigger('selectAgent', { agentId: 'agent1' }); + trigger('setConversation', { + messages: [{ role: 'user', content: 'old' }], + previewSessionInfo: { sessionId: 'sess-1', sessionType: 'simulated' } + }); + await waitFor(() => expect(selectorPropsRef.current?.isPreviewingSession).toBe(true)); + + // Legacy auto-load: messages, no previewSessionInfo. + trigger('setConversation', { + messages: [{ role: 'user', content: 'auto-loaded' }] + }); + + await waitFor(() => { + expect(selectorPropsRef.current?.isPreviewingSession).toBe(false); + }); + }); + + it('drops preview flag on an explicit toolbar Clear (empty + no preview info, not stopping)', async () => { + render(); + trigger('selectAgent', { agentId: 'agent1' }); + trigger('setConversation', { + messages: [{ role: 'user', content: 'old' }], + previewSessionInfo: { sessionId: 'sess-1', sessionType: 'simulated' } + }); + await waitFor(() => expect(selectorPropsRef.current?.isPreviewingSession).toBe(true)); + + // Toolbar Clear: empty messages, null preview info, not in a stopping + // transition (isSessionStarting is false here). + trigger('setConversation', { messages: [], previewSessionInfo: null }); + + await waitFor(() => { + expect(selectorPropsRef.current?.isPreviewingSession).toBe(false); + }); + }); + + it('preserves preview flag mid-stop (empty + null + isSessionStarting=true)', async () => { + // Simulate the stopping transition triggered by a history-row click: + // backend sends sessionStarting first, then the empty setConversation, + // then the previewed conversation. + render(); + trigger('selectAgent', { agentId: 'agent1' }); + trigger('setConversation', { + messages: [{ role: 'user', content: 'old' }], + previewSessionInfo: { sessionId: 'sess-1', sessionType: 'simulated' } + }); + await waitFor(() => expect(selectorPropsRef.current?.isPreviewingSession).toBe(true)); + + // Backend flips into stopping mode. + trigger('sessionStarting', { message: 'Stopping current session...' }); + + // Empty setConversation arrives while sessionStarting is still true. + trigger('setConversation', { messages: [], previewSessionInfo: null }); + + // Preview flag must NOT be cleared during the stopping transition. + expect(selectorPropsRef.current?.isPreviewingSession).toBe(true); + }); + }); }); From 8cba68eebbd80e527e39e546338aea5cfe1923f0 Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Mon, 18 May 2026 11:45:23 +0100 Subject: [PATCH 16/17] feat: gate toolbar and chat UI during session stop transitions Add a sessionStopping context flag set during SDK teardown so view/title icons can no longer be triggered between sessionActive=false and the SDK's end completing. Hide the chat transcript and surface a unified "Stopping session..." spinner while a stop is in flight, disable the Stop button and tab navigation, and reorder the live-test toolbar so Restart Options appear before Debug. --- package.json | 20 ++-- .../handlers/webviewMessageHandlers.ts | 4 +- .../agentCombined/session/sessionManager.ts | 6 ++ .../agentCombined/state/agentViewState.ts | 4 + .../handlers/webviewMessageHandlers.test.ts | 3 +- test/views/sessionManager.test.ts | 1 + test/webview/AgentPreview.coverage.test.tsx | 93 +++++++++++++++++++ test/webview/App.sessionLifecycle.test.tsx | 6 +- webview/src/App.tsx | 2 +- .../components/AgentPreview/AgentPreview.tsx | 20 +++- .../components/AgentPreview/AgentSelector.tsx | 6 +- 11 files changed, 143 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index 2f1dfdd9..57dcf62f 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:sessionError && agentforceDX:hasConversationData", + "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", + "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 && !agentforceDX:sessionError", + "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:canResetAgentView && !agentforceDX:sessionError", + "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", + "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" }, { diff --git a/src/views/agentCombined/handlers/webviewMessageHandlers.ts b/src/views/agentCombined/handlers/webviewMessageHandlers.ts index 7776de6c..7b99016f 100644 --- a/src/views/agentCombined/handlers/webviewMessageHandlers.ts +++ b/src/views/agentCombined/handlers/webviewMessageHandlers.ts @@ -481,11 +481,12 @@ export class WebviewMessageHandlers { // 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 current session...'); + this.messageSender.sendSessionStarting('Stopping session...'); this.messageSender.sendSetConversation([], true, null); this.messageSender.sendTraceHistory(agentId, []); try { @@ -527,6 +528,7 @@ export class WebviewMessageHandlers { // 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(); } } diff --git a/src/views/agentCombined/session/sessionManager.ts b/src/views/agentCombined/session/sessionManager.ts index 85350a27..ae0c8ac0 100644 --- a/src/views/agentCombined/session/sessionManager.ts +++ b/src/views/agentCombined/session/sessionManager.ts @@ -240,6 +240,10 @@ export class SessionManager { // This matches the webview's optimistic update for button state await this.state.setSessionActive(false); await this.state.setSessionStarting(false); + // Block toolbar actions while the SDK teardown is in flight. Without this, + // the gap between sessionActive=false and clearSessionState exposes + // toolbar items gated only on !sessionActive && !sessionStarting. + await this.state.setSessionStopping(true); const agentName = this.state.currentAgentName; const sessionId = this.state.sessionId; @@ -287,6 +291,8 @@ export class SessionManager { if (sessionWasStarting && restoreViewCallback) { await restoreViewCallback(); } + + await this.state.setSessionStopping(false); } /** diff --git a/src/views/agentCombined/state/agentViewState.ts b/src/views/agentCombined/state/agentViewState.ts index 2d56fd3b..cb11b594 100644 --- a/src/views/agentCombined/state/agentViewState.ts +++ b/src/views/agentCombined/state/agentViewState.ts @@ -196,6 +196,10 @@ export class AgentViewState { await vscode.commands.executeCommand('setContext', 'agentforceDX:sessionStarting', starting); } + async setSessionStopping(stopping: boolean): Promise { + await vscode.commands.executeCommand('setContext', 'agentforceDX:sessionStopping', stopping); + } + beginSessionStart(): number { return ++this._sessionStartOperationId; } diff --git a/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts b/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts index a842efa7..bb40f37f 100644 --- a/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts +++ b/test/views/agentCombined/handlers/webviewMessageHandlers.test.ts @@ -109,6 +109,7 @@ describe('WebviewMessageHandlers', () => { pendingStartAgentSource: undefined, setSessionActive: jest.fn().mockResolvedValue(undefined), setSessionStarting: jest.fn().mockResolvedValue(undefined), + setSessionStopping: jest.fn().mockResolvedValue(undefined), setResetAgentViewAvailable: jest.fn().mockResolvedValue(undefined), setSessionErrorState: jest.fn().mockResolvedValue(undefined), setConversationDataAvailable: jest.fn().mockResolvedValue(undefined), @@ -422,7 +423,7 @@ describe('WebviewMessageHandlers', () => { expect(mockState.cancelPendingSessionStart).toHaveBeenCalled(); expect(mockMessageSender.sendSetConversation).toHaveBeenCalledWith([], true, null); expect(mockMessageSender.sendTraceHistory).toHaveBeenCalledWith('agent-1', []); - expect(mockMessageSender.sendSessionStarting).toHaveBeenCalledWith('Stopping current session...'); + expect(mockMessageSender.sendSessionStarting).toHaveBeenCalledWith('Stopping session...'); // Published agents end with 'UserRequest' expect(previewEnd).toHaveBeenCalledWith('UserRequest'); expect(restoreConnection).toHaveBeenCalled(); diff --git a/test/views/sessionManager.test.ts b/test/views/sessionManager.test.ts index 19a80dc3..73b1d2a1 100644 --- a/test/views/sessionManager.test.ts +++ b/test/views/sessionManager.test.ts @@ -102,6 +102,7 @@ describe('SessionManager', () => { sessionStartOperationId: 1, // Match the return value of beginSessionStart setSessionActive: jest.fn().mockResolvedValue(undefined), setSessionStarting: jest.fn().mockResolvedValue(undefined), + setSessionStopping: jest.fn().mockResolvedValue(undefined), setResetAgentViewAvailable: jest.fn().mockResolvedValue(undefined), setSessionErrorState: jest.fn().mockResolvedValue(undefined), setConversationDataAvailable: jest.fn().mockResolvedValue(undefined), diff --git a/test/webview/AgentPreview.coverage.test.tsx b/test/webview/AgentPreview.coverage.test.tsx index 45bbed12..119bfef1 100644 --- a/test/webview/AgentPreview.coverage.test.tsx +++ b/test/webview/AgentPreview.coverage.test.tsx @@ -553,4 +553,97 @@ describe('AgentPreview - Coverage Tests', () => { expect(vscodeApi.sendChatMessage).toHaveBeenCalledWith('Test message'); }); }); + + describe('Stopping transition', () => { + it('shows the unified "Stopping session..." spinner and hides messages while a stop is pending (simulation)', async () => { + const { rerender, container } = render( + + ); + + // Drop a message into the chat through the setConversation handler. + handlers.get('setConversation')!({ + messages: [{ role: 'agent', content: 'Hello visible!' }] + }); + handlers.get('sessionStarted')!({ sessionId: 'sess-1', skipWelcome: true }); + + await waitFor(() => { + expect(screen.getByText('Hello visible!')).toBeInTheDocument(); + }); + + // Stop is clicked: parent flips isStopPending true. + rerender( + + ); + + // Unified stopping message and spinner. + await waitFor(() => { + expect(screen.getByText('Stopping session...')).toBeInTheDocument(); + }); + expect(container.querySelector('.loading-spinner')).toBeInTheDocument(); + + // Chat transcript is hidden while stopping. + expect(screen.queryByText('Hello visible!')).not.toBeInTheDocument(); + }); + + it('uses the same "Stopping session..." message for live test as for simulation', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Stopping session...')).toBeInTheDocument(); + }); + // No mode-specific phrasing. + expect(screen.queryByText(/Stopping live test/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Stopping simulation/i)).not.toBeInTheDocument(); + }); + + it('shows the unified stopping spinner when the backend drives the history-tab switch path', async () => { + // The history-tab switch path comes from the backend as a sessionStarting + // message with the stopping phrase, surfaced through the standard chat + // loader. Verify the same unified copy reaches the chat area. + const { container } = render( + + ); + + handlers.get('sessionStarting')!({ message: 'Stopping session...' }); + + await waitFor(() => { + expect(screen.getByText('Stopping session...')).toBeInTheDocument(); + }); + expect(container.querySelector('.loading-spinner')).toBeInTheDocument(); + }); + }); }); diff --git a/test/webview/App.sessionLifecycle.test.tsx b/test/webview/App.sessionLifecycle.test.tsx index b5eefcd8..c0a4cb7d 100644 --- a/test/webview/App.sessionLifecycle.test.tsx +++ b/test/webview/App.sessionLifecycle.test.tsx @@ -412,9 +412,9 @@ describe('App session lifecycle', () => { expect(selectorPropsRef.current?.isStopPending).toBe(true); expect(selectorPropsRef.current?.isSessionTransitioning).toBe(true); - // Backend fires sessionStarting('Stopping current session...') as part + // Backend fires sessionStarting('Stopping session...') as part // of the preview transition. isStopPending must NOT be cleared here. - trigger('sessionStarting', { message: 'Stopping current session...' }); + trigger('sessionStarting', { message: 'Stopping session...' }); expect(selectorPropsRef.current?.isStopPending).toBe(true); // The preview lands; isStopPending clears so the button can settle on @@ -580,7 +580,7 @@ describe('App session lifecycle', () => { await waitFor(() => expect(selectorPropsRef.current?.isPreviewingSession).toBe(true)); // Backend flips into stopping mode. - trigger('sessionStarting', { message: 'Stopping current session...' }); + trigger('sessionStarting', { message: 'Stopping session...' }); // Empty setConversation arrives while sessionStarting is still true. trigger('setConversation', { messages: [], previewSessionInfo: null }); diff --git a/webview/src/App.tsx b/webview/src/App.tsx index 46bc8c5b..8c61c546 100644 --- a/webview/src/App.tsx +++ b/webview/src/App.tsx @@ -520,7 +520,7 @@ const App: React.FC = () => { isPreviewingSession={isPreviewingSession} />
- {previewAgentId !== '' && !isSessionStarting && ( + {previewAgentId !== '' && !isSessionStarting && !isStopPending && ( void; @@ -531,15 +533,27 @@ const AgentPreview = forwardRef( // immediately on click, before the backend confirms sessionEnded. const inputEnabled = agentConnected && parentIsSessionActive && !isStopPending && !isSessionTransitioning; + // While a Stop is in flight, mirror the "Connecting..." loader so the user + // sees the chat-area spinner and a clear stopping message instead of a + // frozen chat that still looks interactive. + const showStopLoader = isStopPending && !isLoading; + const effectiveIsLoading = isLoading || showStopLoader; + const effectiveLoadingMessage = showStopLoader ? STOPPING_SESSION_MESSAGE : loadingMessage; + + // Hide the chat transcript while a Stop is in flight so the user sees only + // the spinner and stopping message. Mirrors the empty-chat treatment during + // session start. + const visibleMessages = showStopLoader ? [] : messages; + return (
- +
diff --git a/webview/src/components/AgentPreview/AgentSelector.tsx b/webview/src/components/AgentPreview/AgentSelector.tsx index c79cedf9..d4f8cadf 100644 --- a/webview/src/components/AgentPreview/AgentSelector.tsx +++ b/webview/src/components/AgentPreview/AgentSelector.tsx @@ -307,7 +307,7 @@ const AgentSelector: React.FC = ({ size="small" onClick={handleStartClick} className="agent-selector__start-button" - disabled={isLoading || isSessionStarting || isSessionTransitioning} + disabled={isLoading || isSessionStarting || isSessionTransitioning || isStopPending} startIcon={shouldShowStop ? stopIcon : playIcon} > {shouldShowStop ? 'Stop Live Test' : isPreviewingSession ? 'Resume Live Test' : 'Start Live Test'} @@ -318,7 +318,7 @@ const AgentSelector: React.FC = ({ size="small" onClick={handleStartClick} className="agent-selector__start-button" - disabled={isLoading || isSessionStarting || isSessionTransitioning} + disabled={isLoading || isSessionStarting || isSessionTransitioning || isStopPending} startIcon={stopIcon} > {isLiveMode ? 'Stop Live Test' : 'Stop Simulation'} @@ -335,7 +335,7 @@ const AgentSelector: React.FC = ({ { label: 'Live Test', value: 'live' } ]} className="agent-selector__start-button" - disabled={isLoading || isSessionStarting || isSessionTransitioning} + disabled={isLoading || isSessionStarting || isSessionTransitioning || isStopPending} startIcon={shouldShowStop ? stopIcon : playIcon} > {shouldShowStop From 55ca0a0f9d56a6e8e618c270f55d07c8be92e8c3 Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Mon, 18 May 2026 15:03:56 +0100 Subject: [PATCH 17/17] fix: capture prior session identity before overwriting agent source --- .../handlers/webviewMessageHandlers.ts | 14 ++++++++++---- src/views/agentCombined/state/agentViewState.ts | 5 ++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/views/agentCombined/handlers/webviewMessageHandlers.ts b/src/views/agentCombined/handlers/webviewMessageHandlers.ts index 7b99016f..2a3d917d 100644 --- a/src/views/agentCombined/handlers/webviewMessageHandlers.ts +++ b/src/views/agentCombined/handlers/webviewMessageHandlers.ts @@ -462,6 +462,16 @@ export class WebviewMessageHandlers { 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; @@ -469,10 +479,6 @@ export class WebviewMessageHandlers { // 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). - const previousAgent = this.state.agentInstance; - const previousSessionId = this.state.sessionId; - const previousSource = this.state.currentAgentSource; - const hadActiveSession = !!(previousAgent && previousSessionId); if (hadActiveSession) { this.state.cancelPendingSessionStart(); // Flip context flags immediately so toolbar actions tied to sessionActive diff --git a/src/views/agentCombined/state/agentViewState.ts b/src/views/agentCombined/state/agentViewState.ts index cb11b594..8bc0151f 100644 --- a/src/views/agentCombined/state/agentViewState.ts +++ b/src/views/agentCombined/state/agentViewState.ts @@ -275,6 +275,9 @@ export class AgentViewState { this._pendingStartAgentId = undefined; this._pendingStartAgentSource = undefined; this._pendingSelectAgentId = undefined; - this._previewedSessionId = undefined; + // Route through the setter so the hasLoadedSession context flag is reset + // alongside the field. Direct assignment would leave the toolbar's Clear + // Chat Session action visible after a teardown. + this.previewedSessionId = undefined; } }