mirror of
https://github.com/microsoft/vscode.git
synced 2026-05-17 05:41:07 +01:00
Add archived state management and worktree sharing for chat sessions (#316561)
* Add archived state management and worktree sharing for chat sessions - Introduced `archived` property in `ChatSessionMetadataFile` to track archived sessions. - Updated `IChatSessionMetadataStore` interface to include methods for setting and getting archived state. - Implemented logic in `ChatSessionMetadataStore` and `MockChatSessionMetadataStore` to handle archived sessions. - Added `getBlockingSiblingSessionsForFolder` utility to identify sessions that share worktrees. - Modified CLI chat session commands to respect archived state during session deletion and worktree cleanup. - Updated tests to cover new functionality and ensure proper behavior of session management. * Add worktree sharing edge-case unit tests Co-authored-by: DonJayamanne <1948812+DonJayamanne@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -105,6 +105,12 @@ export interface ChatSessionMetadataFile {
|
||||
* session or if the session is a child session created from the Agents app.
|
||||
*/
|
||||
parentSessionId?: string;
|
||||
/**
|
||||
* Whether the session is currently archived. Tracked here so worktree-sharing
|
||||
* checks can ignore archived siblings (whose worktrees are reconstructed on
|
||||
* un-archive via {@link IChatSessionWorktreeService.recreateWorktreeOnUnarchive}).
|
||||
*/
|
||||
archived?: boolean;
|
||||
/** Milliseconds since epoch when this metadata was first written. */
|
||||
created?: number;
|
||||
/** Milliseconds since epoch of the last write. Used for top-N trim sort and cross-process merge. */
|
||||
@@ -152,7 +158,19 @@ export interface IChatSessionMetadataStore {
|
||||
setSessionOrigin(sessionId: string): Promise<void>;
|
||||
getSessionOrigin(sessionId: string): Promise<'vscode' | 'other'>;
|
||||
setSessionParentId(sessionId: string, parentSessionId: string): Promise<void>;
|
||||
getSessionParentId(sessionId: string): Promise<string | undefined>;
|
||||
/**
|
||||
* Returns the parent lineage info for a session, distinguishing forked sessions
|
||||
* (created via the fork action) from sub-sessions (spawned by a parent session
|
||||
* to do work on its behalf). Returns `undefined` for top-level sessions with no
|
||||
* stored parent.
|
||||
*/
|
||||
getSessionParentId(sessionId: string): Promise<{ parentSessionId: string; kind: 'forked' | 'sub-session' } | undefined>;
|
||||
/**
|
||||
* Persist the archived state of a session. Called from the chat session item state
|
||||
* change handler so worktree-sharing checks can later ignore archived siblings.
|
||||
*/
|
||||
setSessionArchived(sessionId: string, archived: boolean): Promise<void>;
|
||||
getSessionArchived(sessionId: string): Promise<boolean>;
|
||||
/**
|
||||
* Re-read the shared bulk metadata file from disk and merge into the in-memory cache.
|
||||
* Wired to the chat-sessions UI refresh action so cross-process writes become visible
|
||||
|
||||
+5
@@ -75,4 +75,9 @@ export interface IChatSessionWorkspaceFolderService {
|
||||
clearWorkspaceChanges(folderUri: vscode.Uri): string[];
|
||||
|
||||
hasCachedChanges(sessionId: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Returns the ids of sessions whose tracked workspace folder matches the given URI.
|
||||
*/
|
||||
getAssociatedSessions(folderUri: vscode.Uri): string[];
|
||||
}
|
||||
|
||||
+15
-1
@@ -149,10 +149,24 @@ export class MockChatSessionMetadataStore implements IChatSessionMetadataStore {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
getSessionParentId(_sessionId: string): Promise<string | undefined> {
|
||||
getSessionParentId(_sessionId: string): Promise<{ parentSessionId: string; kind: 'forked' | 'sub-session' } | undefined> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
private readonly _archived = new Set<string>();
|
||||
|
||||
async setSessionArchived(sessionId: string, archived: boolean): Promise<void> {
|
||||
if (archived) {
|
||||
this._archived.add(sessionId);
|
||||
} else {
|
||||
this._archived.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
async getSessionArchived(sessionId: string): Promise<boolean> {
|
||||
return this._archived.has(sessionId);
|
||||
}
|
||||
|
||||
getSessionIdsForFolder(folder: vscode.Uri): string[] {
|
||||
const folderPath = folder.fsPath;
|
||||
const sessionIds: string[] = [];
|
||||
|
||||
+16
-2
@@ -393,6 +393,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
|
||||
parentSessionId: sourceSessionId,
|
||||
origin: 'vscode',
|
||||
kind: 'forked',
|
||||
archived: false,
|
||||
};
|
||||
await this.updateMetadataFields(targetSessionId, forkedMetadata);
|
||||
}
|
||||
@@ -423,9 +424,22 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
|
||||
await this.updateMetadataFields(sessionId, { parentSessionId, kind: 'sub-session' });
|
||||
}
|
||||
|
||||
public async getSessionParentId(sessionId: string): Promise<string | undefined> {
|
||||
public async getSessionParentId(sessionId: string): Promise<{ parentSessionId: string; kind: 'forked' | 'sub-session' } | undefined> {
|
||||
const metadata = await this.getSessionMetadata(sessionId, false);
|
||||
return metadata?.parentSessionId;
|
||||
if (!metadata?.parentSessionId || !metadata.kind) {
|
||||
return undefined;
|
||||
}
|
||||
return { parentSessionId: metadata.parentSessionId, kind: metadata.kind };
|
||||
}
|
||||
|
||||
public async setSessionArchived(sessionId: string, archived: boolean): Promise<void> {
|
||||
await this._ready;
|
||||
await this.updateMetadataFields(sessionId, { archived });
|
||||
}
|
||||
|
||||
public async getSessionArchived(sessionId: string): Promise<boolean> {
|
||||
const metadata = await this.getSessionMetadata(sessionId, false);
|
||||
return metadata?.archived === true;
|
||||
}
|
||||
|
||||
private async getSessionMetadata(sessionId: string, createMetadataFileIfNotFound = true): Promise<ChatSessionMetadataFile | undefined> {
|
||||
|
||||
@@ -73,6 +73,7 @@ import { ClaudeCustomizationProvider } from './claudeCustomizationProvider';
|
||||
import { CopilotCLIChatSessionInitializer, ICopilotCLIChatSessionInitializer } from '../copilotcli/vscode-node/copilotCLIChatSessionInitializer';
|
||||
import { CopilotCLIChatSessionContentProvider, CopilotCLIChatSessionParticipant, registerCLIChatCommands } from './copilotCLIChatSessions';
|
||||
import { CopilotCLIChatSessionContentProvider as CopilotCLIChatSessionContentProviderV1, CopilotCLIChatSessionItemProvider as CopilotCLIChatSessionItemProviderV1, CopilotCLIChatSessionParticipant as CopilotCLIChatSessionParticipantV1, registerCLIChatCommands as registerCLIChatCommandsV1 } from './copilotCLIChatSessionsContribution';
|
||||
import { getBlockingSiblingSessionsForFolder } from './worktreeSharing';
|
||||
import { CopilotCLICustomizationProvider } from '../copilotcli/vscode-node/copilotCLICustomizationProvider';
|
||||
import { CopilotCLITerminalIntegration, ICopilotCLITerminalIntegration } from './copilotCLITerminalIntegration';
|
||||
import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider';
|
||||
@@ -231,6 +232,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib
|
||||
const copilotModels = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLIModels));
|
||||
const copilotCLIFolderMruService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatFolderMruService));
|
||||
const pullRequestCreationService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IPullRequestCreationService));
|
||||
const sessionMetadata = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatSessionMetadataStore));
|
||||
|
||||
this._register(copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLISessionTracker)));
|
||||
this._register(copilotcliAgentInstaService.createInstance(CopilotCLIContrib));
|
||||
@@ -241,10 +243,9 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib
|
||||
this._register(vscode.chat.registerChatSessionContentProvider(this.copilotcliSessionType, copilotcliChatSessionContentProvider, copilotcliParticipant));
|
||||
const copilotcliCustomizationProvider = this._register(copilotcliAgentInstaService.createInstance(CopilotCLICustomizationProvider));
|
||||
this._register(vscode.chat.registerChatSessionCustomizationProvider(this.copilotcliSessionType, CopilotCLICustomizationProvider.metadata, copilotcliCustomizationProvider));
|
||||
this._register(registerCLIChatCommands(copilotCLISessionService, copilotCLIWorktreeManagerService, copilotCLIWorktreeCheckpointService, gitService, gitCommitMessageService, copilotCLIWorkspaceFolderSessions, copilotcliChatSessionContentProvider, folderRepositoryManager, copilotCLIFolderMruService, nativeEnvService, fileSystemService, sessionTracker, terminalIntegration, pullRequestCreationService, logService));
|
||||
this._register(registerCLIChatCommands(copilotCLISessionService, copilotCLIWorktreeManagerService, copilotCLIWorktreeCheckpointService, gitService, gitCommitMessageService, copilotCLIWorkspaceFolderSessions, copilotcliChatSessionContentProvider, folderRepositoryManager, copilotCLIFolderMruService, nativeEnvService, fileSystemService, sessionTracker, terminalIntegration, pullRequestCreationService, sessionMetadata, logService));
|
||||
// #endregion
|
||||
|
||||
const sessionMetadata = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatSessionMetadataStore));
|
||||
return { sessionMetadata };
|
||||
}
|
||||
|
||||
@@ -301,13 +302,32 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib
|
||||
const copilotCLISessionService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLISessionService));
|
||||
const copilotCLIWorktreeManagerService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatSessionWorktreeService));
|
||||
const copilotCLIWorktreeCheckpointService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatSessionWorktreeCheckpointService));
|
||||
const copilotCLIWorkspaceFolderSessions = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatSessionWorkspaceFolderService));
|
||||
const copilotCLIMetadataStore = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatSessionMetadataStore));
|
||||
|
||||
// Handle worktree cleanup/recreation when archive state changes
|
||||
const onDidChangeChatSessionItemState = (providerRegistration as { onDidChangeChatSessionItemState?: vscode.Event<vscode.ChatSessionItem> }).onDidChangeChatSessionItemState;
|
||||
if (onDidChangeChatSessionItemState) {
|
||||
this._register(onDidChangeChatSessionItemState(async (item) => {
|
||||
const sessionId = SessionIdForCLI.parse(item.resource);
|
||||
// Persist archived state first so worktree-sharing checks (delete/archive)
|
||||
// can ignore archived siblings — their worktrees are reconstructed on
|
||||
// un-archive via `recreateWorktreeOnUnarchive`.
|
||||
try {
|
||||
await copilotCLIMetadataStore.setSessionArchived(sessionId, !!item.archived);
|
||||
} catch (error) {
|
||||
logService.error(`[CopilotCLI] Failed to persist archived state for session ${sessionId}:`, error);
|
||||
}
|
||||
if (item.archived) {
|
||||
// Skip worktree cleanup if other live sessions still depend on this worktree.
|
||||
const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(sessionId);
|
||||
if (worktreePath) {
|
||||
const siblings = await getBlockingSiblingSessionsForFolder(worktreePath, sessionId, copilotCLIMetadataStore, copilotCLIWorkspaceFolderSessions);
|
||||
if (siblings.length > 0) {
|
||||
logService.trace(`[CopilotCLI] Skipping worktree cleanup for archived session ${sessionId}: ${siblings.length} other session(s) still use the worktree`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await copilotCLIWorktreeManagerService.cleanupWorktreeOnArchive(sessionId);
|
||||
logService.trace(`[CopilotCLI] Worktree cleanup for session ${sessionId}: ${result.cleaned ? 'cleaned' : result.reason}`);
|
||||
@@ -328,7 +348,6 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib
|
||||
}));
|
||||
}
|
||||
|
||||
const copilotCLIWorkspaceFolderSessions = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatSessionWorkspaceFolderService));
|
||||
const folderRepositoryManager = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IFolderRepositoryManager));
|
||||
const nativeEnvService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(INativeEnvService));
|
||||
const fileSystemService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IFileSystemService));
|
||||
@@ -345,7 +364,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib
|
||||
this._register(vscode.chat.registerChatSessionContentProvider(this.copilotcliSessionType, copilotcliChatSessionContentProvider, copilotcliParticipant));
|
||||
const copilotcliCustomizationProvider = this._register(copilotcliAgentInstaService.createInstance(CopilotCLICustomizationProvider));
|
||||
this._register(vscode.chat.registerChatSessionCustomizationProvider(this.copilotcliSessionType, CopilotCLICustomizationProvider.metadata, copilotcliCustomizationProvider));
|
||||
this._register(registerCLIChatCommandsV1(copilotcliSessionItemProvider, copilotCLISessionService, copilotCLIWorktreeManagerService, copilotCLIWorktreeCheckpointService, gitService, gitCommitMessageService, gitExtensionService, toolsService, copilotCLIWorkspaceFolderSessions, copilotcliChatSessionContentProvider, folderRepositoryManager, copilotFolderMruService, nativeEnvService, fileSystemService, pullRequestCreationService, logService));
|
||||
this._register(registerCLIChatCommandsV1(copilotcliSessionItemProvider, copilotCLISessionService, copilotCLIWorktreeManagerService, copilotCLIWorktreeCheckpointService, gitService, gitCommitMessageService, gitExtensionService, toolsService, copilotCLIWorkspaceFolderSessions, copilotcliChatSessionContentProvider, folderRepositoryManager, copilotFolderMruService, nativeEnvService, fileSystemService, pullRequestCreationService, copilotCLIMetadataStore, logService));
|
||||
// #endregion
|
||||
|
||||
const sessionMetadata = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatSessionMetadataStore));
|
||||
|
||||
+50
-12
@@ -51,6 +51,7 @@ import { ICopilotCLITerminalIntegration, TerminalOpenLocation } from './copilotC
|
||||
import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider';
|
||||
import { UNTRUSTED_FOLDER_MESSAGE } from './folderRepositoryManagerImpl';
|
||||
import { IPullRequestDetectionService } from './pullRequestDetectionService';
|
||||
import { getBlockingSiblingSessionsForFolder } from './worktreeSharing';
|
||||
import { getCopilotCLIModelDetails, persistCopilotCLIResponseModelId } from './copilotCLIModelDetails';
|
||||
import { getSelectedSessionOptions, ISessionOptionGroupBuilder, OPEN_REPOSITORY_COMMAND_ID, toRepositoryOptionItem, toWorkspaceFolderOptionItem } from './sessionOptionGroupBuilder';
|
||||
import { ISessionRequestLifecycle } from './sessionRequestLifecycle';
|
||||
@@ -259,7 +260,24 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
|
||||
if (controller.onDidChangeChatSessionItemState) {
|
||||
this._register(controller.onDidChangeChatSessionItemState(async (item) => {
|
||||
const sessionId = SessionIdForCLI.parse(item.resource);
|
||||
// Persist archived state first so worktree-sharing checks (delete/archive)
|
||||
// can ignore archived siblings — their worktrees are reconstructed on
|
||||
// un-archive via `recreateWorktreeOnUnarchive`.
|
||||
try {
|
||||
await this._metadataStore.setSessionArchived(sessionId, !!item.archived);
|
||||
} catch (error) {
|
||||
this.logService.error(`[CopilotCLI] Failed to persist archived state for session ${sessionId}:`, error);
|
||||
}
|
||||
if (item.archived) {
|
||||
// Skip worktree cleanup if other live sessions still depend on this worktree.
|
||||
const worktreePath = await this.copilotCLIWorktreeManagerService.getWorktreePath(sessionId);
|
||||
if (worktreePath) {
|
||||
const siblings = await getBlockingSiblingSessionsForFolder(worktreePath, sessionId, this._metadataStore, this._workspaceFolderService);
|
||||
if (siblings.length > 0) {
|
||||
this.logService.trace(`[CopilotCLI] Skipping worktree cleanup for archived session ${sessionId}: ${siblings.length} other session(s) still use the worktree`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await this.copilotCLIWorktreeManagerService.cleanupWorktreeOnArchive(sessionId);
|
||||
this.logService.trace(`[CopilotCLI] Worktree cleanup for session ${sessionId}: ${result.cleaned ? 'cleaned' : result.reason}`);
|
||||
@@ -479,7 +497,8 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
|
||||
workingDirectory: vscode.Uri | undefined,
|
||||
): Promise<{ readonly [key: string]: unknown }> {
|
||||
if (worktreeProperties) {
|
||||
const sessionParentId = await this._metadataStore.getSessionParentId(sessionId);
|
||||
const parentInfo = await this._metadataStore.getSessionParentId(sessionId);
|
||||
const sessionParentId = parentInfo?.kind === 'sub-session' ? parentInfo.parentSessionId : undefined;
|
||||
|
||||
return {
|
||||
sessionParentId,
|
||||
@@ -531,11 +550,12 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
|
||||
} satisfies { readonly [key: string]: unknown };
|
||||
}
|
||||
|
||||
const [sessionParentId, sessionRequestDetails, repositoryProperties] = await Promise.all([
|
||||
const [parentInfo, sessionRequestDetails, repositoryProperties] = await Promise.all([
|
||||
this._metadataStore.getSessionParentId(sessionId),
|
||||
this._metadataStore.getRequestDetails(sessionId),
|
||||
this._metadataStore.getRepositoryProperties(sessionId)
|
||||
]);
|
||||
const sessionParentId = parentInfo?.kind === 'sub-session' ? parentInfo.parentSessionId : undefined;
|
||||
|
||||
let lastCheckpointRef: string | undefined;
|
||||
for (let i = sessionRequestDetails.length - 1; i >= 0; i--) {
|
||||
@@ -1038,18 +1058,19 @@ export function registerCLIChatCommands(
|
||||
sessionTracker: ICopilotCLISessionTracker,
|
||||
terminalIntegration: ICopilotCLITerminalIntegration,
|
||||
pullRequestCreationService: IPullRequestCreationService,
|
||||
metadataStore: IChatSessionMetadataStore,
|
||||
logService: ILogService
|
||||
): IDisposable {
|
||||
const disposableStore = new DisposableStore();
|
||||
|
||||
async function deleteSessionById(id: string): Promise<void> {
|
||||
const worktree = await copilotCLIWorktreeManagerService.getWorktreeProperties(id);
|
||||
const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(id);
|
||||
async function deleteSessionById(sessionId: string, options?: { keepWorktree?: boolean }): Promise<void> {
|
||||
const worktree = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
|
||||
const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(sessionId);
|
||||
|
||||
await copilotCLISessionService.deleteSession(id);
|
||||
await copilotCliWorkspaceSession.deleteTrackedWorkspaceFolder(id);
|
||||
await copilotCLISessionService.deleteSession(sessionId);
|
||||
await copilotCliWorkspaceSession.deleteTrackedWorkspaceFolder(sessionId);
|
||||
|
||||
if (worktreePath) {
|
||||
if (worktreePath && !options?.keepWorktree) {
|
||||
const worktreeExists = await fileSystemService.stat(worktreePath).then(() => true, () => false);
|
||||
if (worktreeExists) {
|
||||
try {
|
||||
@@ -1064,7 +1085,21 @@ export function registerCLIChatCommands(
|
||||
}
|
||||
}
|
||||
|
||||
await contentProvider.refreshSession({ reason: 'delete', sessionId: id });
|
||||
await contentProvider.refreshSession({ reason: 'delete', sessionId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the worktree at `worktreePath` is still in use by another
|
||||
* non-archived, non-sub-session sibling. Used to gate worktree deletion.
|
||||
*/
|
||||
async function shouldKeepWorktreeForOtherSessions(sessionId: string, worktreePath: vscode.Uri): Promise<boolean> {
|
||||
const siblings = await getBlockingSiblingSessionsForFolder(
|
||||
worktreePath,
|
||||
sessionId,
|
||||
metadataStore,
|
||||
copilotCliWorkspaceSession,
|
||||
);
|
||||
return siblings.length > 0;
|
||||
}
|
||||
|
||||
// Terminal integration setup: resolve session dirs for terminal links.
|
||||
@@ -1076,8 +1111,9 @@ export function registerCLIChatCommands(
|
||||
if (sessionItem?.resource) {
|
||||
const id = SessionIdForCLI.parse(sessionItem.resource);
|
||||
const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(id);
|
||||
const keepWorktree = !!worktreePath && await shouldKeepWorktreeForOtherSessions(id, worktreePath);
|
||||
|
||||
const confirmMessage = worktreePath
|
||||
const confirmMessage = (worktreePath && !keepWorktree)
|
||||
? l10n.t('Are you sure you want to delete the session and its associated worktree?')
|
||||
: l10n.t('Are you sure you want to delete the session?');
|
||||
|
||||
@@ -1089,7 +1125,7 @@ export function registerCLIChatCommands(
|
||||
);
|
||||
|
||||
if (result === deleteLabel) {
|
||||
await deleteSessionById(id);
|
||||
await deleteSessionById(id, { keepWorktree });
|
||||
}
|
||||
}
|
||||
}));
|
||||
@@ -1116,7 +1152,9 @@ export function registerCLIChatCommands(
|
||||
for (const sessionItem of sessionItems) {
|
||||
if (sessionItem.resource) {
|
||||
const id = SessionIdForCLI.parse(sessionItem.resource);
|
||||
await deleteSessionById(id);
|
||||
const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(id);
|
||||
const keepWorktree = !!worktreePath && await shouldKeepWorktreeForOtherSessions(id, worktreePath);
|
||||
await deleteSessionById(id, { keepWorktree });
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
+23
-6
@@ -58,6 +58,7 @@ import { getCopilotCLIModelDetails, persistCopilotCLIResponseModelId } from './c
|
||||
import { ICopilotCLITerminalIntegration, TerminalOpenLocation } from './copilotCLITerminalIntegration';
|
||||
import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider';
|
||||
import { IPullRequestCreationService } from './pullRequestCreationService';
|
||||
import { getBlockingSiblingSessionsForFolder } from './worktreeSharing';
|
||||
import { convertReferenceToVariable } from '../copilotcli/vscode-node/copilotCLIPromptReferences';
|
||||
import { clearChangesCacheForAffectedSessions } from './chatSessionRepositoryTracker';
|
||||
|
||||
@@ -346,7 +347,10 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
|
||||
|
||||
// Metadata
|
||||
let metadata: { readonly [key: string]: unknown };
|
||||
const sessionParentId = await raceCancellation(this.chatSessionMetadataStore.getSessionParentId(session.id), token);
|
||||
const parentInfo = await raceCancellation(this.chatSessionMetadataStore.getSessionParentId(session.id), token);
|
||||
// Only sub-sessions are surfaced as children of their parent in the UI; forked
|
||||
// sessions keep their lineage internally but appear as top-level items.
|
||||
const sessionParentId = parentInfo?.kind === 'sub-session' ? parentInfo.parentSessionId : undefined;
|
||||
|
||||
if (worktreeProperties) {
|
||||
// Worktree
|
||||
@@ -2139,17 +2143,27 @@ export function registerCLIChatCommands(
|
||||
envService: INativeEnvService,
|
||||
fileSystemService: IFileSystemService,
|
||||
pullRequestCreationService: IPullRequestCreationService,
|
||||
metadataStore: IChatSessionMetadataStore,
|
||||
logService: ILogService
|
||||
): IDisposable {
|
||||
const disposableStore = new DisposableStore();
|
||||
async function deleteSessionById(sessionId: string): Promise<void> {
|
||||
async function shouldKeepWorktreeForOtherSessions(sessionId: string, worktreePath: vscode.Uri): Promise<boolean> {
|
||||
const siblings = await getBlockingSiblingSessionsForFolder(
|
||||
worktreePath,
|
||||
sessionId,
|
||||
metadataStore,
|
||||
copilotCliWorkspaceSession,
|
||||
);
|
||||
return siblings.length > 0;
|
||||
}
|
||||
async function deleteSessionById(sessionId: string, options?: { keepWorktree?: boolean }): Promise<void> {
|
||||
const worktree = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
|
||||
const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(sessionId);
|
||||
|
||||
await copilotCLISessionService.deleteSession(sessionId);
|
||||
await copilotCliWorkspaceSession.deleteTrackedWorkspaceFolder(sessionId);
|
||||
|
||||
if (worktreePath) {
|
||||
if (worktreePath && !options?.keepWorktree) {
|
||||
const worktreeExists = await fileSystemService.stat(worktreePath).then(() => true, () => false);
|
||||
if (worktreeExists) {
|
||||
try {
|
||||
@@ -2169,8 +2183,9 @@ export function registerCLIChatCommands(
|
||||
const id = SessionIdForCLI.parse(sessionItem.resource);
|
||||
const sessionId = copilotcliSessionItemProvider.untitledSessionIdMapping.get(id) ?? id;
|
||||
const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(sessionId);
|
||||
const keepWorktree = !!worktreePath && await shouldKeepWorktreeForOtherSessions(sessionId, worktreePath);
|
||||
|
||||
const confirmMessage = worktreePath
|
||||
const confirmMessage = (worktreePath && !keepWorktree)
|
||||
? l10n.t('Are you sure you want to delete the session and its associated worktree?')
|
||||
: l10n.t('Are you sure you want to delete the session?');
|
||||
|
||||
@@ -2182,7 +2197,7 @@ export function registerCLIChatCommands(
|
||||
);
|
||||
|
||||
if (result === deleteLabel) {
|
||||
await deleteSessionById(sessionId);
|
||||
await deleteSessionById(sessionId, { keepWorktree });
|
||||
copilotcliSessionItemProvider.notifySessionsChange();
|
||||
}
|
||||
}
|
||||
@@ -2211,7 +2226,9 @@ export function registerCLIChatCommands(
|
||||
if (sessionItem.resource) {
|
||||
const id = SessionIdForCLI.parse(sessionItem.resource);
|
||||
const sessionId = copilotcliSessionItemProvider.untitledSessionIdMapping.get(id) ?? id;
|
||||
await deleteSessionById(sessionId);
|
||||
const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(sessionId);
|
||||
const keepWorktree = !!worktreePath && await shouldKeepWorktreeForOtherSessions(sessionId, worktreePath);
|
||||
await deleteSessionById(sessionId, { keepWorktree });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -170,7 +170,7 @@ function createProvider() {
|
||||
const metadataStore = new class extends mock<IChatSessionMetadataStore>() {
|
||||
override getRequestDetails = vi.fn(async () => []);
|
||||
override getRepositoryProperties = vi.fn(async () => undefined);
|
||||
override getSessionParentId = vi.fn(async () => undefined);
|
||||
override getSessionParentId = vi.fn<IChatSessionMetadataStore['getSessionParentId']>(async () => undefined);
|
||||
};
|
||||
const gitService = new TestGitService();
|
||||
const folderRepositoryManager = new TestFolderRepositoryManager();
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Uri } from 'vscode';
|
||||
import { mock } from '../../../../util/common/test/simpleMock';
|
||||
import { IChatSessionMetadataStore } from '../../common/chatSessionMetadataStore';
|
||||
import { IChatSessionWorkspaceFolderService } from '../../common/chatSessionWorkspaceFolderService';
|
||||
import { getBlockingSiblingSessionsForFolder } from '../worktreeSharing';
|
||||
|
||||
class TestMetadataStore extends mock<IChatSessionMetadataStore>() {
|
||||
sessionIdsForFolder: string[] = [];
|
||||
parentBySessionId = new Map<string, Awaited<ReturnType<IChatSessionMetadataStore['getSessionParentId']>>>();
|
||||
archivedBySessionId = new Map<string, boolean>();
|
||||
|
||||
override getSessionIdsForFolder = vi.fn(() => this.sessionIdsForFolder);
|
||||
override getSessionParentId = vi.fn(async (sessionId: string) => this.parentBySessionId.get(sessionId));
|
||||
override getSessionArchived = vi.fn(async (sessionId: string) => this.archivedBySessionId.get(sessionId) ?? false);
|
||||
}
|
||||
|
||||
class TestWorkspaceFolderService extends mock<IChatSessionWorkspaceFolderService>() {
|
||||
associatedSessions: string[] = [];
|
||||
override getAssociatedSessions = vi.fn(() => this.associatedSessions);
|
||||
}
|
||||
|
||||
describe('getBlockingSiblingSessionsForFolder', () => {
|
||||
const folder = Uri.file('/workspace/repo');
|
||||
let metadataStore: TestMetadataStore;
|
||||
let workspaceFolderService: TestWorkspaceFolderService;
|
||||
|
||||
beforeEach(() => {
|
||||
metadataStore = new TestMetadataStore();
|
||||
workspaceFolderService = new TestWorkspaceFolderService();
|
||||
});
|
||||
|
||||
it('returns no blockers when only the excluded session matches', async () => {
|
||||
metadataStore.sessionIdsForFolder = ['session-1'];
|
||||
|
||||
const blockers = await getBlockingSiblingSessionsForFolder(folder, 'session-1', metadataStore, workspaceFolderService);
|
||||
|
||||
expect(blockers).toEqual([]);
|
||||
expect(metadataStore.getSessionParentId).not.toHaveBeenCalled();
|
||||
expect(metadataStore.getSessionArchived).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters out archived and sub-session siblings', async () => {
|
||||
metadataStore.sessionIdsForFolder = ['excluded', 'active', 'archived', 'sub', 'forked'];
|
||||
workspaceFolderService.associatedSessions = ['sub'];
|
||||
metadataStore.archivedBySessionId.set('archived', true);
|
||||
metadataStore.parentBySessionId.set('sub', { parentSessionId: 'parent', kind: 'sub-session' });
|
||||
metadataStore.parentBySessionId.set('forked', { parentSessionId: 'parent', kind: 'forked' });
|
||||
|
||||
const blockers = await getBlockingSiblingSessionsForFolder(folder, 'excluded', metadataStore, workspaceFolderService);
|
||||
|
||||
expect(blockers.sort()).toEqual(['active', 'forked']);
|
||||
});
|
||||
|
||||
it('de-duplicates session ids across metadata and workspace associated sessions', async () => {
|
||||
metadataStore.sessionIdsForFolder = ['excluded', 'shared', 'metadata-only'];
|
||||
workspaceFolderService.associatedSessions = ['shared', 'workspace-only', 'excluded'];
|
||||
|
||||
const blockers = await getBlockingSiblingSessionsForFolder(folder, 'excluded', metadataStore, workspaceFolderService);
|
||||
|
||||
expect(blockers.sort()).toEqual(['metadata-only', 'shared', 'workspace-only']);
|
||||
expect(metadataStore.getSessionArchived).toHaveBeenCalledTimes(3);
|
||||
expect(metadataStore.getSessionParentId).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { IChatSessionMetadataStore } from '../common/chatSessionMetadataStore';
|
||||
import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService';
|
||||
|
||||
/**
|
||||
* Returns the ids of "blocking" sibling sessions that share the same worktree
|
||||
* (or workspace folder) as `excludeSessionId` and would be broken if the
|
||||
* worktree directory were deleted.
|
||||
*
|
||||
* A candidate is treated as blocking iff it is *not* the excluded session, *not*
|
||||
* a `sub-session` (sub-sessions don't independently keep a worktree alive — they
|
||||
* follow their parent), and *not* archived (archived sessions have their
|
||||
* worktree reconstructed via `recreateWorktreeOnUnarchive`, so they don't block
|
||||
* cleanup either).
|
||||
*/
|
||||
export async function getBlockingSiblingSessionsForFolder(
|
||||
folder: vscode.Uri,
|
||||
excludeSessionId: string,
|
||||
metadataStore: IChatSessionMetadataStore,
|
||||
workspaceFolderService: IChatSessionWorkspaceFolderService,
|
||||
): Promise<string[]> {
|
||||
const candidates = new Set<string>([
|
||||
...metadataStore.getSessionIdsForFolder(folder),
|
||||
...workspaceFolderService.getAssociatedSessions(folder),
|
||||
]);
|
||||
candidates.delete(excludeSessionId);
|
||||
|
||||
const results: string[] = [];
|
||||
await Promise.all(Array.from(candidates).map(async id => {
|
||||
const [parent, archived] = await Promise.all([
|
||||
metadataStore.getSessionParentId(id),
|
||||
metadataStore.getSessionArchived(id),
|
||||
]);
|
||||
if (archived) {
|
||||
return;
|
||||
}
|
||||
if (parent?.kind === 'sub-session') {
|
||||
return;
|
||||
}
|
||||
results.push(id);
|
||||
}));
|
||||
return results;
|
||||
}
|
||||
@@ -208,6 +208,7 @@ async function registerChatServices(testingServiceCollection: TestingServiceColl
|
||||
async getWorkspaceChanges() { return undefined; },
|
||||
async hasCachedChanges() { return false; },
|
||||
clearWorkspaceChanges() { return []; },
|
||||
getAssociatedSessions() { return []; },
|
||||
onDidChangeWorkspaceFolderChanges: () => ({ dispose() { } }),
|
||||
} as IChatSessionWorkspaceFolderService);
|
||||
testingServiceCollection.define(IChatSessionWorktreeService, {
|
||||
|
||||
Reference in New Issue
Block a user