Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as vscode from 'vscode';
7
import { ChatExtendedRequestHandler } from 'vscode';
8
import { PermissionMode } from '@anthropic-ai/claude-agent-sdk';
9
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
10
import { INativeEnvService } from '../../../platform/env/common/envService';
11
import { getGitHubRepoInfoFromContext, IGitService } from '../../../platform/git/common/gitService';
12
import { ILogService } from '../../../platform/log/common/logService';
13
import { IChatEndpoint } from '../../../platform/networking/common/networking';
14
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
15
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
16
import { Emitter, Event } from '../../../util/vs/base/common/event';
17
import { Disposable, DisposableStore } from '../../../util/vs/base/common/lifecycle';
18
import { autorun, derived, IObservable, ISettableObservable, observableFromEvent, observableValue } from '../../../util/vs/base/common/observable';
19
import { basename } from '../../../util/vs/base/common/resources';
20
import { URI } from '../../../util/vs/base/common/uri';
21
import { generateUuid } from '../../../util/vs/base/common/uuid';
22
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
23
import { ClaudeFolderInfo } from '../claude/common/claudeFolderInfo';
24
import { ClaudeSessionUri } from '../claude/common/claudeSessionUri';
25
import { ClaudeAgentManager } from '../claude/node/claudeCodeAgent';
26
import { CLAUDE_REASONING_EFFORT_PROPERTY, formatClaudeModelDetails, IClaudeCodeModels, pickReasoningEffort } from '../claude/node/claudeCodeModels';
27
import { IClaudeCodeSdkService } from '../claude/node/claudeCodeSdkService';
28
import { parseClaudeModelId } from '../claude/node/claudeModelId';
29
import { IClaudeSessionStateService } from '../claude/common/claudeSessionStateService';
30
import { IClaudeCodeSessionService } from '../claude/node/sessionParser/claudeCodeSessionService';
31
import { IClaudeCodeSessionInfo, IClaudeCodeSession, SYNTHETIC_MODEL_ID } from '../claude/node/sessionParser/claudeSessionSchema';
32
import { IClaudeSlashCommandService } from '../claude/vscode-node/claudeSlashCommandService';
33
import { IChatFolderMruService } from '../common/folderRepositoryManager';
34
import { builtinSlashCommands } from '../common/builtinSlashCommands';
35
import { IClaudeWorkspaceFolderService } from '../common/claudeWorkspaceFolderService';
36
import { buildChatHistory } from './chatHistoryBuilder';
37
import { ClaudeSessionOptionBuilder, buildPermissionModeItems, FOLDER_OPTION_ID, isPermissionMode, PERMISSION_MODE_OPTION_ID } from './claudeSessionOptionBuilder';
38
import { toWorkspaceFolderOptionItem } from './sessionOptionGroupBuilder';
39
40
// Import the tool permission handlers
41
import '../claude/vscode-node/toolPermissionHandlers/index';
42
43
interface SessionMetadata {
44
readonly workingDirectoryPath: string;
45
readonly repositoryPath?: string;
46
readonly branchName?: string;
47
readonly upstreamBranchName?: string;
48
readonly hasGitHubRemote?: boolean;
49
readonly incomingChanges?: number;
50
readonly outgoingChanges?: number;
51
readonly uncommittedChanges?: number;
52
}
53
54
function getSessionResource(sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri): vscode.Uri | undefined {
55
return sessionItemOrResource instanceof vscode.Uri
56
? sessionItemOrResource
57
: sessionItemOrResource?.resource;
58
}
59
60
// Import the MCP server contributors to trigger self-registration
61
import '../claude/vscode-node/mcpServers/index';
62
63
interface InputStateReactivePipeline {
64
readonly permissionMode: ISettableObservable<PermissionMode>;
65
readonly folderUri: ISettableObservable<URI | undefined>;
66
readonly folderItems: ISettableObservable<readonly vscode.ChatSessionProviderOptionItem[]>;
67
readonly isSessionStarted: ISettableObservable<boolean>;
68
readonly store: DisposableStore;
69
}
70
71
function getSelectedFolderUri(inputState: vscode.ChatSessionInputState | undefined): URI | undefined {
72
const selectedFolderId = inputState?.groups.find(group => group.id === FOLDER_OPTION_ID)?.selected?.id;
73
return selectedFolderId ? URI.file(selectedFolderId) : undefined;
74
}
75
76
export class ClaudeChatSessionContentProvider extends Disposable implements vscode.ChatSessionContentProvider {
77
private readonly _controller: ClaudeChatSessionItemController;
78
79
constructor(
80
private readonly claudeAgentManager: ClaudeAgentManager,
81
@IClaudeCodeSessionService private readonly sessionService: IClaudeCodeSessionService,
82
@IClaudeSessionStateService private readonly sessionStateService: IClaudeSessionStateService,
83
@IClaudeSlashCommandService private readonly slashCommandService: IClaudeSlashCommandService,
84
@IClaudeCodeModels private readonly claudeModels: IClaudeCodeModels,
85
@IInstantiationService instantiationService: IInstantiationService
86
) {
87
super();
88
this._controller = this._register(instantiationService.createInstance(ClaudeChatSessionItemController));
89
}
90
91
// #region Chat Participant Handler
92
93
provideHandleOptionsChange(resource: vscode.Uri, updates: ReadonlyArray<vscode.ChatSessionOptionUpdate>, _token: vscode.CancellationToken): void {
94
const sessionId = ClaudeSessionUri.getSessionId(resource);
95
for (const update of updates) {
96
const value = update.value;
97
if (update.optionId === PERMISSION_MODE_OPTION_ID && value && isPermissionMode(value)) {
98
this.sessionStateService.setPermissionModeForSession(sessionId, value);
99
}
100
}
101
}
102
103
createHandler(): ChatExtendedRequestHandler {
104
return async (request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise<vscode.ChatResult | void> => {
105
const { chatSessionContext } = context;
106
if (!chatSessionContext) {
107
/* Via @claude */
108
// TODO: Think about how this should work
109
stream.markdown(vscode.l10n.t("Start a new Claude Agent session"));
110
stream.button({ command: `workbench.action.chat.openNewSessionEditor.${ClaudeSessionUri.scheme}`, title: vscode.l10n.t("Start Session") });
111
return {};
112
}
113
114
// Try to handle as a slash command first
115
const slashResult = await this.slashCommandService.tryHandleCommand(request, stream, token);
116
if (slashResult.handled) {
117
return slashResult.result ?? {};
118
}
119
120
const effectiveSessionId = ClaudeSessionUri.getSessionId(chatSessionContext.chatSessionItem.resource);
121
const yieldRequested = () => context.yieldRequested;
122
123
// Determine whether this is a new session by checking if a session
124
// already exists on disk via the session service.
125
const sessionUri = ClaudeSessionUri.forSessionId(effectiveSessionId);
126
const existingSession = await this.sessionService.getSession(sessionUri, token);
127
const isNewSession = !existingSession;
128
129
const modelId = parseClaudeModelId(request.model.id);
130
const selectedPermissionId = chatSessionContext.inputState.groups.find(group => group.id === PERMISSION_MODE_OPTION_ID)?.selected?.id;
131
if (!selectedPermissionId || !isPermissionMode(selectedPermissionId)) {
132
throw new Error(`Permission mode not set for session ${effectiveSessionId}`);
133
}
134
const permissionMode = selectedPermissionId;
135
const selectedFolderUri = getSelectedFolderUri(chatSessionContext.inputState);
136
const folderInfo = await this._controller.getFolderInfoForSession(effectiveSessionId, selectedFolderUri);
137
138
// Commit UI state to session state service before invoking agent manager
139
this.sessionStateService.setModelIdForSession(effectiveSessionId, modelId);
140
this.sessionStateService.setPermissionModeForSession(effectiveSessionId, permissionMode);
141
this.sessionStateService.setFolderInfoForSession(effectiveSessionId, folderInfo);
142
143
// Resolve the endpoint once and reuse it for both reasoning effort
144
// and the response footer details — they otherwise both call
145
// `resolveEndpoint` (which hits the cached endpoint list, then
146
// re-filters), which is wasted work and risks divergence.
147
const endpoint = await this._resolveEndpointForRequest(modelId.toEndpointModelId());
148
const rawReasoningEffort = request.modelConfiguration?.[CLAUDE_REASONING_EFFORT_PROPERTY];
149
const reasoningEffort = pickReasoningEffort(endpoint, typeof rawReasoningEffort === 'string' ? rawReasoningEffort : undefined);
150
this.sessionStateService.setReasoningEffortForSession(effectiveSessionId, reasoningEffort);
151
152
// Set usage handler to report token usage for context window widget
153
this.sessionStateService.setUsageHandlerForSession(effectiveSessionId, (usage) => {
154
stream.usage(usage);
155
});
156
157
const prompt = request.prompt;
158
await this._controller.updateItemStatus(effectiveSessionId, vscode.ChatSessionStatus.InProgress, prompt);
159
const result = await this.claudeAgentManager.handleRequest(effectiveSessionId, request, stream, token, isNewSession, yieldRequested);
160
await this._controller.updateItemStatus(effectiveSessionId, vscode.ChatSessionStatus.Completed, prompt);
161
162
// Clear usage handler after request completes
163
this.sessionStateService.setUsageHandlerForSession(effectiveSessionId, undefined);
164
165
const details = endpoint ? formatClaudeModelDetails(endpoint) : undefined;
166
return {
167
...(details ? { details } : {}),
168
...(result.errorDetails ? { errorDetails: result.errorDetails } : {}),
169
};
170
};
171
}
172
173
// #endregion
174
175
async provideChatSessionContent(sessionResource: vscode.Uri, token: vscode.CancellationToken, context?: { readonly inputState: vscode.ChatSessionInputState }): Promise<vscode.ChatSession> {
176
const existingSession = await this.sessionService.getSession(sessionResource, token);
177
const detailsByModelId = existingSession ? await this._buildModelDetailsLookup(existingSession, token) : undefined;
178
const history = existingSession ?
179
buildChatHistory(existingSession, detailsByModelId ? id => detailsByModelId.get(id) : undefined) :
180
[];
181
182
const options: Record<string, string | vscode.ChatSessionProviderOptionItem> = {};
183
const groups = context?.inputState.groups ?? [];
184
for (const group of groups) {
185
if (group.selected) {
186
// Only lock the folder group — permission mode must stay editable
187
const locked = group.id === FOLDER_OPTION_ID;
188
options[group.id] = locked
189
? { ...group.selected, locked: true }
190
: group.selected.id;
191
}
192
}
193
194
return {
195
title: existingSession?.label,
196
history,
197
activeResponseCallback: undefined,
198
requestHandler: undefined,
199
options,
200
};
201
}
202
203
/**
204
* Resolves a Claude model id to its endpoint. Wraps `resolveEndpoint` in a
205
* try/catch so transient failures degrade gracefully (return `undefined`)
206
* instead of breaking the response or session-load path.
207
*/
208
private async _resolveEndpointForRequest(modelId: string): Promise<IChatEndpoint | undefined> {
209
try {
210
return await this.claudeModels.resolveEndpoint(modelId, undefined);
211
} catch {
212
return undefined;
213
}
214
}
215
216
/**
217
* Resolves the display string for each unique non-synthetic model id observed in the
218
* session's assistant messages. Returns `undefined` (not an empty map) when no model
219
* ids are present, when the caller has cancelled, or when no ids resolve to known
220
* endpoints — so callers can skip the per-turn details work entirely.
221
*/
222
private async _buildModelDetailsLookup(session: IClaudeCodeSession, token: vscode.CancellationToken): Promise<Map<string, string> | undefined> {
223
if (token.isCancellationRequested) {
224
return undefined;
225
}
226
const modelIds = new Set<string>();
227
for (const msg of session.messages) {
228
if (msg.type === 'assistant' && msg.message.role === 'assistant') {
229
const model = msg.message.model;
230
if (model && model !== SYNTHETIC_MODEL_ID) {
231
modelIds.add(model);
232
}
233
}
234
}
235
if (modelIds.size === 0) {
236
return undefined;
237
}
238
const detailsByModelId = new Map<string, string>();
239
await Promise.all([...modelIds].map(async modelId => {
240
if (token.isCancellationRequested) {
241
return;
242
}
243
const endpoint = await this._resolveEndpointForRequest(modelId);
244
if (endpoint) {
245
detailsByModelId.set(modelId, formatClaudeModelDetails(endpoint));
246
}
247
}));
248
if (token.isCancellationRequested) {
249
return undefined;
250
}
251
return detailsByModelId.size > 0 ? detailsByModelId : undefined;
252
}
253
}
254
255
/**
256
* Chat session item controller wrapper for Claude Agent.
257
* Reads sessions from ~/.claude/projects/<folder-slug>/, where each file name is a session id (GUID).
258
*
259
* Owns the input state (getChatSessionInputState) lifecycle: wiring external
260
* state listeners and resolving folder info for sessions. Group construction
261
* is delegated to {@link ClaudeSessionOptionBuilder}.
262
*/
263
export class ClaudeChatSessionItemController extends Disposable {
264
private readonly _controller: vscode.ChatSessionItemController;
265
private readonly _optionBuilder: ClaudeSessionOptionBuilder;
266
private readonly _inProgressItems = new Map<string, vscode.ChatSessionItem>();
267
private _showBadge: boolean;
268
269
// #region Shared Observable State
270
271
/** Whether the "bypass permissions" config is enabled — controls permission mode items. */
272
private readonly _bypassPermissionsEnabled: IObservable<boolean>;
273
274
/** Current workspace folders — controls folder group items and visibility. */
275
private readonly _workspaceFolders: IObservable<URI[]>;
276
277
278
// #endregion
279
280
constructor(
281
@IClaudeCodeSessionService private readonly _claudeCodeSessionService: IClaudeCodeSessionService,
282
@IClaudeSessionStateService private readonly _sessionStateService: IClaudeSessionStateService,
283
@IConfigurationService _configurationService: IConfigurationService,
284
@IChatFolderMruService folderMruService: IChatFolderMruService,
285
@IWorkspaceService private readonly _workspaceService: IWorkspaceService,
286
@INativeEnvService private readonly _envService: INativeEnvService,
287
@IGitService private readonly _gitService: IGitService,
288
@IClaudeCodeSdkService private readonly _sdkService: IClaudeCodeSdkService,
289
@ILogService private readonly _logService: ILogService,
290
@IClaudeWorkspaceFolderService private readonly _claudeWorkspaceFolderService: IClaudeWorkspaceFolderService,
291
) {
292
super();
293
this._optionBuilder = new ClaudeSessionOptionBuilder(_configurationService, folderMruService, _workspaceService);
294
295
this._bypassPermissionsEnabled = observableFromEvent(
296
this,
297
Event.filter(_configurationService.onDidChangeConfiguration,
298
e => e.affectsConfiguration(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions.fullyQualifiedId)),
299
() => _configurationService.getConfig(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions) as boolean,
300
);
301
302
// Bridge vscode.Event → internal Event for workspace folder changes
303
const workspaceFoldersEmitter = this._register(new Emitter<void>());
304
const workspaceFoldersSubscription = _workspaceService.onDidChangeWorkspaceFolders(() => workspaceFoldersEmitter.fire());
305
this._register({ dispose: () => workspaceFoldersSubscription.dispose() });
306
this._workspaceFolders = observableFromEvent(
307
this,
308
workspaceFoldersEmitter.event,
309
() => _workspaceService.getWorkspaceFolders(),
310
);
311
312
this._registerCommands();
313
this._controller = this._register(vscode.chat.createChatSessionItemController(
314
ClaudeSessionUri.scheme,
315
() => this._refreshItems(CancellationToken.None)
316
));
317
318
this._controller.newChatSessionItemHandler = async (context, _token) => {
319
const newSessionId = generateUuid();
320
const item = this._controller.createChatSessionItem(
321
ClaudeSessionUri.forSessionId(newSessionId),
322
context.request.prompt,
323
);
324
item.iconPath = new vscode.ThemeIcon('claude');
325
item.timing = { created: Date.now() };
326
327
// Set workspace metadata for correct session grouping
328
const selectedFolderUri = getSelectedFolderUri(context.inputState);
329
const folderInfo = await this.getFolderInfoForSession(newSessionId, selectedFolderUri);
330
if (folderInfo.cwd) {
331
item.metadata = await this._buildSessionMetadata(folderInfo.cwd);
332
}
333
334
this._inProgressItems.set(newSessionId, item);
335
return item;
336
};
337
338
this._controller.forkHandler = async (sessionResource: vscode.Uri, request: vscode.ChatRequestTurn2 | undefined, token: CancellationToken): Promise<vscode.ChatSessionItem> => {
339
const item = this._controller.items.get(sessionResource);
340
const title = vscode.l10n.t('Forked: {0}', item?.label ?? request?.prompt ?? 'Claude Session');
341
342
// Fork whole history if no request specified
343
let upToMessageId: string | undefined = undefined;
344
if (request) {
345
// we need to get the message right before the `request`
346
const session = await this._claudeCodeSessionService.getSession(sessionResource, token);
347
if (!session) {
348
// This shouldn't happen
349
this._logService.error(`Failed to fork session: session not found for resource ${sessionResource.toString()}`);
350
throw new Error('Unable to fork: session not found.');
351
} else {
352
const messageIndex = session.messages.findIndex(m => m.uuid === request.id);
353
if (messageIndex === -1) {
354
this._logService.error(`Failed to fork session: request with id ${request.id} not found in session ${sessionResource.toString()}`);
355
throw new Error('Unable to fork: the selected message could not be found.');
356
}
357
if (messageIndex === 0) {
358
this._logService.error(`Failed to fork session: cannot fork at the first message`);
359
throw new Error('Cannot fork from the first message.');
360
}
361
const forkMessage = session.messages[messageIndex - 1];
362
upToMessageId = forkMessage.uuid;
363
}
364
}
365
const result = await this._sdkService.forkSession(
366
ClaudeSessionUri.getSessionId(sessionResource),
367
{ upToMessageId, title }
368
);
369
const newItem = this._controller.createChatSessionItem(ClaudeSessionUri.forSessionId(result.sessionId), title);
370
newItem.iconPath = new vscode.ThemeIcon('claude');
371
newItem.timing = { created: Date.now() };
372
// FYI, dropping any other metadata fields here...
373
if (item?.metadata?.workingDirectoryPath) {
374
newItem.metadata = await this._buildSessionMetadata(item.metadata.workingDirectoryPath);
375
}
376
377
// Copy parent session state to the forked session
378
const parentSessionId = ClaudeSessionUri.getSessionId(sessionResource);
379
const parentPermission = this._sessionStateService.getPermissionModeForSession(parentSessionId);
380
const parentFolder = this._sessionStateService.getFolderInfoForSession(parentSessionId);
381
this._sessionStateService.setPermissionModeForSession(result.sessionId, parentPermission);
382
if (parentFolder) {
383
this._sessionStateService.setFolderInfoForSession(result.sessionId, {
384
...parentFolder,
385
additionalDirectories: [...(parentFolder.additionalDirectories ?? [])],
386
});
387
}
388
389
this._controller.items.add(newItem);
390
return newItem;
391
};
392
393
this._showBadge = this._computeShowBadge();
394
395
// Refresh session items and recompute badge when repositories change.
396
// _computeShowBadge() reads gitService.repositories synchronously, which
397
// may be incomplete while the git extension is still initializing.
398
this._register(_gitService.onDidOpenRepository(() => {
399
this._showBadge = this._computeShowBadge();
400
void this._refreshItems(CancellationToken.None);
401
}));
402
this._register(_gitService.onDidCloseRepository(() => {
403
this._showBadge = this._computeShowBadge();
404
void this._refreshItems(CancellationToken.None);
405
}));
406
407
this._setupInputState();
408
}
409
410
// #region Input State
411
412
/**
413
* Creates a reactive pipeline for a single input state.
414
*
415
* Per-state observables (`permissionMode`, `folderUri`, `isSessionStarted`) are
416
* combined with shared observables (`_bypassPermissionsEnabled`, `_workspaceFolders`)
417
* into derived group computations. An autorun reads the derived groups and pushes
418
* the result to `state.groups`, which is the "UI".
419
*
420
* The returned `DisposableStore` owns the autorun lifecycle and is disposed via
421
* `state.onDidDispose` in the caller.
422
*
423
* Returns the per-state observables so callers can drive external updates, plus a
424
* `DisposableStore` that owns the autorun lifecycle.
425
*/
426
private _createInputStateReactivePipeline(
427
state: vscode.ChatSessionInputState,
428
): InputStateReactivePipeline {
429
const store = new DisposableStore();
430
431
// Seed values are computed up front so that the first autorun pass
432
// observes fully-seeded observables and does not clobber `initialGroups`.
433
const seed = this._computeSeedValues(state.groups);
434
435
const permissionMode = observableValue<PermissionMode>(this, seed.permissionMode);
436
const folderUri = observableValue<URI | undefined>(this, seed.folderUri);
437
const folderItems = observableValue<readonly vscode.ChatSessionProviderOptionItem[]>(this, seed.folderItems);
438
const isSessionStarted = observableValue<boolean>(this, seed.isSessionStarted);
439
440
// When workspace folders change, update folder items reactively.
441
// Falls back to the async MRU list when the workspace becomes empty,
442
// matching the old imperative `buildNewFolderGroup` behavior.
443
store.add(autorun(reader => {
444
/** @description syncWorkspaceFolderItems */
445
const folders = this._workspaceFolders.read(reader);
446
if (folders.length !== 0) {
447
folderItems.set(
448
folders.map(f => toWorkspaceFolderOptionItem(f, this._workspaceService.getWorkspaceFolderName(f) || basename(f))),
449
undefined,
450
);
451
} else {
452
this._optionBuilder.getFolderOptionItems()
453
.then(items => folderItems.set(items, undefined))
454
.catch(e => this._logService.error(e));
455
}
456
}));
457
458
const permissionModeGroup = derived(reader => {
459
/** @description permissionModeGroup */
460
const bypassEnabled = this._bypassPermissionsEnabled.read(reader);
461
const selectedMode = permissionMode.read(reader);
462
const group = buildPermissionModeItems(bypassEnabled);
463
const selectedItem = group.items.find(i => i.id === selectedMode) ?? group.items[0];
464
return { ...group, selected: selectedItem };
465
});
466
467
const folderGroup = derived<vscode.ChatSessionProviderOptionGroup | undefined>(reader => {
468
/** @description folderGroup */
469
const items = folderItems.read(reader);
470
const folders = this._workspaceFolders.read(reader);
471
// Hide folder group when there's exactly one workspace folder (implicit)
472
if (folders.length === 1) {
473
return undefined;
474
}
475
const selectedFolder = folderUri.read(reader);
476
const locked = isSessionStarted.read(reader);
477
const lockedItems = locked ? items.map(i => ({ ...i, locked: true })) : items;
478
const selectedItem = selectedFolder
479
? lockedItems.find(i => i.id === selectedFolder.fsPath)
480
: lockedItems[0];
481
return {
482
id: FOLDER_OPTION_ID,
483
name: vscode.l10n.t('Folder'),
484
description: vscode.l10n.t('Pick Folder'),
485
items: lockedItems,
486
selected: selectedItem ? (locked ? { ...selectedItem, locked: true } : selectedItem) : undefined,
487
};
488
});
489
490
const allGroups = derived(reader => {
491
/** @description allGroups */
492
const groups: vscode.ChatSessionProviderOptionGroup[] = [];
493
const folder = folderGroup.read(reader);
494
if (folder) {
495
groups.push(folder);
496
}
497
groups.push(permissionModeGroup.read(reader));
498
return groups;
499
});
500
501
store.add(autorun(reader => {
502
/** @description syncInputStateGroups */
503
state.groups = allGroups.read(reader);
504
}));
505
506
return { permissionMode, folderUri, folderItems, isSessionStarted, store };
507
}
508
509
private _setupInputState(): void {
510
this._controller.getChatSessionInputState = async (sessionResource, context, token) => {
511
let state: vscode.ChatSessionInputState;
512
let pipeline: InputStateReactivePipeline;
513
514
if (context.previousInputState) {
515
state = this._controller.createChatSessionInputState([...context.previousInputState.groups]);
516
pipeline = this._createInputStateReactivePipeline(state);
517
} else {
518
const isExistingSession = sessionResource && await this._claudeCodeSessionService.getSession(sessionResource, token) !== undefined;
519
const initialGroups = isExistingSession
520
? await this._buildExistingSessionGroups(sessionResource)
521
: await this._optionBuilder.buildNewSessionGroups();
522
state = this._controller.createChatSessionInputState(initialGroups);
523
pipeline = this._createInputStateReactivePipeline(state);
524
}
525
526
if (sessionResource) {
527
pipeline.isSessionStarted.set(true, undefined);
528
529
// React to external permission mode changes for this session.
530
// Runs for both previousInputState and new-state paths so that
531
// EnterPlanMode / ExitPlanMode tool calls always update the input UI.
532
const sessionId = ClaudeSessionUri.getSessionId(sessionResource);
533
const externalPermissionMode = observableFromEvent(
534
this,
535
Event.filter(this._sessionStateService.onDidChangeSessionState,
536
e => e.sessionId === sessionId && e.permissionMode !== undefined),
537
() => this._sessionStateService.getPermissionModeForSession(sessionId),
538
);
539
pipeline.store.add(autorun(reader => {
540
/** @description syncExternalPermissionMode */
541
pipeline.permissionMode.set(externalPermissionMode.read(reader), undefined);
542
}));
543
}
544
545
pipeline.store.add(state.onDidDispose(() => pipeline.store.dispose()));
546
return state;
547
};
548
}
549
550
/**
551
* Extracts seed values for the per-state observables from the input groups.
552
* Pure and synchronous — runs before any autoruns are attached so the first
553
* autorun pass observes fully-seeded values and does not overwrite the
554
* carefully-constructed initial groups.
555
*
556
* Also recovers the `isSessionStarted` signal from `locked` items — required to
557
* preserve lock state when restoring a previously-started session.
558
*/
559
private _computeSeedValues(groups: readonly vscode.ChatSessionProviderOptionGroup[]): {
560
readonly permissionMode: PermissionMode;
561
readonly folderUri: URI | undefined;
562
readonly folderItems: readonly vscode.ChatSessionProviderOptionItem[];
563
readonly isSessionStarted: boolean;
564
} {
565
let permissionMode: PermissionMode = this._optionBuilder.lastUsedPermissionMode;
566
const permissionGroup = groups.find(g => g.id === PERMISSION_MODE_OPTION_ID);
567
if (permissionGroup?.selected && isPermissionMode(permissionGroup.selected.id)) {
568
permissionMode = permissionGroup.selected.id;
569
}
570
571
let folderUri: URI | undefined;
572
let folderItems: readonly vscode.ChatSessionProviderOptionItem[] = [];
573
let isSessionStarted = false;
574
const folderGroup = groups.find(g => g.id === FOLDER_OPTION_ID);
575
if (folderGroup) {
576
if (folderGroup.items.length > 0) {
577
folderItems = folderGroup.items;
578
}
579
if (folderGroup.selected) {
580
folderUri = URI.file(folderGroup.selected.id);
581
}
582
// Restore the "started" signal: if any items (or the selected item) carry
583
// `locked: true`, the session was previously started and must stay locked.
584
if (folderGroup.selected?.locked || folderGroup.items.some(i => i.locked)) {
585
isSessionStarted = true;
586
}
587
}
588
589
return { permissionMode, folderUri, folderItems, isSessionStarted };
590
}
591
592
private async _buildExistingSessionGroups(sessionResource: vscode.Uri): Promise<vscode.ChatSessionProviderOptionGroup[]> {
593
const sessionId = ClaudeSessionUri.getSessionId(sessionResource);
594
const permissionMode = this._sessionStateService.getPermissionModeForSession(sessionId);
595
596
const workspaceFolders = this._workspaceService.getWorkspaceFolders();
597
let folderUri: URI | undefined;
598
if (workspaceFolders.length !== 1) {
599
const stateFolder = this._sessionStateService.getFolderInfoForSession(sessionId);
600
if (stateFolder) {
601
folderUri = URI.file(stateFolder.cwd);
602
} else {
603
const session = await this._claudeCodeSessionService.getSession(sessionResource, CancellationToken.None);
604
if (session?.cwd) {
605
folderUri = URI.file(session.cwd);
606
} else {
607
folderUri = await this._optionBuilder.getDefaultFolder();
608
}
609
}
610
}
611
return this._optionBuilder.buildExistingSessionGroups(permissionMode, folderUri);
612
}
613
614
// #endregion
615
616
// #region Folder Resolution
617
618
async getFolderInfoForSession(sessionId: string, selectedFolderUri?: URI): Promise<ClaudeFolderInfo> {
619
const workspaceFolders = this._workspaceService.getWorkspaceFolders();
620
621
if (workspaceFolders.length === 1) {
622
return {
623
cwd: workspaceFolders[0].fsPath,
624
additionalDirectories: [],
625
};
626
}
627
628
// Multi-root or empty workspace: resolve selected folder from inputState, sessionStateService, or session file
629
const folderUri = selectedFolderUri ?? await this._resolveSessionFolder(sessionId);
630
631
if (workspaceFolders.length > 1) {
632
const cwd = folderUri?.fsPath ?? workspaceFolders[0].fsPath;
633
const additionalDirectories = workspaceFolders
634
.map(f => f.fsPath)
635
.filter(p => p !== cwd);
636
return { cwd, additionalDirectories };
637
}
638
639
// Empty workspace
640
if (folderUri) {
641
return {
642
cwd: folderUri.fsPath,
643
additionalDirectories: [],
644
};
645
}
646
647
// Fallback for empty workspace with no selection: try MRU
648
const defaultFolder = await this._optionBuilder.getDefaultFolder();
649
if (defaultFolder) {
650
return {
651
cwd: defaultFolder.fsPath,
652
additionalDirectories: [],
653
};
654
}
655
656
// No folder available at all — fall back to the user's home directory
657
return {
658
cwd: this._envService.userHome.fsPath,
659
additionalDirectories: [],
660
};
661
}
662
663
private async _resolveSessionFolder(sessionId: string): Promise<URI | undefined> {
664
const stateFolder = this._sessionStateService.getFolderInfoForSession(sessionId);
665
if (stateFolder) {
666
return URI.file(stateFolder.cwd);
667
}
668
669
const sessionResource = ClaudeSessionUri.forSessionId(sessionId);
670
const session = await this._claudeCodeSessionService.getSession(sessionResource, CancellationToken.None);
671
if (session?.cwd) {
672
return URI.file(session.cwd);
673
}
674
675
return this._optionBuilder.getDefaultFolder();
676
}
677
678
// #endregion
679
680
updateItemLabel(sessionId: string, label: string): void {
681
const resource = ClaudeSessionUri.forSessionId(sessionId);
682
const item = this._controller.items.get(resource);
683
if (item) {
684
item.label = label;
685
}
686
}
687
688
async updateItemStatus(sessionId: string, status: vscode.ChatSessionStatus, newItemLabel: string): Promise<void> {
689
const resource = ClaudeSessionUri.forSessionId(sessionId);
690
let item = this._controller.items.get(resource);
691
if (!item) {
692
const session = await this._claudeCodeSessionService.getSession(resource, CancellationToken.None);
693
if (session) {
694
item = await this._createClaudeChatSessionItem(session);
695
} else {
696
const newlyCreatedSessionInfo: IClaudeCodeSessionInfo = {
697
id: sessionId,
698
label: newItemLabel,
699
created: Date.now(),
700
lastRequestEnded: Date.now(),
701
folderName: undefined
702
};
703
item = await this._createClaudeChatSessionItem(newlyCreatedSessionInfo);
704
}
705
706
this._controller.items.add(item);
707
}
708
709
item.status = status;
710
if (status === vscode.ChatSessionStatus.InProgress) {
711
const timing = item.timing ? { ...item.timing } : { created: Date.now() };
712
timing.lastRequestStarted = Date.now();
713
// Clear lastRequestEnded while a request is in progress
714
timing.lastRequestEnded = undefined;
715
item.timing = timing;
716
this._inProgressItems.set(sessionId, item);
717
} else {
718
this._inProgressItems.delete(sessionId);
719
if (status === vscode.ChatSessionStatus.Completed) {
720
if (!item.timing) {
721
item.timing = {
722
created: Date.now(),
723
lastRequestEnded: Date.now()
724
};
725
} else {
726
item.timing = { ...item.timing, lastRequestEnded: Date.now() };
727
}
728
const session = await this._claudeCodeSessionService.getSession(resource, CancellationToken.None);
729
if (session?.cwd && await this._workspaceService.isResourceTrusted(URI.file(session.cwd))) {
730
item.changes = await this._claudeWorkspaceFolderService.getWorkspaceChanges(
731
session.cwd,
732
session.gitBranch,
733
undefined,
734
true,
735
);
736
}
737
}
738
}
739
}
740
741
private async _refreshItems(token: vscode.CancellationToken): Promise<void> {
742
const sessions = await this._claudeCodeSessionService.getAllSessions(token);
743
const results = await Promise.allSettled(sessions.map(session => this._createClaudeChatSessionItem(session)));
744
const items: vscode.ChatSessionItem[] = [];
745
for (let i = 0; i < results.length; i++) {
746
const result = results[i];
747
if (result.status === 'fulfilled') {
748
items.push(result.value);
749
} else {
750
const session = sessions[i];
751
this._logService.warn(`Failed to create Claude chat session item for ${session.id} (${session.label}) ${result.reason}`);
752
}
753
}
754
items.push(...this._inProgressItems.values());
755
this._controller.items.replace(items);
756
}
757
758
private async _createClaudeChatSessionItem(session: IClaudeCodeSessionInfo): Promise<vscode.ChatSessionItem> {
759
let badge: vscode.MarkdownString | undefined;
760
if (session.folderName && this._showBadge) {
761
badge = new vscode.MarkdownString(`$(folder) ${session.folderName}`);
762
badge.supportThemeIcons = true;
763
}
764
765
const item = this._controller.createChatSessionItem(ClaudeSessionUri.forSessionId(session.id), session.label);
766
item.badge = badge;
767
item.tooltip = `Claude Code session: ${session.label}`;
768
item.timing = {
769
created: session.created,
770
lastRequestStarted: session.lastRequestStarted,
771
lastRequestEnded: session.lastRequestEnded,
772
};
773
item.iconPath = new vscode.ThemeIcon('claude');
774
if (session.cwd) {
775
const isTrusted = await this._workspaceService.isResourceTrusted(URI.file(session.cwd));
776
if (isTrusted) {
777
const [metadata, changes] = await Promise.all([
778
this._buildSessionMetadata(session.cwd, isTrusted),
779
this._claudeWorkspaceFolderService.getWorkspaceChanges(
780
session.cwd,
781
session.gitBranch,
782
undefined,
783
),
784
]);
785
item.metadata = metadata;
786
item.changes = changes;
787
} else {
788
item.metadata = await this._buildSessionMetadata(session.cwd, isTrusted);
789
}
790
}
791
return item;
792
}
793
794
private _computeShowBadge(): boolean {
795
const workspaceFolders = this._workspaceService.getWorkspaceFolders();
796
if (workspaceFolders.length === 0) {
797
return true; // Empty window
798
}
799
if (workspaceFolders.length > 1) {
800
return true; // Multi-root workspace
801
}
802
803
// Single-root workspace with multiple git repositories
804
const repositories = this._gitService.repositories
805
.filter(repository => repository.kind !== 'worktree');
806
return repositories.length > 1;
807
}
808
809
private async _buildSessionMetadata(cwd: string, isTrusted?: boolean): Promise<SessionMetadata> {
810
const cwdUri = URI.file(cwd);
811
if (!(isTrusted ?? await this._workspaceService.isResourceTrusted(cwdUri))) {
812
return { workingDirectoryPath: cwd };
813
}
814
815
const repoContext = await this._gitService.getRepository(cwdUri);
816
if (!repoContext) {
817
return { workingDirectoryPath: cwd };
818
}
819
820
const changes = repoContext.changes;
821
const uncommittedChanges = changes
822
? changes.mergeChanges.length + changes.indexChanges.length + changes.workingTree.length + changes.untrackedChanges.length
823
: 0;
824
825
return {
826
workingDirectoryPath: cwd,
827
repositoryPath: repoContext.rootUri.fsPath,
828
branchName: repoContext.headBranchName,
829
upstreamBranchName: repoContext.upstreamRemote && repoContext.upstreamBranchName
830
? `${repoContext.upstreamRemote}/${repoContext.upstreamBranchName}`
831
: undefined,
832
hasGitHubRemote: getGitHubRepoInfoFromContext(repoContext) !== undefined,
833
incomingChanges: repoContext.headIncomingChanges ?? 0,
834
outgoingChanges: repoContext.headOutgoingChanges ?? 0,
835
uncommittedChanges,
836
};
837
}
838
839
private _registerPromptCommand(commandId: string, prompt: string): void {
840
this._register(vscode.commands.registerCommand(commandId, async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {
841
const resource = getSessionResource(sessionItemOrResource);
842
if (!resource) {
843
return;
844
}
845
await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.claude-code', {
846
resource,
847
prompt,
848
});
849
}));
850
}
851
852
private _registerCommands(): void {
853
this._register(vscode.commands.registerCommand('github.copilot.claude.sessions.rename', async (sessionItem?: vscode.ChatSessionItem) => {
854
if (!sessionItem?.resource) {
855
return;
856
}
857
858
const sessionId = ClaudeSessionUri.getSessionId(sessionItem.resource);
859
const newTitle = await vscode.window.showInputBox({
860
prompt: vscode.l10n.t('New agent session title'),
861
value: sessionItem.label,
862
validateInput: value => {
863
if (!value.trim()) {
864
return vscode.l10n.t('Title cannot be empty');
865
}
866
return undefined;
867
}
868
});
869
870
if (newTitle) {
871
const trimmedTitle = newTitle.trim();
872
if (trimmedTitle) {
873
try {
874
await this._sdkService.renameSession(sessionId, trimmedTitle);
875
this.updateItemLabel(sessionId, trimmedTitle);
876
} catch (e) {
877
this._logService.error(e, `[ClaudeChatSessionItemController] Failed to rename session: ${sessionId}`);
878
}
879
}
880
}
881
}));
882
883
this._registerPromptCommand('github.copilot.claude.sessions.commit', builtinSlashCommands.commit);
884
this._registerPromptCommand('github.copilot.claude.sessions.commitAndSync', `${builtinSlashCommands.commit} and ${builtinSlashCommands.sync}`);
885
this._registerPromptCommand('github.copilot.claude.sessions.sync', builtinSlashCommands.sync);
886
887
this._register(vscode.commands.registerCommand('github.copilot.claude.sessions.initializeRepository', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {
888
const resource = getSessionResource(sessionItemOrResource);
889
if (!resource) {
890
return;
891
}
892
893
const sessionId = ClaudeSessionUri.getSessionId(resource);
894
const folderInfo = await this.getFolderInfoForSession(sessionId);
895
const workspaceFolder = URI.file(folderInfo.cwd);
896
897
const repository = await this._gitService.initRepository(workspaceFolder);
898
if (!repository) {
899
return;
900
}
901
902
void this._refreshItems(CancellationToken.None);
903
}));
904
}
905
}
906
907