Skip to content

Commit 6a8889b

Browse files
TylerLeonhardtCopilot
andauthored
Move to InputState instead of metadata on sessions (#311060)
* Move to InputState instead of metadata on sessions Cleans up more old patterns with new patterns. Co-authored-by: Copilot <copilot@github.com> * feedback Co-authored-by: Copilot <copilot@github.com> --------- Co-authored-by: Copilot <copilot@github.com>
1 parent 6579de6 commit 6a8889b

3 files changed

Lines changed: 170 additions & 384 deletions

File tree

extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts

Lines changed: 66 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import { PermissionMode } from '@anthropic-ai/claude-agent-sdk';
76
import * as vscode from 'vscode';
87
import { ChatExtendedRequestHandler } from 'vscode';
98
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
@@ -12,7 +11,7 @@ import { IGitService } from '../../../platform/git/common/gitService';
1211
import { ILogService } from '../../../platform/log/common/logService';
1312
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
1413
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
15-
import { Disposable, IDisposable } from '../../../util/vs/base/common/lifecycle';
14+
import { Disposable } from '../../../util/vs/base/common/lifecycle';
1615
import { URI } from '../../../util/vs/base/common/uri';
1716
import { generateUuid } from '../../../util/vs/base/common/uuid';
1817
import { ClaudeFolderInfo } from '../claude/common/claudeFolderInfo';
@@ -27,7 +26,6 @@ import { IClaudeSlashCommandService } from '../claude/vscode-node/claudeSlashCom
2726
import { IChatFolderMruService } from '../common/folderRepositoryManager';
2827
import { buildChatHistory } from './chatHistoryBuilder';
2928
import { ClaudeSessionOptionBuilder, FOLDER_OPTION_ID, isPermissionMode, PERMISSION_MODE_OPTION_ID } from './claudeSessionOptionBuilder';
30-
import { getSelectedOption } from './sessionOptionGroupBuilder';
3129

3230
// Import the tool permission handlers
3331
import '../claude/vscode-node/toolPermissionHandlers/index';
@@ -103,8 +101,14 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco
103101
}
104102

105103
const modelId = parseClaudeModelId(request.model.id);
106-
const permissionMode = this._controller.getPermissionModeForSession(effectiveSessionId);
107-
const folderInfo = await this._controller.getFolderInfoForSession(effectiveSessionId);
104+
const selectedPermissionId = chatSessionContext.inputState.groups.find(group => group.id === PERMISSION_MODE_OPTION_ID)?.selected?.id;
105+
if (!selectedPermissionId || !isPermissionMode(selectedPermissionId)) {
106+
throw new Error(`Permission mode not set for session ${effectiveSessionId}`);
107+
}
108+
const permissionMode = selectedPermissionId;
109+
const selectedFolderId = chatSessionContext.inputState.groups.find(group => group.id === FOLDER_OPTION_ID)?.selected?.id;
110+
const selectedFolderUri = selectedFolderId ? URI.file(selectedFolderId) : undefined;
111+
const folderInfo = await this._controller.getFolderInfoForSession(effectiveSessionId, selectedFolderUri);
108112

109113
// Commit UI state to session state service before invoking agent manager
110114
this.sessionStateService.setModelIdForSession(effectiveSessionId, modelId);
@@ -163,9 +167,8 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco
163167
* Reads sessions from ~/.claude/projects/<folder-slug>/, where each file name is a session id (GUID).
164168
*
165169
* Owns the input state (getChatSessionInputState) lifecycle: wiring external
166-
* state listeners, persisting selections to metadata, and resolving permission
167-
* mode / folder info for sessions. Group construction is delegated to
168-
* {@link ClaudeSessionOptionBuilder}.
170+
* state listeners and resolving folder info for sessions. Group construction
171+
* is delegated to {@link ClaudeSessionOptionBuilder}.
169172
*/
170173
export class ClaudeChatSessionItemController extends Disposable {
171174
private readonly _controller: vscode.ChatSessionItemController;
@@ -200,16 +203,6 @@ export class ClaudeChatSessionItemController extends Disposable {
200203
);
201204
item.iconPath = new vscode.ThemeIcon('claude');
202205
item.timing = { created: Date.now() };
203-
204-
const permissionModeSelection = getSelectedOption(context.inputState.groups, PERMISSION_MODE_OPTION_ID);
205-
const permissionMode = permissionModeSelection?.id;
206-
const folderSelection = getSelectedOption(context.inputState.groups, FOLDER_OPTION_ID);
207-
const folder = folderSelection?.id ? URI.file(folderSelection.id) : undefined;
208-
209-
item.metadata = {
210-
permissionMode,
211-
cwd: folder,
212-
};
213206
this._inProgressItems.set(newSessionId, item);
214207
return item;
215208
};
@@ -248,7 +241,19 @@ export class ClaudeChatSessionItemController extends Disposable {
248241
const newItem = this._controller.createChatSessionItem(ClaudeSessionUri.forSessionId(result.sessionId), title);
249242
newItem.iconPath = new vscode.ThemeIcon('claude');
250243
newItem.timing = { created: Date.now() };
251-
newItem.metadata = item?.metadata ? { ...item.metadata } : undefined;
244+
245+
// Copy parent session state to the forked session
246+
const parentSessionId = ClaudeSessionUri.getSessionId(sessionResource);
247+
const parentPermission = this._sessionStateService.getPermissionModeForSession(parentSessionId);
248+
const parentFolder = this._sessionStateService.getFolderInfoForSession(parentSessionId);
249+
this._sessionStateService.setPermissionModeForSession(result.sessionId, parentPermission);
250+
if (parentFolder) {
251+
this._sessionStateService.setFolderInfoForSession(result.sessionId, {
252+
...parentFolder,
253+
additionalDirectories: [...(parentFolder.additionalDirectories ?? [])],
254+
});
255+
}
256+
252257
this._controller.items.add(newItem);
253258
return newItem;
254259
};
@@ -273,44 +278,30 @@ export class ClaudeChatSessionItemController extends Disposable {
273278
// #region Input State
274279

275280
private _setupInputState(): void {
276-
const trackedStates: { ref: WeakRef<vscode.ChatSessionInputState>; subscription: IDisposable }[] = [];
281+
const trackedStates: { ref: WeakRef<vscode.ChatSessionInputState> }[] = [];
277282

278283
const sweepStaleEntries = () => {
279284
for (let i = trackedStates.length - 1; i >= 0; i--) {
280285
if (!trackedStates[i].ref.deref()) {
281-
trackedStates[i].subscription.dispose();
282286
trackedStates.splice(i, 1);
283287
}
284288
}
285289
};
286290

287-
// Dispose all subscriptions when the content provider is disposed
288-
this._register({
289-
dispose: () => {
290-
for (const entry of trackedStates) {
291-
entry.subscription.dispose();
292-
}
293-
trackedStates.length = 0;
291+
this._controller.getChatSessionInputState = async (sessionResource, context, token) => {
292+
if (context.previousInputState) {
293+
const state = this._controller.createChatSessionInputState([...context.previousInputState.groups]);
294+
trackedStates.push({ ref: new WeakRef(state) });
295+
return state;
294296
}
295-
});
296297

297-
this._controller.getChatSessionInputState = async (sessionResource, context, token) => {
298298
const isExistingSession = sessionResource && await this._claudeCodeSessionService.getSession(sessionResource, token) !== undefined;
299299

300300
const groups = isExistingSession
301301
? await this._buildExistingSessionGroups(sessionResource)
302-
: await this._optionBuilder.buildNewSessionGroups(context.previousInputState);
302+
: await this._optionBuilder.buildNewSessionGroups();
303303
const state = this._controller.createChatSessionInputState(groups);
304-
305-
const ref = new WeakRef(state);
306-
const subscription = state.onDidChange(() => {
307-
const s = ref.deref();
308-
if (s) {
309-
this._handleInputStateChange(s);
310-
}
311-
});
312-
trackedStates.push({ ref, subscription });
313-
304+
trackedStates.push({ ref: new WeakRef(state) });
314305
return state;
315306
};
316307

@@ -342,11 +333,6 @@ export class ClaudeChatSessionItemController extends Disposable {
342333
if (e.permissionMode === undefined) {
343334
return;
344335
}
345-
const existingMode = this.getMetadata(e.sessionId)?.permissionMode;
346-
if (e.permissionMode === existingMode) {
347-
return;
348-
}
349-
this.setMetadata(e.sessionId, { permissionMode: e.permissionMode });
350336
for (const entry of trackedStates) {
351337
const state = entry.ref.deref();
352338
if (state?.sessionResource) {
@@ -368,23 +354,24 @@ export class ClaudeChatSessionItemController extends Disposable {
368354

369355
private async _buildExistingSessionGroups(sessionResource: vscode.Uri): Promise<vscode.ChatSessionProviderOptionGroup[]> {
370356
const sessionId = ClaudeSessionUri.getSessionId(sessionResource);
371-
const permissionMode = this.getPermissionModeForSession(sessionId);
372-
const workspaceFolders = this._workspaceService.getWorkspaceFolders();
373-
const folderUri = workspaceFolders.length !== 1 ? await this._getDefaultFolderForSession(sessionId) : undefined;
374-
return this._optionBuilder.buildExistingSessionGroups(permissionMode, folderUri);
375-
}
357+
const permissionMode = this._sessionStateService.getPermissionModeForSession(sessionId);
376358

377-
private _handleInputStateChange(state: vscode.ChatSessionInputState): void {
378-
const { permissionMode, folderUri } = this._optionBuilder.getSelections(state.groups);
379-
const sessionId = state.sessionResource ? ClaudeSessionUri.getSessionId(state.sessionResource) : undefined;
380-
if (sessionId) {
381-
if (permissionMode) {
382-
this.setMetadata(sessionId, { permissionMode });
383-
}
384-
if (folderUri) {
385-
this.setMetadata(sessionId, { cwd: folderUri });
359+
const workspaceFolders = this._workspaceService.getWorkspaceFolders();
360+
let folderUri: URI | undefined;
361+
if (workspaceFolders.length !== 1) {
362+
const stateFolder = this._sessionStateService.getFolderInfoForSession(sessionId);
363+
if (stateFolder) {
364+
folderUri = URI.file(stateFolder.cwd);
365+
} else {
366+
const session = await this._claudeCodeSessionService.getSession(sessionResource, CancellationToken.None);
367+
if (session?.cwd) {
368+
folderUri = URI.file(session.cwd);
369+
} else {
370+
folderUri = await this._optionBuilder.getDefaultFolder();
371+
}
386372
}
387373
}
374+
return this._optionBuilder.buildExistingSessionGroups(permissionMode, folderUri);
388375
}
389376

390377
private async _rebuildInputState(state: vscode.ChatSessionInputState): Promise<void> {
@@ -397,26 +384,9 @@ export class ClaudeChatSessionItemController extends Disposable {
397384

398385
// #endregion
399386

400-
// #region Permission Mode & Folder Resolution
387+
// #region Folder Resolution
401388

402-
private async _getDefaultFolderForSession(sessionId: string): Promise<URI | undefined> {
403-
const selected = this.getMetadata(sessionId)?.cwd;
404-
if (selected) {
405-
return selected;
406-
}
407-
408-
const defaultFolder = await this._optionBuilder.getDefaultFolder();
409-
if (defaultFolder) {
410-
this.setMetadata(sessionId, { cwd: defaultFolder });
411-
}
412-
return defaultFolder;
413-
}
414-
415-
getPermissionModeForSession(sessionId: string): PermissionMode {
416-
return this.getMetadata(sessionId)?.permissionMode ?? this._sessionStateService.getPermissionModeForSession(sessionId);
417-
}
418-
419-
async getFolderInfoForSession(sessionId: string): Promise<ClaudeFolderInfo> {
389+
async getFolderInfoForSession(sessionId: string, selectedFolderUri?: URI): Promise<ClaudeFolderInfo> {
420390
const workspaceFolders = this._workspaceService.getWorkspaceFolders();
421391

422392
if (workspaceFolders.length === 1) {
@@ -426,21 +396,21 @@ export class ClaudeChatSessionItemController extends Disposable {
426396
};
427397
}
428398

429-
// Multi-root or empty workspace: use the selected folder
430-
const selectedFolder = this.getMetadata(sessionId)?.cwd;
399+
// Multi-root or empty workspace: resolve selected folder from inputState, sessionStateService, or session file
400+
const folderUri = selectedFolderUri ?? await this._resolveSessionFolder(sessionId);
431401

432402
if (workspaceFolders.length > 1) {
433-
const cwd = selectedFolder?.fsPath ?? workspaceFolders[0].fsPath;
403+
const cwd = folderUri?.fsPath ?? workspaceFolders[0].fsPath;
434404
const additionalDirectories = workspaceFolders
435405
.map(f => f.fsPath)
436406
.filter(p => p !== cwd);
437407
return { cwd, additionalDirectories };
438408
}
439409

440410
// Empty workspace
441-
if (selectedFolder) {
411+
if (folderUri) {
442412
return {
443-
cwd: selectedFolder.fsPath,
413+
cwd: folderUri.fsPath,
444414
additionalDirectories: [],
445415
};
446416
}
@@ -461,45 +431,23 @@ export class ClaudeChatSessionItemController extends Disposable {
461431
};
462432
}
463433

464-
// #endregion
465-
466-
// #region Metadata
467-
468-
setMetadata(sessionId: string, metadata: Partial<{ permissionMode: PermissionMode; cwd?: URI }>): void {
469-
const item = this._controller.items.get(ClaudeSessionUri.forSessionId(sessionId));
470-
if (item) {
471-
item.metadata = {
472-
...item.metadata,
473-
permissionMode: metadata.permissionMode ?? item.metadata?.permissionMode,
474-
cwd: metadata.cwd ?? item.metadata?.cwd,
475-
};
434+
private async _resolveSessionFolder(sessionId: string): Promise<URI | undefined> {
435+
const stateFolder = this._sessionStateService.getFolderInfoForSession(sessionId);
436+
if (stateFolder) {
437+
return URI.file(stateFolder.cwd);
476438
}
477-
}
478439

479-
getMetadata(sessionId: string): { permissionMode?: PermissionMode; cwd?: URI } | undefined {
480-
const candidate = this._controller.items.get(ClaudeSessionUri.forSessionId(sessionId));
481-
if (candidate) {
482-
if (candidate.metadata?.permissionMode !== undefined && !isPermissionMode(candidate.metadata.permissionMode)) {
483-
this._logService.warn(`Invalid permission mode "${candidate.metadata?.permissionMode}" found in metadata for session ${sessionId}. Falling back to default.`);
484-
candidate.metadata = {
485-
permissionMode: 'acceptEdits',
486-
cwd: candidate.metadata?.cwd,
487-
};
488-
}
489-
if (candidate.metadata?.cwd && !(URI.isUri(candidate.metadata.cwd))) {
490-
this._logService.warn(`Invalid cwd "${candidate.metadata.cwd}" found in metadata for session ${sessionId}. Ignoring.`);
491-
candidate.metadata = {
492-
permissionMode: candidate.metadata.permissionMode,
493-
cwd: undefined,
494-
};
495-
}
496-
return {
497-
permissionMode: candidate.metadata?.permissionMode,
498-
cwd: candidate.metadata?.cwd,
499-
};
440+
const sessionResource = ClaudeSessionUri.forSessionId(sessionId);
441+
const session = await this._claudeCodeSessionService.getSession(sessionResource, CancellationToken.None);
442+
if (session?.cwd) {
443+
return URI.file(session.cwd);
500444
}
445+
446+
return this._optionBuilder.getDefaultFolder();
501447
}
502448

449+
// #endregion
450+
503451
updateItemLabel(sessionId: string, label: string): void {
504452
const resource = ClaudeSessionUri.forSessionId(sessionId);
505453
const item = this._controller.items.get(resource);
@@ -575,11 +523,6 @@ export class ClaudeChatSessionItemController extends Disposable {
575523
lastRequestEnded: session.lastRequestEnded,
576524
};
577525
item.iconPath = new vscode.ThemeIcon('claude');
578-
item.metadata = {
579-
// Allow it to be set when opened
580-
permissionMode: undefined,
581-
cwd: session.cwd ? URI.file(session.cwd) : undefined
582-
};
583526
return item;
584527
}
585528

extensions/copilot/src/extension/chatSessions/vscode-node/claudeSessionOptionBuilder.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export class ClaudeSessionOptionBuilder {
4242
private readonly _workspaceService: IWorkspaceService,
4343
) { }
4444

45-
async buildNewSessionGroups(previousInputState: vscode.ChatSessionInputState | undefined): Promise<vscode.ChatSessionProviderOptionGroup[]> {
45+
async buildNewSessionGroups(previousInputState?: vscode.ChatSessionInputState): Promise<vscode.ChatSessionProviderOptionGroup[]> {
4646
const groups: vscode.ChatSessionProviderOptionGroup[] = [];
4747

4848
const folderGroup = await this.buildNewFolderGroup(previousInputState);

0 commit comments

Comments
 (0)