mirror of
https://github.com/microsoft/vscode.git
synced 2026-05-03 02:59:02 -05:00
Show historical debug sessions in Agent Debug Panel (#309073)
* Show historical debug sessions in Agent Debug Logs * Feedback update * add test
This commit is contained in:
@@ -1305,6 +1305,31 @@ export class ChatDebugFileLoggerService extends Disposable implements IChatDebug
|
||||
}
|
||||
}
|
||||
|
||||
async listSessionIds(): Promise<string[]> {
|
||||
const dir = this._getDebugLogsDir();
|
||||
if (!dir) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const entries = await this._fileSystemService.readDirectory(dir);
|
||||
const dirs = entries.filter(([, type]) => type === 2 /* FileType.Directory */);
|
||||
|
||||
// Stat each directory in parallel to sort by most recently modified.
|
||||
const withMtime = await Promise.all(dirs.map(async ([name]) => {
|
||||
try {
|
||||
const stat = await this._fileSystemService.stat(URI.joinPath(dir, name));
|
||||
return { name, mtime: stat.mtime };
|
||||
} catch {
|
||||
return { name, mtime: 0 };
|
||||
}
|
||||
}));
|
||||
withMtime.sort((a, b) => b.mtime - a.mtime);
|
||||
return withMtime.map(e => e.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async _cleanupOldLogs(): Promise<void> {
|
||||
const dir = this._getDebugLogsDir();
|
||||
if (!dir) {
|
||||
|
||||
@@ -750,4 +750,70 @@ describe('ChatDebugFileLoggerService', () => {
|
||||
expect(childHooks).toHaveLength(1);
|
||||
expect(childHooks[0].name).toBe('PreToolUse');
|
||||
});
|
||||
|
||||
describe('listSessionIds', () => {
|
||||
it('returns empty when no sessions exist', async () => {
|
||||
const ids = await service.listSessionIds();
|
||||
expect(ids).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('lists session directories on disk', async () => {
|
||||
await service.startSession('session-a');
|
||||
otelService.fireSpan(makeToolCallSpan('session-a', 'read_file'));
|
||||
await service.flush('session-a');
|
||||
|
||||
await service.startSession('session-b');
|
||||
otelService.fireSpan(makeToolCallSpan('session-b', 'edit_file'));
|
||||
await service.flush('session-b');
|
||||
|
||||
const ids = await service.listSessionIds();
|
||||
expect(ids).toContain('session-a');
|
||||
expect(ids).toContain('session-b');
|
||||
});
|
||||
|
||||
it('returns sessions sorted by most recently modified first', async () => {
|
||||
await service.startSession('older-session');
|
||||
otelService.fireSpan(makeToolCallSpan('older-session', 'read_file'));
|
||||
await service.flush('older-session');
|
||||
|
||||
// Small delay so mtime differs
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
await service.startSession('newer-session');
|
||||
otelService.fireSpan(makeToolCallSpan('newer-session', 'edit_file'));
|
||||
await service.flush('newer-session');
|
||||
|
||||
const ids = await service.listSessionIds();
|
||||
expect(ids.indexOf('newer-session')).toBeLessThan(ids.indexOf('older-session'));
|
||||
});
|
||||
|
||||
it('does not include non-directory entries', async () => {
|
||||
// Create a session directory
|
||||
await service.startSession('real-session');
|
||||
otelService.fireSpan(makeToolCallSpan('real-session', 'read_file'));
|
||||
await service.flush('real-session');
|
||||
|
||||
// Create a stray file in the debug-logs directory
|
||||
const debugLogsDir = service.debugLogsDir!;
|
||||
await fs.promises.writeFile(path.join(debugLogsDir.fsPath, 'stray-file.jsonl'), '{}');
|
||||
|
||||
const ids = await service.listSessionIds();
|
||||
expect(ids).toContain('real-session');
|
||||
expect(ids).not.toContain('stray-file.jsonl');
|
||||
});
|
||||
|
||||
it('handles stat failures gracefully', async () => {
|
||||
await service.startSession('good-session');
|
||||
otelService.fireSpan(makeToolCallSpan('good-session', 'read_file'));
|
||||
await service.flush('good-session');
|
||||
|
||||
// Create an empty directory that can be listed but stat should still work
|
||||
const debugLogsDir = service.debugLogsDir!;
|
||||
await fs.promises.mkdir(path.join(debugLogsDir.fsPath, 'empty-dir'));
|
||||
|
||||
const ids = await service.listSessionIds();
|
||||
expect(ids).toContain('good-session');
|
||||
expect(ids).toContain('empty-dir');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -753,17 +753,30 @@ export abstract class ToolCallingLoop<TOptions extends IToolCallingLoopOptions =
|
||||
// log entries are routed to a dedicated child JSONL file.
|
||||
// parentChatSessionId is only set on subagent requests
|
||||
// (see CapturingToken setup in defaultIntentRequestHandler).
|
||||
if (parentChatSessionId && chatSessionId) {
|
||||
const childLabel = debugLogLabel ?? `runSubagent-${agentName}`;
|
||||
if (chatSessionId) {
|
||||
const fileLogger = this._instantiationService.invokeFunction(accessor =>
|
||||
accessor.get(IChatDebugFileLoggerService));
|
||||
fileLogger.startChildSession(
|
||||
chatSessionId, parentChatSessionId, childLabel, parentTraceContext?.spanId);
|
||||
// Also register the invoke_agent span's ID so that hook spans
|
||||
// (whose parentSpanId is this span) are routed to the child session.
|
||||
const invokeSpanId = span.getSpanContext()?.spanId;
|
||||
if (invokeSpanId) {
|
||||
fileLogger.registerSpanSession(invokeSpanId, chatSessionId);
|
||||
|
||||
// Register this session as a child of its parent so that debug
|
||||
// log entries are routed to a dedicated child JSONL file.
|
||||
// parentChatSessionId is only set on subagent requests
|
||||
// (see CapturingToken setup in defaultIntentRequestHandler).
|
||||
if (parentChatSessionId) {
|
||||
const childLabel = debugLogLabel ?? `runSubagent-${agentName}`;
|
||||
fileLogger.startChildSession(
|
||||
chatSessionId, parentChatSessionId, childLabel, parentTraceContext?.spanId);
|
||||
// Also register the invoke_agent span's ID so that hook spans
|
||||
// (whose parentSpanId is this span) are routed to the child session.
|
||||
const invokeSpanId = span.getSpanContext()?.spanId;
|
||||
if (invokeSpanId) {
|
||||
fileLogger.registerSpanSession(invokeSpanId, chatSessionId);
|
||||
}
|
||||
} else {
|
||||
// For top-level agent invocations (not subagents), start a debug
|
||||
// file logging session so entries are flushed to JSONL on disk.
|
||||
// This is idempotent — calling startSession on an already-started
|
||||
// session just promotes it if needed.
|
||||
fileLogger.startSession(chatSessionId).catch(() => { /* best effort */ });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,8 @@ export class OTelChatDebugLogProviderContribution extends Disposable implements
|
||||
this._provideChatDebugLogExport(sessionResource, options, token),
|
||||
resolveChatDebugLogImport: (data, token) =>
|
||||
this._resolveChatDebugLogImport(data, token),
|
||||
provideAvailableDebugSessionResources: (token) =>
|
||||
this._getAvailableDebugSessionResources(token),
|
||||
}));
|
||||
} catch (e) {
|
||||
this._logService.warn(`[OTelDebug] Failed to register debug log provider: ${e}`);
|
||||
@@ -690,6 +692,71 @@ export class OTelChatDebugLogProviderContribution extends Disposable implements
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async _getAvailableDebugSessionResources(
|
||||
token: vscode.CancellationToken,
|
||||
): Promise<{ uri: vscode.Uri; title?: string }[]> {
|
||||
// Sessions are returned sorted by most recent first from listSessionIds().
|
||||
// We only read JSONL tails for a limited batch to keep startup fast.
|
||||
// The home view shows PAGE_SIZE (5) at a time, so reading ~15 covers
|
||||
// the visible page plus a buffer for filtered-out discovery-only sessions.
|
||||
const MAX_TAIL_READS = 15;
|
||||
|
||||
try {
|
||||
const sessionIds = await this._fileLogger.listSessionIds();
|
||||
|
||||
// Read tails only for the first batch (most recent sessions).
|
||||
const toRead = sessionIds.slice(0, MAX_TAIL_READS);
|
||||
const settled = await Promise.allSettled(toRead.map(async (id): Promise<{ uri: vscode.Uri; title?: string } | undefined> => {
|
||||
if (token.isCancellationRequested) {
|
||||
return undefined;
|
||||
}
|
||||
const encoded = Buffer.from(id).toString('base64url');
|
||||
const uri = vscode.Uri.parse(`vscode-chat-session://local/${encoded}`);
|
||||
|
||||
let title: string | undefined;
|
||||
let hasRealEvents = false;
|
||||
try {
|
||||
const entries = await this._fileLogger.readTailEntries(id, 50);
|
||||
const userMsg = entries.find(e => e.type === 'user_message');
|
||||
if (userMsg) {
|
||||
hasRealEvents = true;
|
||||
const content = userMsg.attrs.content as string | undefined;
|
||||
if (content) {
|
||||
title = content.length > 80 ? content.slice(0, 80) + '\u2026' : content;
|
||||
}
|
||||
}
|
||||
if (!hasRealEvents) {
|
||||
hasRealEvents = entries.some(e =>
|
||||
e.type === 'tool_call' || e.type === 'llm_request' ||
|
||||
e.type === 'agent_response' || e.type === 'subagent'
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
if (!hasRealEvents) {
|
||||
return undefined;
|
||||
}
|
||||
if (!title) {
|
||||
const shortId = id.length > 12 ? id.slice(0, 12) + '\u2026' : id;
|
||||
title = `Session ${shortId}`;
|
||||
}
|
||||
return { uri, title };
|
||||
}));
|
||||
|
||||
const results: { uri: vscode.Uri; title?: string }[] = [];
|
||||
for (const entry of settled) {
|
||||
if (entry.status === 'fulfilled' && entry.value) {
|
||||
results.push(entry.value);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
} catch (err) {
|
||||
this._logService.error(`[OTelDebug] Failed to list available sessions: ${err}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
@@ -737,6 +737,17 @@ declare module 'vscode' {
|
||||
data: Uint8Array,
|
||||
token: CancellationToken
|
||||
): ProviderResult<ChatDebugLogImportResult>;
|
||||
|
||||
/**
|
||||
* Return session resource URIs that have debug log data available,
|
||||
* including historical sessions persisted on disk.
|
||||
*
|
||||
* @param token A cancellation token.
|
||||
* @returns Session URIs with available debug data and optional titles.
|
||||
*/
|
||||
provideAvailableDebugSessionResources?(
|
||||
token: CancellationToken
|
||||
): ProviderResult<{ uri: Uri; title?: string }[]>;
|
||||
}
|
||||
|
||||
export namespace chat {
|
||||
|
||||
@@ -139,6 +139,12 @@ export interface IChatDebugFileLoggerService {
|
||||
* Uses a streaming parser to avoid loading the entire file into memory.
|
||||
*/
|
||||
streamEntries(sessionId: string, onEntry: (entry: IDebugLogEntry) => void): Promise<void>;
|
||||
|
||||
/**
|
||||
* List session IDs that have debug log directories on disk.
|
||||
* Returns both active and historical sessions found in the debug-logs/ directory.
|
||||
*/
|
||||
listSessionIds(): Promise<string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,4 +197,5 @@ export class NullChatDebugFileLoggerService implements IChatDebugFileLoggerServi
|
||||
async readEntries(): Promise<IDebugLogEntry[]> { return []; }
|
||||
async readTailEntries(): Promise<IDebugLogEntry[]> { return []; }
|
||||
async streamEntries(): Promise<void> { }
|
||||
async listSessionIds(): Promise<string[]> { return []; }
|
||||
}
|
||||
|
||||
@@ -75,6 +75,13 @@ export class MainThreadChatDebug extends Disposable implements MainThreadChatDeb
|
||||
return uri;
|
||||
}
|
||||
}));
|
||||
|
||||
// Register a lazy fetcher so historical sessions are loaded from the
|
||||
// extension only when the debug panel home page first needs them.
|
||||
this._chatDebugService.registerAvailableSessionsFetcher(async (token) => {
|
||||
const entries = await this._proxy.$getAvailableDebugSessionResources(handle, token);
|
||||
return entries.map(e => ({ uri: URI.revive(e.uri), title: e.title }));
|
||||
});
|
||||
}
|
||||
|
||||
$unregisterChatDebugLogProvider(handle: number): void {
|
||||
|
||||
@@ -1542,6 +1542,7 @@ export interface ExtHostChatDebugShape {
|
||||
$resolveChatDebugLogEvent(handle: number, eventId: string, token: CancellationToken): Promise<IChatDebugResolvedEventContentDto | undefined>;
|
||||
$exportChatDebugLog(handle: number, sessionResource: UriComponents, coreEvents: IChatDebugEventDto[], sessionTitle: string | undefined, token: CancellationToken): Promise<VSBuffer | undefined>;
|
||||
$importChatDebugLog(handle: number, data: VSBuffer, token: CancellationToken): Promise<{ uri: UriComponents; sessionTitle?: string } | undefined>;
|
||||
$getAvailableDebugSessionResources(handle: number, token: CancellationToken): Promise<{ uri: UriComponents; title?: string }[]>;
|
||||
$onCoreDebugEvent(event: IChatDebugEventDto): void;
|
||||
}
|
||||
|
||||
|
||||
@@ -422,6 +422,14 @@ export class ExtHostChatDebug extends Disposable implements ExtHostChatDebugShap
|
||||
return { uri: result.uri, sessionTitle: result.sessionTitle };
|
||||
}
|
||||
|
||||
async $getAvailableDebugSessionResources(_handle: number, token: CancellationToken): Promise<{ uri: UriComponents; title?: string }[]> {
|
||||
if (!this._provider?.provideAvailableDebugSessionResources) {
|
||||
return [];
|
||||
}
|
||||
const result = await this._provider.provideAvailableDebugSessionResources(token);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
for (const store of this._activeProgress.values()) {
|
||||
store.dispose();
|
||||
|
||||
@@ -19,10 +19,13 @@ import { IChatService } from '../../common/chatService/chatService.js';
|
||||
import { AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING } from '../../common/promptSyntax/promptTypes.js';
|
||||
import { getChatSessionType, isUntitledChatSession, LocalChatSessionUri } from '../../common/model/chatUri.js';
|
||||
import { IChatWidgetService } from '../chat.js';
|
||||
import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js';
|
||||
import { IPreferencesService } from '../../../../services/preferences/common/preferences.js';
|
||||
|
||||
const $ = DOM.$;
|
||||
|
||||
const PAGE_SIZE = 5;
|
||||
|
||||
export class ChatDebugHomeView extends Disposable {
|
||||
|
||||
private readonly _onNavigateToSession = this._register(new Emitter<URI>());
|
||||
@@ -32,11 +35,21 @@ export class ChatDebugHomeView extends Disposable {
|
||||
private readonly scrollContent: HTMLElement;
|
||||
private readonly renderDisposables = this._register(new DisposableStore());
|
||||
|
||||
/** Number of sessions currently visible (grows on "Show More"). */
|
||||
private _visibleCount = PAGE_SIZE;
|
||||
|
||||
/** Session resource that the user last navigated to from the home view. */
|
||||
private _lastOpenedSessionResource: URI | undefined;
|
||||
|
||||
/** Tracks the number of known sessions so we can detect new ones. */
|
||||
private _lastKnownSessionCount = 0;
|
||||
|
||||
constructor(
|
||||
parent: HTMLElement,
|
||||
@IChatService private readonly chatService: IChatService,
|
||||
@IChatDebugService private readonly chatDebugService: IChatDebugService,
|
||||
@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,
|
||||
@IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService,
|
||||
@IConfigurationService private readonly configurationService: IConfigurationService,
|
||||
@IPreferencesService private readonly preferencesService: IPreferencesService,
|
||||
) {
|
||||
@@ -49,6 +62,24 @@ export class ChatDebugHomeView extends Disposable {
|
||||
this.render();
|
||||
}
|
||||
}));
|
||||
|
||||
// Re-render when a new session appears so it surfaces at the top.
|
||||
this._register(this.chatDebugService.onDidAddEvent(e => {
|
||||
const currentCount = this.chatDebugService.getSessionResources().length;
|
||||
if (currentCount !== this._lastKnownSessionCount) {
|
||||
this._lastKnownSessionCount = currentCount;
|
||||
if (this.container.style.display !== 'none') {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Re-render when historical sessions are discovered from disk.
|
||||
this._register(this.chatDebugService.onDidChangeAvailableSessionResources(() => {
|
||||
if (this.container.style.display !== 'none') {
|
||||
this.render();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
show(): void {
|
||||
@@ -61,6 +92,22 @@ export class ChatDebugHomeView extends Disposable {
|
||||
}
|
||||
|
||||
render(): void {
|
||||
const isFileLoggingEnabled = this.configurationService.getValue<boolean>(AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING);
|
||||
this._lastKnownSessionCount = this.chatDebugService.getSessionResources().length;
|
||||
|
||||
const sessionResources = isFileLoggingEnabled
|
||||
? this._getFilteredSessionResources(this.chatDebugService.getAvailableSessionResources())
|
||||
: [];
|
||||
this._renderWithSessions(sessionResources);
|
||||
}
|
||||
|
||||
private _getFilteredSessionResources(resources: readonly URI[]): URI[] {
|
||||
const cliSessionTypes = new Set(['copilotcli', 'claude-code']);
|
||||
return [...resources]
|
||||
.filter(r => !cliSessionTypes.has(getChatSessionType(r)) || !isUntitledChatSession(r));
|
||||
}
|
||||
|
||||
private _renderWithSessions(sessionResources: URI[]): void {
|
||||
DOM.clearNode(this.scrollContent);
|
||||
this.renderDisposables.clear();
|
||||
|
||||
@@ -85,24 +132,19 @@ export class ChatDebugHomeView extends Disposable {
|
||||
const activeWidget = this.chatWidgetService.lastFocusedWidget;
|
||||
const activeSessionResource = activeWidget?.viewModel?.sessionResource;
|
||||
|
||||
// List sessions that have debug event data.
|
||||
// Use the debug service as the source of truth — it includes sessions
|
||||
// whose chat models may have been archived (e.g. when a new chat was started).
|
||||
const cliSessionTypes = new Set(['copilotcli', 'claude-code']);
|
||||
const sessionResources = [...this.chatDebugService.getSessionResources()].reverse()
|
||||
// Hide untitled bootstrap sessions for CLI session types (e.g. copilotcli, claude-code).
|
||||
// These are transient sessions created during async session setup that only contain
|
||||
// a single "Load Hooks" event and would confuse users.
|
||||
.filter(r => !cliSessionTypes.has(getChatSessionType(r)) || !isUntitledChatSession(r));
|
||||
|
||||
// Sort: active session first
|
||||
if (activeSessionResource) {
|
||||
const activeIndex = sessionResources.findIndex(r => r.toString() === activeSessionResource.toString());
|
||||
if (activeIndex > 0) {
|
||||
sessionResources.splice(activeIndex, 1);
|
||||
sessionResources.unshift(activeSessionResource);
|
||||
// Bubble active sessions to top
|
||||
const bubbleToTop = (resource: URI | undefined) => {
|
||||
if (!resource) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const idx = sessionResources.findIndex(r => r.toString() === resource.toString());
|
||||
if (idx > 0) {
|
||||
sessionResources.splice(idx, 1);
|
||||
sessionResources.unshift(resource);
|
||||
}
|
||||
};
|
||||
bubbleToTop(this._lastOpenedSessionResource);
|
||||
bubbleToTop(activeSessionResource);
|
||||
|
||||
DOM.append(this.scrollContent, $('p.chat-debug-home-subtitle', undefined,
|
||||
sessionResources.length > 0
|
||||
@@ -111,22 +153,29 @@ export class ChatDebugHomeView extends Disposable {
|
||||
));
|
||||
|
||||
if (sessionResources.length > 0) {
|
||||
const visibleSessions = sessionResources.slice(0, this._visibleCount);
|
||||
|
||||
const sessionList = DOM.append(this.scrollContent, $('.chat-debug-home-session-list'));
|
||||
sessionList.setAttribute('role', 'list');
|
||||
sessionList.setAttribute('aria-label', localize('chatDebug.sessionList', "Chat sessions"));
|
||||
|
||||
const items: HTMLButtonElement[] = [];
|
||||
|
||||
for (const sessionResource of sessionResources) {
|
||||
const rawTitle = this.chatService.getSessionTitle(sessionResource);
|
||||
for (const sessionResource of visibleSessions) {
|
||||
// Resolve title: agent sessions model (same as sidebar) → chat service → historical from JSONL → fallback
|
||||
const agentSession = this.agentSessionsService.model.getSession(sessionResource);
|
||||
const rawTitle = agentSession?.label ?? this.chatService.getSessionTitle(sessionResource);
|
||||
const importedTitle = this.chatDebugService.getImportedSessionTitle(sessionResource);
|
||||
const historicalTitle = this.chatDebugService.getHistoricalSessionTitle(sessionResource);
|
||||
let sessionTitle: string;
|
||||
if (rawTitle && !isUUID(rawTitle)) {
|
||||
sessionTitle = rawTitle;
|
||||
} else if (LocalChatSessionUri.isLocalSession(sessionResource)) {
|
||||
sessionTitle = localize('chatDebug.newSession', "New Chat");
|
||||
} else if (historicalTitle) {
|
||||
sessionTitle = historicalTitle;
|
||||
} else if (importedTitle) {
|
||||
sessionTitle = localize('chatDebug.importedSession', "Imported: {0}", importedTitle);
|
||||
} else if (LocalChatSessionUri.isLocalSession(sessionResource)) {
|
||||
sessionTitle = localize('chatDebug.newSession', "New Chat");
|
||||
} else if (getChatSessionType(sessionResource) === 'copilotcli') {
|
||||
const pathId = sessionResource.path.replace(/^\//, '').split('-')[0];
|
||||
const shortId = pathId || sessionResource.authority || sessionResource.toString();
|
||||
@@ -161,11 +210,24 @@ export class ChatDebugHomeView extends Disposable {
|
||||
}
|
||||
|
||||
this.renderDisposables.add(DOM.addDisposableListener(item, DOM.EventType.CLICK, () => {
|
||||
this._lastOpenedSessionResource = sessionResource;
|
||||
this._onNavigateToSession.fire(sessionResource);
|
||||
}));
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
// "Show More" button when there are more sessions to display
|
||||
if (sessionResources.length > this._visibleCount) {
|
||||
const remaining = sessionResources.length - this._visibleCount;
|
||||
const showMoreButton = this.renderDisposables.add(new Button(this.scrollContent, { ...defaultButtonStyles, secondary: true }));
|
||||
showMoreButton.element.classList.add('chat-debug-home-show-more');
|
||||
showMoreButton.label = localize('chatDebug.showMore', "Show More ({0})", remaining);
|
||||
this.renderDisposables.add(showMoreButton.onDidClick(() => {
|
||||
this._visibleCount += PAGE_SIZE;
|
||||
this.render();
|
||||
}));
|
||||
}
|
||||
|
||||
// Arrow key navigation between session items
|
||||
this.renderDisposables.add(DOM.addDisposableListener(sessionList, DOM.EventType.KEY_DOWN, (e: KeyboardEvent) => {
|
||||
if (items.length === 0) {
|
||||
|
||||
@@ -100,6 +100,12 @@
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.chat-debug-home-show-more {
|
||||
margin-top: 8px;
|
||||
width: auto;
|
||||
max-width: 400px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
@keyframes chat-debug-shimmer {
|
||||
0% { background-position: 120% 0; }
|
||||
|
||||
@@ -236,6 +236,35 @@ export interface IChatDebugService extends IDisposable {
|
||||
*/
|
||||
getImportedSessionTitle(sessionResource: URI): string | undefined;
|
||||
|
||||
/**
|
||||
* Fired when available session resources change (e.g. historical sessions discovered from disk).
|
||||
*/
|
||||
readonly onDidChangeAvailableSessionResources: Event<void>;
|
||||
|
||||
/**
|
||||
* Store session resources that have debug log data available on disk.
|
||||
* Called by the main thread after the extension reports historical sessions.
|
||||
*/
|
||||
addAvailableSessionResources(resources: readonly { uri: URI; title?: string }[]): void;
|
||||
|
||||
/**
|
||||
* Get all session resources that have debug log data available,
|
||||
* including historical sessions persisted on disk by the provider.
|
||||
* Triggers a lazy fetch from the registered fetcher on first call.
|
||||
*/
|
||||
getAvailableSessionResources(): readonly URI[];
|
||||
|
||||
/**
|
||||
* Register a callback that fetches available session resources from a provider.
|
||||
* Called lazily when `getAvailableSessionResources()` is first invoked.
|
||||
*/
|
||||
registerAvailableSessionsFetcher(fetcher: (token: CancellationToken) => Promise<{ uri: URI; title?: string }[]>): void;
|
||||
|
||||
/**
|
||||
* Get the stored title for a historical session discovered from disk.
|
||||
*/
|
||||
getHistoricalSessionTitle(sessionResource: URI): string | undefined;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -103,6 +103,9 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic
|
||||
private readonly _onDidClearProviderEvents = this._register(new Emitter<URI>());
|
||||
readonly onDidClearProviderEvents: Event<URI> = this._onDidClearProviderEvents.event;
|
||||
|
||||
private readonly _onDidChangeAvailableSessionResources = this._register(new Emitter<void>());
|
||||
readonly onDidChangeAvailableSessionResources: Event<void> = this._onDidChangeAvailableSessionResources.event;
|
||||
|
||||
private readonly _providers = new Set<IChatDebugLogProvider>();
|
||||
private readonly _invocationCts = new ResourceMap<CancellationTokenSource>();
|
||||
|
||||
@@ -112,6 +115,13 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic
|
||||
/** Session URIs created via import. */
|
||||
private readonly _importedSessions = new ResourceMap<boolean>();
|
||||
|
||||
/** Session URIs reported by providers as available on disk (historical sessions). */
|
||||
private readonly _availableSessionResources: URI[] = [];
|
||||
private readonly _availableSessionResourceSet = new Set<string>();
|
||||
|
||||
/** Titles for historical sessions discovered from disk. */
|
||||
private readonly _historicalSessionTitles = new ResourceMap<string>();
|
||||
|
||||
/** Human-readable titles for imported sessions. */
|
||||
private readonly _importedSessionTitles = new ResourceMap<string>();
|
||||
|
||||
@@ -291,6 +301,9 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic
|
||||
this._seenEventIds.clear();
|
||||
this._importedSessions.clear();
|
||||
this._importedSessionTitles.clear();
|
||||
this._availableSessionResources.length = 0;
|
||||
this._availableSessionResourceSet.clear();
|
||||
this._historicalSessionTitles.clear();
|
||||
}
|
||||
|
||||
/** Remove all ancillary state for an evicted session. */
|
||||
@@ -444,6 +457,70 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic
|
||||
return this._importedSessionTitles.get(sessionResource);
|
||||
}
|
||||
|
||||
addAvailableSessionResources(resources: readonly { uri: URI; title?: string }[]): void {
|
||||
let added = false;
|
||||
for (const { uri, title } of resources) {
|
||||
const key = uri.toString();
|
||||
if (!this._availableSessionResourceSet.has(key)) {
|
||||
this._availableSessionResourceSet.add(key);
|
||||
this._availableSessionResources.push(uri);
|
||||
added = true;
|
||||
}
|
||||
if (title) {
|
||||
this._historicalSessionTitles.set(uri, title);
|
||||
}
|
||||
}
|
||||
if (added) {
|
||||
this._onDidChangeAvailableSessionResources.fire();
|
||||
}
|
||||
}
|
||||
|
||||
/** Lazy fetcher for available sessions from the extension. */
|
||||
private _availableSessionsFetcher: ((token: CancellationToken) => Promise<{ uri: URI; title?: string }[]>) | undefined;
|
||||
private _availableSessionsFetchStarted = false;
|
||||
private _availableSessionsRequested = false;
|
||||
|
||||
getAvailableSessionResources(): readonly URI[] {
|
||||
// Trigger lazy fetch when both a fetcher is registered and this getter is called.
|
||||
this._availableSessionsRequested = true;
|
||||
this._tryFetchAvailableSessions();
|
||||
|
||||
const known = new Set(this._sessionOrder.map(u => u.toString()));
|
||||
const result = [...this._sessionOrder];
|
||||
for (const uri of this._availableSessionResources) {
|
||||
if (!known.has(uri.toString())) {
|
||||
known.add(uri.toString());
|
||||
result.push(uri);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
registerAvailableSessionsFetcher(fetcher: (token: CancellationToken) => Promise<{ uri: URI; title?: string }[]>): void {
|
||||
this._availableSessionsFetcher = fetcher;
|
||||
this._availableSessionsFetchStarted = false;
|
||||
// If the UI already requested sessions before the fetcher was registered, fetch now.
|
||||
this._tryFetchAvailableSessions();
|
||||
}
|
||||
|
||||
private _tryFetchAvailableSessions(): void {
|
||||
if (!this._availableSessionsFetcher || !this._availableSessionsRequested || this._availableSessionsFetchStarted) {
|
||||
return;
|
||||
}
|
||||
this._availableSessionsFetchStarted = true;
|
||||
// Fire-and-forget: don't block the caller.
|
||||
const fetcher = this._availableSessionsFetcher;
|
||||
fetcher(CancellationToken.None).then(entries => {
|
||||
if (entries.length > 0) {
|
||||
this.addAvailableSessionResources(entries);
|
||||
}
|
||||
}).catch(onUnexpectedError);
|
||||
}
|
||||
|
||||
getHistoricalSessionTitle(sessionResource: URI): string | undefined {
|
||||
return this._historicalSessionTitles.get(sessionResource);
|
||||
}
|
||||
|
||||
async exportLog(sessionResource: URI): Promise<Uint8Array | undefined> {
|
||||
for (const provider of this._providers) {
|
||||
if (provider.provideChatDebugLogExport) {
|
||||
|
||||
11
src/vscode-dts/vscode.proposed.chatDebug.d.ts
vendored
11
src/vscode-dts/vscode.proposed.chatDebug.d.ts
vendored
@@ -737,6 +737,17 @@ declare module 'vscode' {
|
||||
data: Uint8Array,
|
||||
token: CancellationToken
|
||||
): ProviderResult<ChatDebugLogImportResult>;
|
||||
|
||||
/**
|
||||
* Return session resource URIs that have debug log data available,
|
||||
* including historical sessions persisted on disk.
|
||||
*
|
||||
* @param token A cancellation token.
|
||||
* @returns Session URIs with available debug data and optional titles.
|
||||
*/
|
||||
provideAvailableDebugSessionResources?(
|
||||
token: CancellationToken
|
||||
): ProviderResult<{ uri: Uri; title?: string }[]>;
|
||||
}
|
||||
|
||||
export namespace chat {
|
||||
|
||||
Reference in New Issue
Block a user