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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 10 additions & 16 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

54 changes: 54 additions & 0 deletions src/views/agentCombined/handlers/webviewMessageHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { HistoryManager } from '../history';
import type { ApexDebugManager } from '../debugging';
import { Logger } from '../../../utils/logger';
import { getAgentSource } from '../agent';
import { listSessionsForAgent } from '../session';

/**
* Handles all incoming messages from the webview
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -383,6 +386,57 @@ export class WebviewMessageHandlers {
this.messageSender.sendLiveMode(this.state.isLiveMode);
}

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

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

/**
* Handles all outgoing messages to the webview
Expand All @@ -27,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 {
Expand Down Expand Up @@ -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<void> {
const sanitizedMessage = this.stripHtmlTags(message);
Expand Down
2 changes: 2 additions & 0 deletions src/views/agentCombined/session/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export { SessionManager } from './sessionManager';
export { createSessionStartGuards } from './sessionStartGuards';
export { listSessionsForAgent } from './sessionHistoryService';
export type { SessionListEntry } from './sessionHistoryService';
133 changes: 133 additions & 0 deletions src/views/agentCombined/session/sessionHistoryService.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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/<key>/sessions/<sessionId>/`,
* 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/<storageKey>/sessions/` so the list
* matches what the sf CLI plugin sees on disk.
*/
export async function listSessionsForAgent(
agentId: string,
agentSource: AgentSource
): Promise<SessionListEntry[]> {
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;
}
Loading
Loading