Skip to content

Conversation

@zhichli
Copy link
Member

@zhichli zhichli commented Jan 9, 2026

Adds "Export Chat as Zip" action to package chat sessions with repository context for benchmark creation.

What it does

  • Creates zip containing chat.json (session data) and repos/*.json (repo state per workspace folder)
  • Captures git state: remote URL, branch, commits, working tree diffs
  • Diffs stored as unified patches compatible with git apply

Workspace Scenarios

Scenario workspaceType syncStatus Reproducibility
Synced with remote remote-git synced ✅ Clone + checkout
Unpushed commits remote-git unpushed TBD: git bundle
Unpublished branch remote-git unpublished TBD: git bundle
Local-only git local-git local-only TBD: folder bundle
No git plain-folder no-git TBD: folder bundle

This PR implements the synced scenario. Other scenarios require git bundle support (future work).

Sample repo.json

{
  "workspaceType": "remote-git",
  "syncStatus": "synced",
  "remoteUrl": "https://github.com/example-org/my-project.git",
  "remoteVendor": "github",
  "remoteTrackingBranch": "origin/feature/chat-export",
  "remoteBaseBranch": "origin/main",
  "remoteHeadCommit": "abc123def456",
  "localBranch": "feature/chat-export",
  "localHeadCommit": "abc123def456",
  "diffs": [
    {
      "relativePath": "src/utils/helper.ts",
      "changeType": "modified",
      "status": "M",
      "unifiedDiff": "--- a/src/utils/helper.ts\n+++ b/src/utils/helper.ts\n@@ -10,6 +10,7 @@\n function helper() {\n+  console.log('debug');\n   return true;\n }"
    }
  ]
}

Copilot AI review requested due to automatic review settings January 9, 2026 19:36
@zhichli zhichli self-assigned this Jan 9, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new feature for internal users to export chat sessions as zip files, including repository information when available. The export captures the chat conversation data along with contextual information about the Git repository state at the time the chat session was started.

Key changes:

  • Adds a new ChatRepoInfoContribution that captures repository context (remote URL, commit hash, changed files) when a chat request is first submitted
  • Implements an "Export Chat as Zip" action that creates a zip file containing chat data and optional repository information
  • Extends the chat model interfaces to store and serialize repository data alongside chat sessions
  • Adds native service support for creating zip files from in-memory file content

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
src/vs/workbench/contrib/chat/common/model/chatModel.ts Adds IExportableRepoData and IExportableRepoDiff interfaces; extends ChatModel to store and serialize repository data
src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts New contribution that captures git repository information on first chat request
src/vs/workbench/contrib/chat/browser/chat.contribution.ts Registers the ChatRepoInfoContribution
src/vs/workbench/contrib/chat/electron-browser/actions/chatExportZip.ts Implements the export chat as zip action with repository data
src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts Registers the export zip action
src/vs/platform/native/common/native.ts Adds createZipFile method to native service interface
src/vs/platform/native/electron-main/nativeHostMainService.ts Implements createZipFile using existing zip utility
src/vs/workbench/test/electron-browser/workbenchTestServices.ts Updates test service mock with createZipFile stub
src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts Updates MockChatModel with repoData property and setRepoData method
Comments suppressed due to low confidence (2)

src/vs/workbench/contrib/chat/common/model/chatModel.ts:1339

  • The repoData field was added to ISerializableChatData3 without a comment explaining what it is or noting backward compatibility concerns, while the inputState field above had such a comment (which was removed in this change). Consider adding a comment like "/** Repository context captured when the session started (optional, fully backwards compatible) */" to maintain consistency and help future maintainers understand the field's purpose and compatibility guarantees.
	repoData?: IExportableRepoData;

src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts:123

  • The ChatRepoInfoContribution lacks test coverage. Since this contribution performs important operations like reading git configuration, capturing repository state, and handling various edge cases (missing git history, worktrees, etc.), consider adding tests to verify the repo data capture logic and error handling behavior.

	static readonly ID = 'workbench.contrib.chatRepoInfo';

	constructor(
		@IChatService private readonly chatService: IChatService,
		@ISCMService private readonly scmService: ISCMService,
		@IFileService private readonly fileService: IFileService,
		@ILogService private readonly logService: ILogService,
	) {
		super();

		this._register(this.chatService.onDidSubmitRequest(async ({ chatSessionResource }) => {
			const model = this.chatService.getSession(chatSessionResource);
			if (!model || model.repoData) {
				return;
			}
			await this.captureAndSetRepoData(model);
		}));
	}

	private async captureAndSetRepoData(model: IChatModel): Promise<void> {
		try {
			const repoData = await this.captureRepoInfo();
			if (repoData) {
				model.setRepoData(repoData);
				if (!repoData.headCommitHash) {
					this.logService.warn('[ChatRepoInfo] Captured repo data without commit hash - git history may not be ready');
				}
			} else {
				this.logService.debug('[ChatRepoInfo] No SCM repository available for chat session');
			}
		} catch (error) {
			this.logService.warn('[ChatRepoInfo] Failed to capture repo info:', error);
		}
	}

	private async captureRepoInfo(): Promise<IExportableRepoData | undefined> {
		// Get the first SCM repository
		const repositories = [...this.scmService.repositories];
		if (repositories.length === 0) {
			return undefined;
		}

		const repository = repositories[0];
		const rootUri = repository.provider.rootUri;
		if (!rootUri) {
			return undefined;
		}

		let remoteUrl: string | undefined;
		try {
			const gitConfigUri = rootUri.with({ path: `${rootUri.path}/.git/config` });
			const exists = await this.fileService.exists(gitConfigUri);
			if (exists) {
				const content = await this.fileService.readFile(gitConfigUri);
				const remotes = getRemotes(content.value.toString());
				remoteUrl = remotes[0];
			}
		} catch (error) {
			this.logService.warn('[ChatRepoInfo] Failed to read git remote URL:', error);
		}

		let branchName: string | undefined;
		let headCommitHash: string | undefined;
		const historyProvider = repository.provider.historyProvider?.get();
		if (historyProvider) {
			const historyItemRef = historyProvider.historyItemRef.get();
			branchName = historyItemRef?.name;
			headCommitHash = historyItemRef?.revision;
		}

		let repoType: 'github' | 'ado' = 'github';
		if (remoteUrl?.includes('dev.azure.com') || remoteUrl?.includes('visualstudio.com')) {
			repoType = 'ado';
		}

		const diffs: IExportableRepoDiff[] = [];
		let changedFileCount = 0;

		for (const group of repository.provider.groups) {
			for (const resource of group.resources) {
				changedFileCount++;
				diffs.push({
					uri: resource.sourceUri.toString(),
					originalUri: resource.multiDiffEditorOriginalUri?.toString() ?? resource.sourceUri.toString(),
					renameUri: undefined,
					status: group.label || group.id,
					diff: undefined
				});
			}
		}

		return {
			remoteUrl,
			repoType,
			branchName,
			headCommitHash,
			changedFileCount,
			diffs
		};
	}
}

@zhichli zhichli force-pushed the zhichli/chatrepoinfo branch from b651569 to 4f6916f Compare January 9, 2026 21:00
@zhichli zhichli requested a review from roblourens January 9, 2026 23:46
@zhichli zhichli added this to the January 2026 milestone Jan 9, 2026
@zhichli zhichli marked this pull request as ready for review January 9, 2026 23:46
@vs-code-engineering
Copy link

📬 CODENOTIFY

The following users are being notified based on files changed in this PR:

@bpasero

Matched files:

  • src/vs/platform/native/common/native.ts
  • src/vs/platform/native/electron-main/nativeHostMainService.ts

@zhichli zhichli requested a review from bpasero January 9, 2026 23:52
@zhichli zhichli changed the title feat-internal: Add export chat as a zip file to include repo info feat-internal: Export Chat as Zip with Repository Context Jan 9, 2026
@zhichli zhichli linked an issue Jan 9, 2026 that may be closed by this pull request
}

try {
await nativeHostService.createZipFile(result.fsPath, files);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's pass around URIs, not string paths

if (model.repoData) {
return;
}
await this.captureAndSetRepoData(model);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like this always captures the repo data for every chat model, every user? But this seems like it coudl be really expensive? Should it only happen when the user requests it?

try {
const repoData = await captureRepoInfo(this.scmService, this.fileService);
if (repoData) {
model.setRepoData(repoData);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like we do this multiple times per ChatModel, and just keep replacing the previous repo data? I don't understand that part.


// Clean up when the model is disposed
disposables.add(model.onDidDispose(() => {
disposables.dispose();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You dispose disposables but it's still in _pendingSessions so it would leak the memory

this._onDidDisposeSession.fire({ sessionResource: [model.sessionResource], reason: 'cleared' });
}));
this._register(this._sessionModels.onDidCreateModel(model => {
this._onDidCreateModel.fire(model);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can expose this._sessionModels.onDidCreateModel rather than create a new event emitter. eg onDidCreateModel = this._sessionModels.onDidCreateModel

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it has to be returned from a getter

Copy link
Member

@bpasero bpasero left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a minor comment, no objections having a zip method in native host service, thats easier than having it anywhere else.

//#region Zip

async createZipFile(windowId: number | undefined, zipPath: string, files: { path: string; contents: string }[]): Promise<void> {
const { zip } = await import('../../../base/node/zip.js');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to lazy import this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Include workspace info in chat session export

4 participants