Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
parentSessionId: sourceSessionId,
origin: 'vscode',
kind: 'forked',
archived: false,
};
await this.updateMetadataFields(targetSessionId, forkedMetadata);
}
Expand Down Expand Up @@ -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 };
Comment thread
DonJayamanne marked this conversation as resolved.
}

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> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));
Expand All @@ -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 };
}

Expand Down Expand Up @@ -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}`);
Expand All @@ -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));
Expand All @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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--) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand All @@ -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?');

Expand All @@ -1089,7 +1125,7 @@ export function registerCLIChatCommands(
);

if (result === deleteLabel) {
await deleteSessionById(id);
await deleteSessionById(id, { keepWorktree });
}
}
}));
Expand All @@ -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 });
}
}
}));
Expand Down
Loading
Loading