Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts
13401 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 { Emitter, Event } from '../../../../base/common/event.js';
7
import { raceCancellationError, raceTimeout } from '../../../../base/common/async.js';
8
import { Codicon } from '../../../../base/common/codicons.js';
9
import { CancellationError } from '../../../../base/common/errors.js';
10
import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js';
11
import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js';
12
import { Schemas } from '../../../../base/common/network.js';
13
import { autorun, constObservable, derived, IObservable, IReader, observableFromEvent, observableValue, transaction } from '../../../../base/common/observable.js';
14
import { ThemeIcon } from '../../../../base/common/themables.js';
15
import { URI } from '../../../../base/common/uri.js';
16
import { ICommandService } from '../../../../platform/commands/common/commands.js';
17
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
18
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
19
import { IAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js';
20
import { getRepositoryName } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.js';
21
import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js';
22
import { AgentSessionProviders, AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js';
23
import { IChatService, IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js';
24
import { IChatResponseModel } from '../../../../workbench/contrib/chat/common/model/chatModel.js';
25
import { ChatSessionStatus, IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, SessionType } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';
26
import { ISession, IChat, ISessionRepository, ISessionWorkspace, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, CopilotCLISessionType, CopilotCloudSessionType, ClaudeCodeSessionType, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL } from '../../../services/sessions/common/session.js';
27
import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js';
28
import { basename, dirname, isEqual } from '../../../../base/common/resources.js';
29
import { ISendRequestOptions, ISessionChangeEvent, ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js';
30
import { ISessionOptionGroup } from '../../chat/browser/newSession.js';
31
import { IsolationMode } from './isolationPicker.js';
32
import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js';
33
import { ILanguageModelToolsService } from '../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js';
34
import { isBuiltinChatMode, IChatMode } from '../../../../workbench/contrib/chat/common/chatModes.js';
35
import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';
36
import { generateUuid } from '../../../../base/common/uuid.js';
37
import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js';
38
import { IGitService, IGitRepository } from '../../../../workbench/contrib/git/common/gitService.js';
39
import { IContextKeyService, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
40
import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js';
41
import { localize } from '../../../../nls.js';
42
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
43
import { ILabelService } from '../../../../platform/label/common/label.js';
44
import { ILogService } from '../../../../platform/log/common/log.js';
45
import { IGitHubService } from '../../github/browser/githubService.js';
46
import { computePullRequestIcon, GitHubPullRequestState } from '../../github/common/types.js';
47
48
const SESSION_WORKSPACE_GROUP_GITHUB = localize('sessionWorkspaceGroup.github', "GitHub");
49
50
export interface ICopilotChatSession {
51
/** Globally unique session ID (`providerId:localId`). */
52
readonly id: string;
53
/** Resource URI identifying this session. */
54
readonly resource: URI;
55
/** ID of the provider that owns this session. */
56
readonly providerId: string;
57
/** Session type ID (e.g., 'copilot-cli', 'copilot-cloud'). */
58
readonly sessionType: string;
59
/** Icon for this session. */
60
readonly icon: ThemeIcon;
61
/** When the session was created. */
62
readonly createdAt: Date;
63
/** Workspace this session operates on. */
64
readonly workspace: IObservable<ISessionWorkspace | undefined>;
65
66
// Reactive properties
67
68
/** Session display title (changes when auto-titled or renamed). */
69
readonly title: IObservable<string>;
70
/** When the session was last updated. */
71
readonly updatedAt: IObservable<Date>;
72
/** Current session status. */
73
readonly status: IObservable<SessionStatus>;
74
/** File changes produced by the session. */
75
readonly changes: IObservable<readonly ISessionFileChange[]>;
76
/** Currently selected model identifier. */
77
readonly modelId: IObservable<string | undefined>;
78
/** Currently selected mode identifier and kind. */
79
readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined>;
80
/** Whether the session is still initializing (e.g., resolving git repository). */
81
readonly loading: IObservable<boolean>;
82
/** Whether the session is archived. */
83
readonly isArchived: IObservable<boolean>;
84
/** Whether the session has been read. */
85
readonly isRead: IObservable<boolean>;
86
/** Status description shown while the session is active (e.g., current agent action). */
87
readonly description: IObservable<IMarkdownString | undefined>;
88
/** Timestamp of when the last agent turn ended, if any. */
89
readonly lastTurnEnd: IObservable<Date | undefined>;
90
/** GitHub information associated with this session, if any. */
91
readonly gitHubInfo: IObservable<IGitHubInfo | undefined>;
92
93
readonly permissionLevel: IObservable<ChatPermissionLevel>;
94
setPermissionLevel(level: ChatPermissionLevel): void;
95
96
readonly branch: IObservable<string | undefined>;
97
setBranch(branch: string | undefined): void;
98
99
readonly isolationMode: IObservable<IsolationMode | undefined>;
100
setIsolationMode(mode: IsolationMode): void;
101
102
setModelId(modelId: string): void;
103
setMode(chatMode: IChatMode | undefined): void;
104
setOption?(optionId: string, value: IChatSessionProviderOptionItem | string): void;
105
106
readonly gitRepository?: IGitRepository;
107
readonly branches: IObservable<readonly string[]>;
108
}
109
110
const OPEN_REPO_COMMAND = 'github.copilot.chat.cloudSessions.openRepository';
111
112
/** Provider ID for the Copilot Chat Sessions provider. */
113
export const COPILOT_PROVIDER_ID = 'default-copilot';
114
115
/** Setting key controlling whether the Copilot provider supports multiple chats per session. */
116
export const COPILOT_MULTI_CHAT_SETTING = 'sessions.github.copilot.multiChatSessions';
117
118
/** Setting key controlling whether Claude agent sessions are available. */
119
export const CLAUDE_CODE_ENABLED_SETTING = 'sessions.chat.claudeAgent.enabled';
120
121
const REPOSITORY_OPTION_ID = 'repository';
122
const PARENT_SESSION_OPTION_ID = 'parentSessionId';
123
const BRANCH_OPTION_ID = 'branch';
124
const ISOLATION_OPTION_ID = 'isolation';
125
const AGENT_OPTION_ID = 'agent';
126
127
type NewSession = CopilotCLISession | RemoteNewSession | ClaudeCodeNewSession;
128
129
function isNewSession(session: ICopilotChatSession): session is NewSession {
130
return session instanceof CopilotCLISession || session instanceof RemoteNewSession || session instanceof ClaudeCodeNewSession;
131
}
132
133
/**
134
* Local new session for Background agent sessions.
135
* Implements {@link ICopilotChatSession} (session facade) and provides
136
* pre-send configuration methods for the new-session flow.
137
*/
138
class CopilotCLISession extends Disposable implements ICopilotChatSession {
139
140
static readonly COPILOT_WORKTREE_PATTERN = 'copilot-worktree-';
141
142
// -- ISessionData fields --
143
144
readonly id: string;
145
readonly providerId: string;
146
readonly sessionType: string;
147
readonly icon: ThemeIcon;
148
readonly createdAt: Date;
149
150
private readonly _title = observableValue(this, '');
151
readonly title: IObservable<string> = this._title;
152
153
private readonly _description: ReturnType<typeof observableValue<IMarkdownString | undefined>>;
154
readonly description: IObservable<IMarkdownString | undefined>;
155
156
private readonly _updatedAt = observableValue(this, new Date());
157
readonly updatedAt: IObservable<Date> = this._updatedAt;
158
159
private readonly _status = observableValue(this, SessionStatus.Untitled);
160
readonly status: IObservable<SessionStatus> = this._status;
161
162
private readonly _permissionLevel = observableValue(this, ChatPermissionLevel.Default);
163
readonly permissionLevel: IObservable<ChatPermissionLevel> = this._permissionLevel;
164
165
private readonly _workspaceData = observableValue<ISessionWorkspace | undefined>(this, undefined);
166
readonly workspace: IObservable<ISessionWorkspace | undefined> = this._workspaceData;
167
168
private readonly _branchObservable = observableValue<string | undefined>(this, undefined);
169
readonly branch: IObservable<string | undefined> = this._branchObservable;
170
171
private readonly _isolationModeObservable = observableValue<IsolationMode | undefined>(this, 'worktree');
172
readonly isolationMode: IObservable<IsolationMode | undefined> = this._isolationModeObservable;
173
174
private readonly _modelIdObservable = observableValue<string | undefined>(this, undefined);
175
readonly modelId: IObservable<string | undefined> = this._modelIdObservable;
176
177
private readonly _modeObservable = observableValue<{ readonly id: string; readonly kind: string } | undefined>(this, undefined);
178
readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined> = this._modeObservable;
179
180
private readonly _loading = observableValue(this, true);
181
readonly loading: IObservable<boolean> = this._loading;
182
183
private readonly _changes: ReturnType<typeof observableValue<readonly ISessionFileChange[]>>;
184
readonly changes: IObservable<readonly ISessionFileChange[]>;
185
186
private readonly _isArchived = observableValue(this, false);
187
readonly isArchived: IObservable<boolean> = this._isArchived;
188
readonly isRead: IObservable<boolean> = observableValue(this, true);
189
readonly lastTurnEnd: IObservable<Date | undefined> = observableValue(this, undefined);
190
readonly gitHubInfo: IObservable<IGitHubInfo | undefined> = observableValue(this, undefined);
191
192
private _gitRepository: IGitRepository | undefined;
193
private readonly _loadBranchesCts = this._register(new MutableDisposable<CancellationTokenSource>());
194
195
// -- Branch state --
196
197
private readonly _branches = observableValue<readonly string[]>(this, []);
198
readonly branches: IObservable<readonly string[]> = this._branches;
199
200
private _defaultBranch: string | undefined;
201
202
// -- New session configuration fields --
203
204
private _repoUri: URI | undefined;
205
private _isolationMode: IsolationMode;
206
private _branch: string | undefined;
207
private _modelId: string | undefined;
208
private _mode: IChatMode | undefined;
209
private _query: string | undefined;
210
private _attachedContext: IChatRequestVariableEntry[] | undefined;
211
212
readonly target = AgentSessionProviders.Background;
213
readonly selectedOptions = new Map<string, IChatSessionProviderOptionItem>();
214
215
get selectedModelId(): string | undefined { return this._modelId; }
216
get chatMode(): IChatMode | undefined { return this._mode; }
217
get query(): string | undefined { return this._query; }
218
get attachedContext(): IChatRequestVariableEntry[] | undefined { return this._attachedContext; }
219
get gitRepository(): IGitRepository | undefined { return this._gitRepository; }
220
get disabled(): boolean {
221
if (!this._repoUri) {
222
return true;
223
}
224
if (this._isolationMode === 'worktree' && !this._branch) {
225
return true;
226
}
227
return false;
228
}
229
230
constructor(
231
readonly resource: URI,
232
readonly sessionWorkspace: ISessionWorkspace,
233
providerId: string,
234
@IChatSessionsService private readonly chatSessionsService: IChatSessionsService,
235
@IGitService private readonly gitService: IGitService,
236
) {
237
super();
238
this.id = toSessionId(providerId, resource);
239
this.providerId = providerId;
240
this.sessionType = AgentSessionProviders.Background;
241
this.icon = CopilotCLISessionType.icon;
242
this.createdAt = new Date();
243
244
const repoUri = sessionWorkspace.repositories[0]?.uri;
245
if (repoUri) {
246
this._repoUri = repoUri;
247
this.setOption(REPOSITORY_OPTION_ID, repoUri.fsPath);
248
}
249
250
// Set ISessionData workspace observable
251
this._workspaceData.set(sessionWorkspace, undefined);
252
253
this._isolationMode = 'worktree';
254
this.setOption(ISOLATION_OPTION_ID, 'worktree');
255
256
// Resolve git repository asynchronously
257
this._resolveGitRepository();
258
259
this._description = observableValue(this, undefined);
260
this.description = this._description;
261
262
this._changes = observableValue<readonly ISessionFileChange[]>(this, []);
263
this.changes = this._changes;
264
}
265
266
private async _resolveGitRepository(): Promise<void> {
267
const repoUri = this.sessionWorkspace.repositories[0]?.uri;
268
if (repoUri) {
269
try {
270
this._gitRepository = await this.gitService.openRepository(repoUri);
271
if (!this._gitRepository) {
272
this.setIsolationMode('workspace');
273
} else if (!this._gitRepository.state.get().HEAD?.commit) {
274
// Empty repositories have no HEAD commit and cannot run worktree isolation.
275
this.setIsolationMode('workspace');
276
}
277
} catch {
278
// No git repository available
279
this.setIsolationMode('workspace');
280
}
281
}
282
if (this._gitRepository) {
283
this._loadBranches(this._gitRepository);
284
285
// Automatically update the selected branch when the repository
286
// state changes. This is done only for the Folder sessions.
287
const currentBranchName = derived(reader => {
288
const state = this._gitRepository?.state.read(reader);
289
return state?.HEAD?.commit ? state.HEAD.name : undefined;
290
});
291
292
this._register(autorun(reader => {
293
const isolationMode = this.isolationMode.read(reader);
294
if (isolationMode === 'worktree') {
295
return;
296
}
297
298
const currentBranch = currentBranchName.read(reader);
299
this.setBranch(currentBranch ?? this._defaultBranch);
300
}));
301
}
302
this._loading.set(false, undefined);
303
}
304
305
private _loadBranches(repo: IGitRepository): void {
306
this._loadBranchesCts.value?.cancel();
307
const cts = this._loadBranchesCts.value = new CancellationTokenSource();
308
309
repo.getRefs({ pattern: 'refs/heads' }, cts.token).then(refs => {
310
if (cts.token.isCancellationRequested) {
311
return;
312
}
313
const hasHeadCommit = !!repo.state.get().HEAD?.commit;
314
const branches = refs
315
.map(r => r.name)
316
.filter((name): name is string => !!name)
317
.filter(name => !name.includes(CopilotCLISession.COPILOT_WORKTREE_PATTERN));
318
319
const defaultBranch = hasHeadCommit
320
? (branches.find(b => b === 'main')
321
?? branches.find(b => b === 'master')
322
?? branches.find(b => b === repo.state.get().HEAD?.name)
323
?? branches[0])
324
: undefined;
325
326
this._defaultBranch = defaultBranch;
327
328
transaction(tx => {
329
this._branches.set(branches, tx);
330
});
331
332
if (defaultBranch && !this._branch) {
333
this.setBranch(defaultBranch);
334
}
335
}).catch(() => {
336
if (!cts.token.isCancellationRequested) {
337
transaction(tx => {
338
this._branches.set([], tx);
339
});
340
}
341
});
342
}
343
344
setIsolationMode(mode: IsolationMode): void {
345
if (this._isolationMode !== mode) {
346
this._isolationMode = mode;
347
this._isolationModeObservable.set(mode, undefined);
348
this.setOption(ISOLATION_OPTION_ID, mode);
349
350
if (mode === 'workspace') {
351
// When switching to workspace mode, update the branch
352
// selection to reflect the current branch as that is
353
// what will be used for the folder session
354
const head = this._gitRepository?.state.get().HEAD;
355
const currentBranch = head?.commit ? head.name : undefined;
356
this.setBranch(currentBranch ?? this._defaultBranch);
357
} else {
358
this.setBranch(this._defaultBranch);
359
}
360
}
361
}
362
363
setBranch(branch: string | undefined): void {
364
if (this._branch !== branch) {
365
this._branch = branch;
366
this._branchObservable.set(branch, undefined);
367
this.setOption(BRANCH_OPTION_ID, branch ?? '');
368
}
369
}
370
371
setModelId(modelId: string | undefined): void {
372
this._modelId = modelId;
373
this._modelIdObservable.set(modelId, undefined);
374
}
375
376
setModeById(modeId: string, modeKind: string): void {
377
this._modeObservable.set({ id: modeId, kind: modeKind }, undefined);
378
}
379
380
setPermissionLevel(level: ChatPermissionLevel): void {
381
this._permissionLevel.set(level, undefined);
382
}
383
384
setTitle(title: string): void {
385
this._title.set(title, undefined);
386
}
387
388
setStatus(status: SessionStatus): void {
389
this._status.set(status, undefined);
390
}
391
392
setArchived(archived: boolean): void {
393
this._isArchived.set(archived, undefined);
394
}
395
396
setMode(mode: IChatMode | undefined): void {
397
if (this._mode?.id !== mode?.id) {
398
this._mode = mode;
399
const modeName = mode?.isBuiltin ? undefined : mode?.name.get();
400
this.setOption(AGENT_OPTION_ID, modeName ?? '');
401
}
402
}
403
404
setOption(optionId: string, value: IChatSessionProviderOptionItem | string): void {
405
if (typeof value === 'string') {
406
this.selectedOptions.set(optionId, { id: value, name: value });
407
} else {
408
this.selectedOptions.set(optionId, value);
409
}
410
this.chatSessionsService.setSessionOption(this.resource, optionId, value);
411
}
412
413
update(agentSession: IAgentSession): void {
414
const session = new AgentSessionAdapter(agentSession, this.providerId, undefined);
415
this._workspaceData.set(session.workspace.get(), undefined);
416
this._title.set(session.title.get(), undefined);
417
this._status.set(session.status.get(), undefined);
418
this._updatedAt.set(session.updatedAt.get(), undefined);
419
this._changes.set(session.changes.get(), undefined);
420
this._description.set(session.description.get(), undefined);
421
}
422
}
423
424
function isModelOptionGroup(group: IChatSessionProviderOptionGroup): boolean {
425
if (group.id === 'models') {
426
return true;
427
}
428
const nameLower = group.name.toLowerCase();
429
return nameLower === 'model' || nameLower === 'models';
430
}
431
432
function isRepositoriesOptionGroup(group: IChatSessionProviderOptionGroup): boolean {
433
return group.id === 'repositories';
434
}
435
436
/**
437
* Remote new session for Cloud agent sessions.
438
* Implements {@link ICopilotChatSession} (session facade) and provides
439
* pre-send configuration methods for the new-session flow.
440
*/
441
export class RemoteNewSession extends Disposable implements ICopilotChatSession {
442
443
// -- ISessionData fields --
444
445
readonly id: string;
446
readonly providerId: string;
447
readonly sessionType: string;
448
readonly icon: ThemeIcon;
449
readonly createdAt: Date;
450
451
private readonly _title = observableValue(this, '');
452
readonly title: IObservable<string> = this._title;
453
454
private readonly _updatedAt = observableValue(this, new Date());
455
readonly updatedAt: IObservable<Date> = this._updatedAt;
456
457
private readonly _status = observableValue(this, SessionStatus.Untitled);
458
readonly status: IObservable<SessionStatus> = this._status;
459
460
private readonly _permissionLevel = observableValue(this, ChatPermissionLevel.Default);
461
readonly permissionLevel: IObservable<ChatPermissionLevel> = this._permissionLevel;
462
463
private readonly _workspaceData = observableValue<ISessionWorkspace | undefined>(this, undefined);
464
readonly workspace: IObservable<ISessionWorkspace | undefined> = this._workspaceData;
465
466
readonly changes: IObservable<readonly ISessionFileChange[]> = observableValue<readonly ISessionFileChange[]>(this, []);
467
468
private readonly _modelIdObservable = observableValue<string | undefined>(this, undefined);
469
readonly modelId: IObservable<string | undefined> = this._modelIdObservable;
470
471
readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined> = observableValue(this, undefined);
472
473
readonly loading: IObservable<boolean> = observableValue(this, false);
474
475
private readonly _isArchived = observableValue(this, false);
476
readonly isArchived: IObservable<boolean> = this._isArchived;
477
readonly isRead: IObservable<boolean> = observableValue(this, true);
478
readonly description: IObservable<IMarkdownString | undefined> = constObservable(undefined);
479
readonly lastTurnEnd: IObservable<Date | undefined> = constObservable(undefined);
480
readonly gitHubInfo: IObservable<IGitHubInfo | undefined> = constObservable(undefined);
481
readonly branch: IObservable<string | undefined> = constObservable(undefined);
482
readonly isolationMode: IObservable<IsolationMode | undefined> = constObservable(undefined);
483
readonly branches: IObservable<readonly string[]> = constObservable([]);
484
readonly gitRepository?: IGitRepository | undefined;
485
486
readonly _hasGitRepo = observableValue(this, false);
487
readonly hasGitRepo: IObservable<boolean> = this._hasGitRepo;
488
489
// -- New session configuration fields --
490
491
private _repoUri: URI | undefined;
492
private _project: ISessionWorkspace | undefined;
493
private _modelId: string | undefined;
494
private _query: string | undefined;
495
private _attachedContext: IChatRequestVariableEntry[] | undefined;
496
497
private readonly _onDidChangeOptionGroups = this._register(new Emitter<void>());
498
readonly onDidChangeOptionGroups: Event<void> = this._onDidChangeOptionGroups.event;
499
500
readonly selectedOptions = new Map<string, IChatSessionProviderOptionItem>();
501
502
get project(): ISessionWorkspace | undefined { return this._project; }
503
get selectedModelId(): string | undefined { return this._modelId; }
504
get chatMode(): IChatMode | undefined { return undefined; }
505
get query(): string | undefined { return this._query; }
506
get attachedContext(): IChatRequestVariableEntry[] | undefined { return this._attachedContext; }
507
get disabled(): boolean {
508
return !this._repoUri && !this.selectedOptions.has('repositories');
509
}
510
511
private readonly _whenClauseKeys = new Set<string>();
512
513
constructor(
514
readonly resource: URI,
515
readonly sessionWorkspace: ISessionWorkspace,
516
readonly target: AgentSessionTarget,
517
providerId: string,
518
@IChatSessionsService private readonly chatSessionsService: IChatSessionsService,
519
@IContextKeyService private readonly contextKeyService: IContextKeyService,
520
) {
521
super();
522
this.id = toSessionId(providerId, resource);
523
this.providerId = providerId;
524
this.sessionType = target;
525
this.icon = CopilotCloudSessionType.icon;
526
this.createdAt = new Date();
527
528
this._updateWhenClauseKeys();
529
this._register(this.chatSessionsService.onDidChangeOptionGroups(() => {
530
this._updateWhenClauseKeys();
531
this._onDidChangeOptionGroups.fire();
532
}));
533
this._register(this.contextKeyService.onDidChangeContext(e => {
534
if (this._whenClauseKeys.size > 0 && e.affectsSome(this._whenClauseKeys)) {
535
this._onDidChangeOptionGroups.fire();
536
}
537
}));
538
539
// Set workspace data
540
this._workspaceData.set(sessionWorkspace, undefined);
541
this._repoUri = sessionWorkspace.repositories[0]?.uri;
542
if (this._repoUri) {
543
const id = this._repoUri.path.substring(1);
544
this.setOption('repositories', { id, name: id });
545
}
546
547
}
548
setPermissionLevel(level: ChatPermissionLevel): void {
549
throw new Error('Method not implemented.');
550
}
551
552
// -- New session configuration methods --
553
554
setIsolationMode(_mode: IsolationMode): void {
555
// No-op for remote sessions
556
}
557
558
setBranch(_branch: string | undefined): void {
559
// No-op for remote sessions
560
}
561
562
setModelId(modelId: string | undefined): void {
563
this._modelId = modelId;
564
}
565
566
setTitle(title: string): void {
567
this._title.set(title, undefined);
568
}
569
570
setStatus(status: SessionStatus): void {
571
this._status.set(status, undefined);
572
}
573
574
setArchived(archived: boolean): void {
575
this._isArchived.set(archived, undefined);
576
}
577
578
setMode(_mode: IChatMode | undefined): void {
579
// Intentionally a no-op: remote sessions do not support client-side mode selection.
580
}
581
582
setOption(optionId: string, value: IChatSessionProviderOptionItem | string): void {
583
if (typeof value !== 'string') {
584
this.selectedOptions.set(optionId, value);
585
}
586
this.chatSessionsService.setSessionOption(this.resource, optionId, value);
587
}
588
589
// --- Option group accessors ---
590
591
getModelOptionGroup(): ISessionOptionGroup | undefined {
592
const groups = this._getOptionGroups();
593
if (!groups) {
594
return undefined;
595
}
596
const group = groups.find(g => isModelOptionGroup(g));
597
if (!group) {
598
return undefined;
599
}
600
return { group, value: this._getValueForGroup(group) };
601
}
602
603
getOtherOptionGroups(): ISessionOptionGroup[] {
604
const groups = this._getOptionGroups();
605
if (!groups) {
606
return [];
607
}
608
return groups
609
.filter(g => !isModelOptionGroup(g) && !isRepositoriesOptionGroup(g) && this._isOptionGroupVisible(g))
610
.map(g => ({ group: g, value: this._getValueForGroup(g) }));
611
}
612
613
getOptionValue(groupId: string): IChatSessionProviderOptionItem | undefined {
614
return this.selectedOptions.get(groupId);
615
}
616
617
setOptionValue(groupId: string, value: IChatSessionProviderOptionItem): void {
618
this.setOption(groupId, value);
619
}
620
621
// --- Internals ---
622
623
private _getOptionGroups(): IChatSessionProviderOptionGroup[] | undefined {
624
return this.chatSessionsService.getOptionGroupsForSessionType(this.target);
625
}
626
627
private _isOptionGroupVisible(group: IChatSessionProviderOptionGroup): boolean {
628
if (!group.when) {
629
return true;
630
}
631
const expr = ContextKeyExpr.deserialize(group.when);
632
return !expr || this.contextKeyService.contextMatchesRules(expr);
633
}
634
635
private _updateWhenClauseKeys(): void {
636
this._whenClauseKeys.clear();
637
const groups = this._getOptionGroups();
638
if (!groups) {
639
return;
640
}
641
for (const group of groups) {
642
if (group.when) {
643
const expr = ContextKeyExpr.deserialize(group.when);
644
if (expr) {
645
for (const key of expr.keys()) {
646
this._whenClauseKeys.add(key);
647
}
648
}
649
}
650
}
651
}
652
653
private _getValueForGroup(group: IChatSessionProviderOptionGroup): IChatSessionProviderOptionItem | undefined {
654
const selected = this.selectedOptions.get(group.id);
655
if (selected) {
656
return selected;
657
}
658
// Check for extension-set session option
659
const sessionOption = this.chatSessionsService.getSessionOption(this.resource, group.id);
660
if (sessionOption && typeof sessionOption !== 'string') {
661
return sessionOption;
662
}
663
if (typeof sessionOption === 'string') {
664
const item = group.items.find(i => i.id === sessionOption.trim());
665
if (item) {
666
return item;
667
}
668
}
669
// Default to first item marked as default, or first item
670
return group.items.find(i => i.default === true) ?? group.items[0];
671
}
672
673
update(_session: IAgentSession): void { }
674
}
675
676
/**
677
* New session for Claude agent sessions.
678
* Implements {@link ICopilotChatSession} (session facade) and provides
679
* pre-send configuration methods for the new-session flow.
680
* Simpler than {@link CopilotCLISession} because the Claude agent manages
681
* its own worktrees and branches at runtime.
682
*/
683
class ClaudeCodeNewSession extends Disposable implements ICopilotChatSession {
684
685
// -- ISessionData fields --
686
687
readonly id: string;
688
readonly providerId: string;
689
readonly sessionType: string;
690
readonly icon: ThemeIcon;
691
readonly createdAt: Date;
692
693
private readonly _title = observableValue(this, '');
694
readonly title: IObservable<string> = this._title;
695
696
private readonly _updatedAt = observableValue(this, new Date());
697
readonly updatedAt: IObservable<Date> = this._updatedAt;
698
699
private readonly _status = observableValue(this, SessionStatus.Untitled);
700
readonly status: IObservable<SessionStatus> = this._status;
701
702
private readonly _permissionLevel = observableValue(this, ChatPermissionLevel.Default);
703
readonly permissionLevel: IObservable<ChatPermissionLevel> = this._permissionLevel;
704
705
private readonly _workspaceData = observableValue<ISessionWorkspace | undefined>(this, undefined);
706
readonly workspace: IObservable<ISessionWorkspace | undefined> = this._workspaceData;
707
708
readonly changes: IObservable<readonly ISessionFileChange[]> = observableValue<readonly ISessionFileChange[]>(this, []);
709
710
private readonly _modelIdObservable = observableValue<string | undefined>(this, undefined);
711
readonly modelId: IObservable<string | undefined> = this._modelIdObservable;
712
713
private readonly _modeObservable = observableValue<{ readonly id: string; readonly kind: string } | undefined>(this, undefined);
714
readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined> = this._modeObservable;
715
716
readonly loading: IObservable<boolean> = observableValue(this, false);
717
718
private readonly _isArchived = observableValue(this, false);
719
readonly isArchived: IObservable<boolean> = this._isArchived;
720
readonly isRead: IObservable<boolean> = observableValue(this, true);
721
readonly description: IObservable<IMarkdownString | undefined> = constObservable(undefined);
722
readonly lastTurnEnd: IObservable<Date | undefined> = constObservable(undefined);
723
readonly gitHubInfo: IObservable<IGitHubInfo | undefined> = constObservable(undefined);
724
readonly branch: IObservable<string | undefined> = constObservable(undefined);
725
readonly isolationMode: IObservable<IsolationMode | undefined> = constObservable(undefined);
726
readonly branches: IObservable<readonly string[]> = constObservable([]);
727
readonly gitRepository?: IGitRepository | undefined;
728
729
// -- New session configuration fields --
730
731
private _modelId: string | undefined;
732
private _mode: IChatMode | undefined;
733
734
readonly target = AgentSessionProviders.Claude;
735
readonly selectedOptions = new Map<string, IChatSessionProviderOptionItem>();
736
737
get selectedModelId(): string | undefined { return this._modelId; }
738
get chatMode(): IChatMode | undefined { return this._mode; }
739
get query(): string | undefined { return undefined; }
740
get attachedContext(): IChatRequestVariableEntry[] | undefined { return undefined; }
741
get disabled(): boolean { return false; }
742
743
constructor(
744
readonly resource: URI,
745
readonly sessionWorkspace: ISessionWorkspace,
746
providerId: string,
747
) {
748
super();
749
this.id = toSessionId(providerId, resource);
750
this.providerId = providerId;
751
this.sessionType = AgentSessionProviders.Claude;
752
this.icon = ClaudeCodeSessionType.icon;
753
this.createdAt = new Date();
754
755
this._workspaceData.set(sessionWorkspace, undefined);
756
}
757
758
setOption(optionId: string, value: IChatSessionProviderOptionItem | string): void {
759
if (typeof value === 'string') {
760
this.selectedOptions.set(optionId, { id: value, name: value });
761
} else {
762
this.selectedOptions.set(optionId, value);
763
}
764
}
765
766
setPermissionLevel(level: ChatPermissionLevel): void {
767
this._permissionLevel.set(level, undefined);
768
}
769
770
setIsolationMode(_mode: IsolationMode): void {
771
// No-op — Claude agent manages its own worktrees
772
}
773
774
setBranch(_branch: string | undefined): void {
775
// No-op — Claude agent manages branches at runtime
776
}
777
778
setModelId(modelId: string | undefined): void {
779
this._modelId = modelId;
780
this._modelIdObservable.set(modelId, undefined);
781
}
782
783
setTitle(title: string): void {
784
this._title.set(title, undefined);
785
}
786
787
setStatus(status: SessionStatus): void {
788
this._status.set(status, undefined);
789
}
790
791
setArchived(archived: boolean): void {
792
this._isArchived.set(archived, undefined);
793
}
794
795
setMode(mode: IChatMode | undefined): void {
796
this._mode = mode;
797
if (mode) {
798
this._modeObservable.set({ id: mode.id, kind: mode.kind }, undefined);
799
} else {
800
this._modeObservable.set(undefined, undefined);
801
}
802
}
803
804
update(_session: IAgentSession): void { }
805
}
806
807
/**
808
* Maps the existing {@link ChatSessionStatus} to the new {@link SessionStatus}.
809
*/
810
function toSessionStatus(status: ChatSessionStatus): SessionStatus {
811
switch (status) {
812
case ChatSessionStatus.InProgress:
813
return SessionStatus.InProgress;
814
case ChatSessionStatus.NeedsInput:
815
return SessionStatus.NeedsInput;
816
case ChatSessionStatus.Completed:
817
return SessionStatus.Completed;
818
case ChatSessionStatus.Failed:
819
return SessionStatus.Error;
820
}
821
}
822
823
/**
824
* Adapts an existing {@link IAgentSession} from the chat layer into the new {@link ICopilotChatSession} facade.
825
*/
826
class AgentSessionAdapter implements ICopilotChatSession {
827
828
readonly id: string;
829
readonly resource: URI;
830
readonly providerId: string;
831
readonly sessionType: string;
832
readonly icon: ThemeIcon;
833
readonly createdAt: Date;
834
835
private readonly _workspace: ReturnType<typeof observableValue<ISessionWorkspace | undefined>>;
836
readonly workspace: IObservable<ISessionWorkspace | undefined>;
837
838
private readonly _title: ReturnType<typeof observableValue<string>>;
839
readonly title: IObservable<string>;
840
841
private readonly _updatedAt: ReturnType<typeof observableValue<Date>>;
842
readonly updatedAt: IObservable<Date>;
843
844
private readonly _status: ReturnType<typeof observableValue<SessionStatus>>;
845
readonly status: IObservable<SessionStatus>;
846
847
private readonly _changes: ReturnType<typeof observableValue<readonly ISessionFileChange[]>>;
848
readonly changes: IObservable<readonly ISessionFileChange[]>;
849
850
readonly modelId: IObservable<string | undefined>;
851
readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined>;
852
readonly loading: IObservable<boolean>;
853
854
private readonly _isArchived: ReturnType<typeof observableValue<boolean>>;
855
readonly isArchived: IObservable<boolean>;
856
857
private readonly _isRead: ReturnType<typeof observableValue<boolean>>;
858
readonly isRead: IObservable<boolean>;
859
860
private readonly _description: ReturnType<typeof observableValue<IMarkdownString | undefined>>;
861
readonly description: IObservable<IMarkdownString | undefined>;
862
863
private readonly _lastTurnEnd: ReturnType<typeof observableValue<Date | undefined>>;
864
readonly lastTurnEnd: IObservable<Date | undefined>;
865
866
private readonly _baseGitHubInfo: ReturnType<typeof observableValue<IGitHubInfo | undefined>>;
867
readonly gitHubInfo: IObservable<IGitHubInfo | undefined>;
868
869
readonly permissionLevel: IObservable<ChatPermissionLevel> = constObservable(ChatPermissionLevel.Default);
870
readonly branch: IObservable<string | undefined> = constObservable(undefined);
871
readonly isolationMode: IObservable<IsolationMode | undefined> = constObservable(undefined);
872
readonly gitRepository?: IGitRepository | undefined;
873
readonly branches: IObservable<readonly string[]> = constObservable([]);
874
875
constructor(
876
session: IAgentSession,
877
providerId: string,
878
private readonly _gitHubService: IGitHubService | undefined,
879
) {
880
this.id = toSessionId(providerId, session.resource);
881
this.resource = session.resource;
882
this.providerId = providerId;
883
this.sessionType = session.providerType;
884
this.icon = this._getSessionTypeIcon(session);
885
this.createdAt = new Date(session.timing.created);
886
this._workspace = observableValue(this, this._buildWorkspace(session));
887
this.workspace = this._workspace;
888
889
this._title = observableValue(this, session.label);
890
this.title = this._title;
891
892
const updatedTime = session.timing.lastRequestEnded ?? session.timing.lastRequestStarted ?? session.timing.created;
893
this._updatedAt = observableValue(this, new Date(updatedTime));
894
this.updatedAt = this._updatedAt;
895
896
this._status = observableValue(this, toSessionStatus(session.status));
897
this.status = this._status;
898
899
this._changes = observableValue<readonly ISessionFileChange[]>(this, this._extractChanges(session));
900
this.changes = this._changes;
901
902
this.modelId = observableValue(this, undefined);
903
this.mode = observableValue(this, undefined);
904
this.loading = observableValue(this, false);
905
906
this._isArchived = observableValue(this, session.isArchived());
907
this.isArchived = this._isArchived;
908
this._isRead = observableValue(this, session.isRead());
909
this.isRead = this._isRead;
910
this._description = observableValue(this, this._extractDescription(session));
911
this.description = this._description;
912
this._lastTurnEnd = observableValue(this, session.timing.lastRequestEnded ? new Date(session.timing.lastRequestEnded) : undefined);
913
this.lastTurnEnd = this._lastTurnEnd;
914
this._baseGitHubInfo = observableValue(this, this._extractGitHubInfo(session));
915
this.gitHubInfo = this._gitHubService
916
? derived(this, reader => {
917
const base = this._baseGitHubInfo.read(reader);
918
if (!base?.pullRequest || !this._gitHubService) {
919
return base;
920
}
921
const prModel = this._gitHubService.getPullRequest(base.owner, base.repo, base.pullRequest.number);
922
const livePR = prModel.pullRequest.read(reader);
923
if (!livePR) {
924
return base;
925
}
926
return { ...base, pullRequest: { ...base.pullRequest, icon: computePullRequestIcon(livePR.isDraft ? 'draft' : livePR.state) } };
927
})
928
: this._baseGitHubInfo;
929
}
930
931
setPermissionLevel(level: ChatPermissionLevel): void {
932
throw new Error('Method not implemented.');
933
}
934
setBranch(branch: string | undefined): void {
935
throw new Error('Method not implemented.');
936
}
937
setIsolationMode(mode: IsolationMode): void {
938
throw new Error('Method not implemented.');
939
}
940
setModelId(modelId: string): void {
941
throw new Error('Method not implemented.');
942
}
943
setMode(chatMode: IChatMode | undefined): void {
944
throw new Error('Method not implemented.');
945
}
946
947
/**
948
* Update reactive properties from a refreshed agent session.
949
*/
950
update(session: IAgentSession): void {
951
transaction(tx => {
952
this._title.set(session.label, tx);
953
const updatedTime = session.timing.lastRequestEnded ?? session.timing.lastRequestStarted ?? session.timing.created;
954
this._updatedAt.set(new Date(updatedTime), tx);
955
this._status.set(toSessionStatus(session.status), tx);
956
this._changes.set(this._extractChanges(session), tx);
957
this._isArchived.set(session.isArchived(), tx);
958
this._isRead.set(session.isRead(), tx);
959
this._description.set(this._extractDescription(session), tx);
960
this._lastTurnEnd.set(session.timing.lastRequestEnded ? new Date(session.timing.lastRequestEnded) : undefined, tx);
961
this._baseGitHubInfo.set(this._extractGitHubInfo(session), tx);
962
});
963
}
964
965
private _getSessionTypeIcon(session: IAgentSession): ThemeIcon {
966
switch (session.providerType) {
967
case AgentSessionProviders.Background:
968
return CopilotCLISessionType.icon;
969
case AgentSessionProviders.Cloud:
970
return CopilotCloudSessionType.icon;
971
case AgentSessionProviders.Claude:
972
return ClaudeCodeSessionType.icon;
973
default:
974
return session.icon;
975
}
976
}
977
978
private _extractDescription(session: IAgentSession): IMarkdownString | undefined {
979
if (!session.description) {
980
return undefined;
981
}
982
return typeof session.description === 'string' ? new MarkdownString(session.description) : session.description;
983
}
984
985
private _extractGitHubInfo(session: IAgentSession): IGitHubInfo | undefined {
986
const metadata = session.metadata;
987
if (!metadata) {
988
return undefined;
989
}
990
991
const { owner, repo } = this._extractOwnerRepo(session);
992
if (!owner || !repo) {
993
return undefined;
994
}
995
996
const pullRequestUri = this._extractPullRequestUri(session);
997
if (!pullRequestUri) {
998
return { owner, repo };
999
}
1000
1001
const prNumber = this._extractPullRequestNumber(session, pullRequestUri);
1002
if (prNumber === undefined) {
1003
return { owner, repo };
1004
}
1005
1006
return { owner, repo, pullRequest: { number: prNumber, uri: pullRequestUri, icon: this._extractPullRequestStateIcon(session) } };
1007
}
1008
1009
private _extractPullRequestNumber(session: IAgentSession, pullRequestUri: URI): number | undefined {
1010
const metadata = session.metadata;
1011
if (typeof metadata?.pullRequestNumber === 'number') {
1012
return metadata.pullRequestNumber as number;
1013
}
1014
const match = /\/pull\/(\d+)/.exec(pullRequestUri.path);
1015
if (match) {
1016
return parseInt(match[1], 10);
1017
}
1018
return undefined;
1019
}
1020
1021
private _extractOwnerRepo(session: IAgentSession): { owner: string | undefined; repo: string | undefined } {
1022
const metadata = session.metadata;
1023
if (!metadata) {
1024
return { owner: undefined, repo: undefined };
1025
}
1026
1027
// Direct owner + name fields
1028
if (typeof metadata.owner === 'string' && typeof metadata.name === 'string') {
1029
return { owner: metadata.owner, repo: metadata.name };
1030
}
1031
1032
// repositoryNwo: "owner/repo"
1033
if (typeof metadata.repositoryNwo === 'string') {
1034
const parts = (metadata.repositoryNwo as string).split('/');
1035
if (parts.length === 2) {
1036
return { owner: parts[0], repo: parts[1] };
1037
}
1038
}
1039
1040
// Parse from workspace repository URI (cloud sessions)
1041
const repoUri = this._buildWorkspace(session)?.repositories[0]?.uri;
1042
if (repoUri && repoUri.scheme === GITHUB_REMOTE_FILE_SCHEME) {
1043
const parts = repoUri.path.split('/').filter(Boolean);
1044
if (parts.length >= 2) {
1045
return { owner: decodeURIComponent(parts[0]), repo: decodeURIComponent(parts[1]) };
1046
}
1047
}
1048
1049
// Parse from pullRequestUrl
1050
if (typeof metadata.pullRequestUrl === 'string') {
1051
const match = /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(metadata.pullRequestUrl as string);
1052
if (match) {
1053
return { owner: match[1], repo: match[2] };
1054
}
1055
}
1056
1057
return { owner: undefined, repo: undefined };
1058
}
1059
1060
private _extractPullRequestStateIcon(session: IAgentSession): ThemeIcon | undefined {
1061
const metadata = session.metadata;
1062
const state = metadata?.pullRequestState;
1063
if (typeof state === 'string') {
1064
return computePullRequestIcon(state as GitHubPullRequestState | 'draft');
1065
}
1066
return undefined;
1067
}
1068
1069
private _extractPullRequestUri(session: IAgentSession): URI | undefined {
1070
const metadata = session.metadata;
1071
if (!metadata) {
1072
return undefined;
1073
}
1074
1075
const url = metadata.pullRequestUrl as string | undefined;
1076
if (url) {
1077
try {
1078
return URI.parse(url);
1079
} catch {
1080
// fall through
1081
}
1082
}
1083
1084
// Construct from pullRequestNumber + owner/repo
1085
const prNumber = metadata.pullRequestNumber as number | undefined;
1086
if (typeof prNumber === 'number') {
1087
const owner = metadata.owner as string | undefined;
1088
const name = metadata.name as string | undefined;
1089
if (owner && name) {
1090
return URI.parse(`https://github.com/${owner}/${name}/pull/${prNumber}`);
1091
}
1092
}
1093
1094
return undefined;
1095
}
1096
1097
private _extractChanges(session: IAgentSession): readonly ISessionFileChange[] {
1098
if (!session.changes) {
1099
return [];
1100
}
1101
if (Array.isArray(session.changes)) {
1102
return session.changes as ISessionFileChange[];
1103
}
1104
// Summary object — create a synthetic entry for total insertions/deletions
1105
const summary = session.changes as { readonly files: number; readonly insertions: number; readonly deletions: number };
1106
if (summary.insertions > 0 || summary.deletions > 0) {
1107
return [{
1108
modifiedUri: URI.parse('summary://changes'),
1109
insertions: summary.insertions,
1110
deletions: summary.deletions,
1111
}];
1112
}
1113
return [];
1114
}
1115
1116
private _buildWorkspace(session: IAgentSession): ISessionWorkspace | undefined {
1117
const [repoUri, worktreeUri, branchName, baseBranchName] = this._extractRepositoryFromMetadata(session);
1118
1119
const repository: ISessionRepository = {
1120
uri: repoUri ?? URI.parse('unknown:///'),
1121
workingDirectory: worktreeUri,
1122
detail: branchName,
1123
baseBranchName,
1124
};
1125
1126
return {
1127
label: getRepositoryName(session) ?? basename(repository.uri),
1128
icon: repoUri?.scheme === GITHUB_REMOTE_FILE_SCHEME ? Codicon.repo : Codicon.folder,
1129
group: repoUri?.scheme === GITHUB_REMOTE_FILE_SCHEME ? SESSION_WORKSPACE_GROUP_GITHUB : SESSION_WORKSPACE_GROUP_LOCAL,
1130
repositories: [repository],
1131
requiresWorkspaceTrust: session.providerType !== AgentSessionProviders.Cloud,
1132
};
1133
}
1134
1135
/**
1136
* Extract repository/worktree information from session metadata.
1137
* Mirrors the logic in sessionsManagementService.getRepositoryFromMetadata().
1138
*/
1139
private _extractRepositoryFromMetadata(session: IAgentSession): [URI | undefined, URI | undefined, string | undefined, string | undefined] {
1140
const metadata = session.metadata;
1141
if (!metadata) {
1142
return [undefined, undefined, undefined, undefined];
1143
}
1144
1145
if (session.providerType === AgentSessionProviders.Cloud) {
1146
const branch = typeof metadata.branch === 'string' ? metadata.branch : 'HEAD';
1147
const repositoryUri = URI.from({
1148
scheme: GITHUB_REMOTE_FILE_SCHEME,
1149
authority: 'github',
1150
path: `/${metadata.owner}/${metadata.name}/${encodeURIComponent(branch)}`
1151
});
1152
return [repositoryUri, undefined, undefined, undefined];
1153
}
1154
1155
// Background/CLI sessions: check workingDirectoryPath first
1156
const workingDirectoryPath = metadata?.workingDirectoryPath as string | undefined;
1157
if (workingDirectoryPath) {
1158
return [URI.file(workingDirectoryPath), undefined, undefined, undefined];
1159
}
1160
1161
// Fall back to repositoryPath + worktreePath
1162
const repositoryPath = metadata?.repositoryPath as string | undefined;
1163
const repositoryPathUri = typeof repositoryPath === 'string' ? URI.file(repositoryPath) : undefined;
1164
1165
const worktreePath = metadata?.worktreePath as string | undefined;
1166
const worktreePathUri = typeof worktreePath === 'string' ? URI.file(worktreePath) : undefined;
1167
1168
const worktreeBranchName = metadata?.branchName as string | undefined;
1169
const worktreeBaseBranchName = metadata?.baseBranchName as string | undefined;
1170
1171
return [
1172
URI.isUri(repositoryPathUri) ? repositoryPathUri : undefined,
1173
URI.isUri(worktreePathUri) ? worktreePathUri : undefined,
1174
worktreeBranchName,
1175
worktreeBaseBranchName,
1176
];
1177
}
1178
}
1179
1180
/**
1181
* Default sessions provider for Copilot CLI and Cloud session types.
1182
* Wraps the existing session infrastructure into the extensible provider model.
1183
*/
1184
export class CopilotChatSessionsProvider extends Disposable implements ISessionsProvider {
1185
1186
readonly id = COPILOT_PROVIDER_ID;
1187
readonly label = localize('copilotChatSessionsProvider', "Copilot Chat");
1188
readonly icon = Codicon.copilot;
1189
get sessionTypes(): readonly ISessionType[] {
1190
const types: ISessionType[] = [CopilotCLISessionType, CopilotCloudSessionType];
1191
if (this._claudeEnabled) {
1192
types.push(ClaudeCodeSessionType);
1193
}
1194
return types;
1195
}
1196
1197
private readonly _onDidChangeSessionTypes = this._register(new Emitter<void>());
1198
readonly onDidChangeSessionTypes: Event<void> = this._onDidChangeSessionTypes.event;
1199
1200
private readonly _onDidChangeSessions = this._register(new Emitter<ISessionChangeEvent>());
1201
readonly onDidChangeSessions: Event<ISessionChangeEvent> = this._onDidChangeSessions.event;
1202
1203
private readonly _onDidReplaceSession = this._register(new Emitter<{ readonly from: ISession; readonly to: ISession }>());
1204
readonly onDidReplaceSession: Event<{ readonly from: ISession; readonly to: ISession }> = this._onDidReplaceSession.event;
1205
1206
/** Cache of adapted sessions, keyed by resource URI string. */
1207
private readonly _sessionCache = new Map<string, AgentSessionAdapter | CopilotCLISession | RemoteNewSession | ClaudeCodeNewSession>();
1208
1209
/** Cache of ISession wrappers, keyed by session group ID. */
1210
private readonly _sessionGroupCache = new Map<string, ISession>();
1211
1212
/** Cache of chats keyed by raw session ID (resource path without leading slash). */
1213
private _chatByRawSessionIdCache: Map<string, ICopilotChatSession> | undefined;
1214
1215
/** Cache of derived group IDs keyed by chat ID. */
1216
private _groupIdByChatIdCache: Map<string, string> | undefined;
1217
1218
/** Cache of sorted chat IDs keyed by group ID. */
1219
private _chatIdsByGroupIdCache: Map<string, string[]> | undefined;
1220
1221
/**
1222
* Emitter fired when the set of chats in a group changes,
1223
* used to update the chats observable in `_chatToSession`.
1224
*/
1225
private readonly _onDidGroupMembershipChange = this._register(new Emitter<{ sessionId: string }>());
1226
1227
private readonly _multiChatEnabled: boolean;
1228
private _claudeEnabled: boolean;
1229
1230
readonly browseActions: readonly ISessionWorkspaceBrowseAction[];
1231
readonly supportsLocalWorkspaces = true;
1232
1233
constructor(
1234
@IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService,
1235
@IChatService private readonly chatService: IChatService,
1236
@IChatSessionsService private readonly chatSessionsService: IChatSessionsService,
1237
@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,
1238
@IDialogService private readonly dialogService: IDialogService,
1239
@ICommandService private readonly commandService: ICommandService,
1240
@IInstantiationService private readonly instantiationService: IInstantiationService,
1241
@ILanguageModelsService private readonly languageModelsService: ILanguageModelsService,
1242
@ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService,
1243
@IConfigurationService private readonly configurationService: IConfigurationService,
1244
@ILogService private readonly logService: ILogService,
1245
@IGitHubService private readonly gitHubService: IGitHubService,
1246
@ILabelService private readonly labelService: ILabelService,
1247
) {
1248
super();
1249
1250
this._multiChatEnabled = this.configurationService.getValue<boolean>(COPILOT_MULTI_CHAT_SETTING) ?? true;
1251
this._claudeEnabled = this.configurationService.getValue<boolean>(CLAUDE_CODE_ENABLED_SETTING);
1252
1253
this._register(this.configurationService.onDidChangeConfiguration(e => {
1254
if (e.affectsConfiguration(CLAUDE_CODE_ENABLED_SETTING)) {
1255
const claudeEnabled = this.configurationService.getValue<boolean>(CLAUDE_CODE_ENABLED_SETTING);
1256
if (this._claudeEnabled !== claudeEnabled) {
1257
this._claudeEnabled = claudeEnabled;
1258
this._onDidChangeSessionTypes.fire();
1259
this._refreshSessionCache();
1260
}
1261
}
1262
}));
1263
1264
this.browseActions = [
1265
{
1266
label: localize('repositories', "Repositories"),
1267
group: SESSION_WORKSPACE_GROUP_GITHUB,
1268
icon: Codicon.library,
1269
providerId: this.id,
1270
run: () => this._browseForRepo(),
1271
},
1272
];
1273
1274
// Forward session changes from the underlying model
1275
this._register(this.agentSessionsService.model.onDidChangeSessions(() => {
1276
this._refreshSessionCache();
1277
}));
1278
}
1279
1280
// -- Sessions --
1281
1282
getSessionTypes(workspaceUri: URI): ISessionType[] {
1283
if (workspaceUri.scheme === GITHUB_REMOTE_FILE_SCHEME || workspaceUri.scheme === SessionType.CopilotCloud) {
1284
return [CopilotCloudSessionType];
1285
}
1286
const types: ISessionType[] = [CopilotCLISessionType];
1287
if (this._claudeEnabled) {
1288
types.push(ClaudeCodeSessionType);
1289
}
1290
return types;
1291
}
1292
1293
getSessions(): ISession[] {
1294
this._ensureSessionCache();
1295
1296
if (!this._isMultiChatEnabled()) {
1297
return Array.from(this._sessionCache.values()).map(chat => this._chatToSession(chat));
1298
}
1299
1300
const allChats = Array.from(this._sessionCache.values()).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
1301
1302
// Group chats using sessionParentId from metadata
1303
const seen = new Set<string>();
1304
const sessions: ISession[] = [];
1305
1306
for (const chat of allChats) {
1307
const groupId = this._getGroupIdForChat(chat);
1308
if (!seen.has(groupId)) {
1309
seen.add(groupId);
1310
sessions.push(this._chatToSession(chat));
1311
}
1312
}
1313
return sessions;
1314
}
1315
1316
// -- Session Lifecycle --
1317
1318
private _currentNewSession: NewSession | undefined;
1319
1320
getSession(sessionId: string): ICopilotChatSession | undefined {
1321
if (this._currentNewSession?.id === sessionId) {
1322
return this._currentNewSession;
1323
}
1324
return this._findChatSession(sessionId);
1325
}
1326
1327
createNewSession(workspaceUri: URI, sessionTypeId: string): ISession {
1328
if (this._currentNewSession) {
1329
this._currentNewSession.dispose();
1330
this._currentNewSession = undefined;
1331
}
1332
1333
const workspace = this.resolveWorkspace(workspaceUri);
1334
if (!workspace) {
1335
throw new Error(`Cannot resolve workspace for URI: ${workspaceUri.toString()}`);
1336
}
1337
1338
if (workspaceUri.scheme === GITHUB_REMOTE_FILE_SCHEME) {
1339
if (sessionTypeId !== CopilotCloudSessionType.id) {
1340
throw new Error('Only Copilot Cloud sessions can be created for GitHub repositories');
1341
}
1342
const resource = URI.from({ scheme: AgentSessionProviders.Cloud, path: `/untitled-${generateUuid()}` });
1343
const session = this.instantiationService.createInstance(RemoteNewSession, resource, workspace, AgentSessionProviders.Cloud, this.id);
1344
this._currentNewSession = session;
1345
return this._chatToSession(session);
1346
}
1347
1348
if (sessionTypeId === ClaudeCodeSessionType.id) {
1349
const resource = URI.from({ scheme: AgentSessionProviders.Claude, path: `/untitled-${generateUuid()}` });
1350
const session = this.instantiationService.createInstance(ClaudeCodeNewSession, resource, workspace, this.id);
1351
this._currentNewSession = session;
1352
return this._chatToSession(session);
1353
}
1354
1355
if (sessionTypeId !== CopilotCLISessionType.id) {
1356
throw new Error(`Unsupported session type '${sessionTypeId}' for local workspaces`);
1357
}
1358
const resource = URI.from({ scheme: AgentSessionProviders.Background, path: `/untitled-${generateUuid()}` });
1359
const session = this.instantiationService.createInstance(CopilotCLISession, resource, workspace, this.id);
1360
this._currentNewSession = session;
1361
return this._chatToSession(session);
1362
}
1363
1364
setModel(sessionId: string, modelId: string): void {
1365
if (this._currentNewSession?.id === sessionId) {
1366
this._currentNewSession.setModelId(modelId);
1367
}
1368
}
1369
1370
// -- Session Actions --
1371
1372
async archiveSession(sessionId: string): Promise<void> {
1373
const agentSession = this._findAgentSession(sessionId);
1374
if (agentSession) {
1375
agentSession.setArchived(true);
1376
return;
1377
}
1378
1379
// Temp session that hasn't been committed — archive it in-place
1380
// so the user can still review whatever content was produced.
1381
const chatSession = this._findChatSession(sessionId);
1382
if (chatSession && isNewSession(chatSession)) {
1383
chatSession.setArchived(true);
1384
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(chatSession)] });
1385
return;
1386
}
1387
}
1388
1389
async unarchiveSession(sessionId: string): Promise<void> {
1390
const agentSession = this._findAgentSession(sessionId);
1391
if (agentSession) {
1392
agentSession.setArchived(false);
1393
return;
1394
}
1395
1396
// Temp session that hasn't been committed — unarchive it in-place
1397
const chatSession = this._findChatSession(sessionId);
1398
if (chatSession && isNewSession(chatSession)) {
1399
chatSession.setArchived(false);
1400
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(chatSession)] });
1401
}
1402
}
1403
1404
async deleteSession(sessionId: string): Promise<void> {
1405
const chatIds = this._getChatIdsInGroup(sessionId);
1406
1407
// Collect all agent sessions to delete (primary + group members)
1408
const allChatIds = new Set([sessionId, ...chatIds]);
1409
const agentSessions: IAgentSession[] = [];
1410
for (const chatId of allChatIds) {
1411
const agentSession = this._findAgentSession(chatId);
1412
if (agentSession) {
1413
agentSessions.push(agentSession);
1414
}
1415
}
1416
1417
if (agentSessions.length === 0) {
1418
// Temp session that hasn't been committed — remove it directly
1419
this._cleanupTempSession(sessionId);
1420
return;
1421
}
1422
1423
// Confirm deletion
1424
const confirmed = await this.dialogService.confirm({
1425
message: localize('deleteSession.confirm', "Are you sure you want to delete this session?"),
1426
detail: agentSessions.length > 1
1427
? localize('deleteSession.detailMultiple', "This will delete all {0} chats in this session. This action cannot be undone.", agentSessions.length)
1428
: localize('deleteSession.detail', "This action cannot be undone."),
1429
primaryButton: localize('deleteSession.delete', "Delete")
1430
});
1431
if (!confirmed.confirmed) {
1432
return;
1433
}
1434
1435
await this._deleteAgentSessions(agentSessions);
1436
1437
this._sessionGroupCache.delete(sessionId);
1438
this._refreshSessionCache();
1439
}
1440
1441
async renameChat(sessionId: string, chatUri: URI, title: string): Promise<void> {
1442
const agentSession = this.agentSessionsService.getSession(chatUri);
1443
if (agentSession?.providerType === CopilotCLISessionType.id) {
1444
await this.commandService.executeCommand('github.copilot.cli.sessions.setTitle', { resource: chatUri }, title);
1445
return;
1446
}
1447
if (agentSession?.providerType === AgentSessionProviders.Claude) {
1448
await this.commandService.executeCommand('github.copilot.claude.sessions.rename', { resource: chatUri }, title);
1449
return;
1450
}
1451
throw new Error('Renaming is not supported for this session type');
1452
}
1453
1454
async deleteChat(sessionId: string, chatUri: URI): Promise<void> {
1455
const session = this._findSession(sessionId);
1456
1457
if (!session?.capabilities.supportsMultipleChats) {
1458
throw new Error('Deleting individual chats is not supported when multi-chat is disabled');
1459
}
1460
1461
const chatIds = this._getChatIdsInGroup(sessionId);
1462
1463
// Find the chat matching the URI first, before deciding whether to
1464
// delete the entire session. This prevents accidentally deleting the
1465
// whole session when the grouping cache is stale and chatIds doesn't
1466
// include the chat being closed.
1467
const chatId = chatIds.find(id => {
1468
const chat = this._sessionCache.get(this._localIdFromchatId(id));
1469
return chat && chat.resource.toString() === chatUri.toString();
1470
});
1471
if (!chatId) {
1472
return;
1473
}
1474
1475
if (chatIds.length <= 1) {
1476
// This is the only chat in the session — delete the entire session
1477
return this.deleteSession(sessionId);
1478
}
1479
1480
// Delete the underlying agent session first.
1481
// _refreshSessionCacheMultiChat handles the removed chat gracefully:
1482
// it detects the chat belongs to a group with remaining siblings and
1483
// fires a changed event on the parent session instead of a removed event.
1484
const agentSession = this._findAgentSession(chatId);
1485
if (agentSession) {
1486
// Confirm deletion
1487
const confirmed = await this.dialogService.confirm({
1488
message: localize('deleteChat.confirm', "Are you sure you want to delete this chat?"),
1489
detail: localize('deleteChat.detail', "This action cannot be undone."),
1490
primaryButton: localize('deleteChat.delete', "Delete")
1491
});
1492
if (!confirmed.confirmed) {
1493
return;
1494
}
1495
1496
await this._deleteAgentSessions([agentSession]);
1497
} else {
1498
// Untitled chat (not yet committed) - clean up directly
1499
const chat = this._findChatSession(chatId);
1500
if (chat) {
1501
const key = chat.resource.toString();
1502
this._sessionCache.delete(key);
1503
this._invalidateGroupingCaches();
1504
if (this._currentNewSession?.id === chatId) {
1505
this._currentNewSession.dispose();
1506
this._currentNewSession = undefined;
1507
}
1508
}
1509
this._sessionGroupCache.delete(sessionId);
1510
this._onDidGroupMembershipChange.fire({ sessionId });
1511
const remainingChatIds = this._getChatIdsInGroup(sessionId);
1512
const primaryChatId = remainingChatIds[0];
1513
const primaryChat = primaryChatId ? this._sessionCache.get(this._localIdFromchatId(primaryChatId)) : undefined;
1514
if (primaryChat) {
1515
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(primaryChat)] });
1516
}
1517
}
1518
}
1519
1520
private async _deleteAgentSessions(agentSessions: IAgentSession[]): Promise<void> {
1521
const cliSessionItems: { resource: URI }[] = [];
1522
for (const agentSession of agentSessions) {
1523
if (agentSession.providerType === CopilotCLISessionType.id) {
1524
cliSessionItems.push({ resource: agentSession.resource });
1525
} else {
1526
await this.chatService.removeHistoryEntry(agentSession.resource);
1527
}
1528
}
1529
if (cliSessionItems.length > 0) {
1530
await this.commandService.executeCommand('agents.github.copilot.cli.deleteSessions', cliSessionItems, { skipConfirmation: true });
1531
}
1532
}
1533
1534
// -- Send --
1535
1536
async sendAndCreateChat(sessionId: string, options: ISendRequestOptions): Promise<ISession> {
1537
// Determine if this is the first chat or a subsequent chat
1538
const session = this._currentNewSession;
1539
if (session && session.id === sessionId) {
1540
// First chat — use the existing new-session flow
1541
return this._sendFirstChat(session, options);
1542
}
1543
1544
if (!this._isMultiChatEnabled()) {
1545
throw new Error(`Session '${sessionId}' not found or not a new session`);
1546
}
1547
1548
// Subsequent chat — create a new chat within the existing session
1549
return this._sendSubsequentChat(sessionId, options);
1550
}
1551
1552
addChat(sessionId: string): IChat {
1553
const session = this._findSession(sessionId);
1554
if (!session?.capabilities.supportsMultipleChats) {
1555
throw new Error('Multiple chats per session is not supported');
1556
}
1557
1558
const newChatSession = this._createNewSessionFrom(sessionId);
1559
1560
newChatSession.setTitle(localize('new chat', "New Chat"));
1561
const key = newChatSession.resource.toString();
1562
this._sessionCache.set(key, newChatSession);
1563
this._invalidateGroupingCaches();
1564
1565
// Invalidate the session group cache so it rebuilds with the new chat
1566
this._sessionGroupCache.delete(sessionId);
1567
this._onDidGroupMembershipChange.fire({ sessionId });
1568
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(newChatSession)] });
1569
1570
return this._toChat(newChatSession);
1571
}
1572
1573
async sendRequest(sessionId: string, chatResource: URI, options: ISendRequestOptions): Promise<ISession> {
1574
if (!this._isMultiChatEnabled()) {
1575
throw new Error('Multiple chats per session is not supported');
1576
}
1577
1578
// The chat must already exist (created via addChat)
1579
const key = chatResource.toString();
1580
const chatSession = this._sessionCache.get(key);
1581
if (!chatSession || !(chatSession instanceof CopilotCLISession)) {
1582
throw new Error(`Chat '${chatResource.toString()}' not found in session '${sessionId}'`);
1583
}
1584
1585
return this._sendExistingChat(sessionId, chatSession, options);
1586
}
1587
1588
/**
1589
* Sends the first chat for a newly created session.
1590
* Adds the temp session to the cache, waits for commit, then replaces it.
1591
*/
1592
private async _sendFirstChat(session: CopilotCLISession | RemoteNewSession | ClaudeCodeNewSession, options: ISendRequestOptions): Promise<ISession> {
1593
1594
const { query, attachedContext } = options;
1595
1596
const contribution = this.chatSessionsService.getChatSessionContribution(session.target);
1597
1598
// Resolve mode
1599
const modeKind = session.chatMode?.kind ?? ChatModeKind.Agent;
1600
const modeIsBuiltin = session.chatMode ? isBuiltinChatMode(session.chatMode) : true;
1601
const modeId: 'ask' | 'agent' | 'edit' | 'custom' | undefined = modeIsBuiltin ? modeKind : 'custom';
1602
1603
const rawModeInstructions = session.chatMode?.modeInstructions?.get();
1604
const modeInstructions = rawModeInstructions ? {
1605
name: session.chatMode!.name.get(),
1606
content: rawModeInstructions.content,
1607
toolReferences: this.toolsService.toToolReferences(rawModeInstructions.toolReferences),
1608
metadata: rawModeInstructions.metadata,
1609
} : undefined;
1610
1611
const permissionLevel = session.permissionLevel.get();
1612
1613
const sendOptions: IChatSendRequestOptions = {
1614
location: ChatAgentLocation.Chat,
1615
userSelectedModelId: session.selectedModelId,
1616
modeInfo: {
1617
kind: modeKind,
1618
isBuiltin: modeIsBuiltin,
1619
modeInstructions,
1620
modeId,
1621
applyCodeBlockSuggestionId: undefined,
1622
permissionLevel,
1623
},
1624
agentIdSilent: contribution?.type,
1625
attachedContext,
1626
};
1627
1628
// Claude sessions use the ChatSessionItemController API which creates
1629
// real session URIs upfront, bypassing the untitled→commit→swap flow.
1630
if (session instanceof ClaudeCodeNewSession) {
1631
return this._sendFirstChatViaController(session, query, sendOptions);
1632
}
1633
1634
// Open chat widget and set permission level
1635
await this.chatSessionsService.getOrCreateChatSession(session.resource, CancellationToken.None);
1636
const chatWidget = await this.chatWidgetService.openSession(session.resource, ChatViewPaneTarget);
1637
if (!chatWidget) {
1638
throw new Error('[DefaultCopilotProvider] Failed to open chat widget');
1639
}
1640
1641
if (permissionLevel) {
1642
chatWidget.input.setPermissionLevel(permissionLevel);
1643
}
1644
1645
// Load session model with selected options
1646
await this._applySessionModelState(session.resource, session);
1647
1648
// Send request
1649
this.logService.debug(`[CopilotChatSessionsProvider] Sending first chat for session ${session.id} with options:`, {
1650
userSelectedModelId: sendOptions.userSelectedModelId,
1651
});
1652
const result = await this.chatService.sendRequest(session.resource, query, sendOptions);
1653
if (result.kind === 'rejected') {
1654
throw new Error(`[DefaultCopilotProvider] sendRequest rejected: ${result.reason}`);
1655
}
1656
1657
// Extract promises to detect cancellation vs normal completion
1658
const responseCompletePromise = result.kind === 'sent'
1659
? result.data.responseCompletePromise
1660
: undefined;
1661
const responseCreatedPromise = result.kind === 'sent'
1662
? result.data.responseCreatedPromise
1663
: undefined;
1664
1665
// Add the new session to the sessions model immediately so it appears in the sessions list
1666
session.setTitle(localize('new session', "New Session"));
1667
session.setStatus(SessionStatus.InProgress);
1668
const key = session.resource.toString();
1669
this._sessionCache.set(key, session);
1670
this._invalidateGroupingCaches();
1671
const newSession = this._chatToSession(session);
1672
this._onDidChangeSessions.fire({ added: [newSession], removed: [], changed: [] });
1673
1674
try {
1675
1676
// Wait for the session to be committed (URI swapped from untitled to real)
1677
const committedResource = await this._waitForCommittedSession(session.resource, responseCompletePromise, responseCreatedPromise);
1678
1679
// Wait for _refreshSessionCache to populate the committed adapter
1680
const committedChat = await this._waitForSessionInCache(committedResource);
1681
1682
// Remove the temp from the cache (the adapter now owns the committed key)
1683
this._sessionCache.delete(key);
1684
this._currentNewSession = undefined;
1685
session.dispose();
1686
1687
const committedSession = this._chatToSession(committedChat);
1688
1689
// Notify listeners that the temp session was replaced by the committed one
1690
this._sessionGroupCache.delete(session.id);
1691
this._onDidReplaceSession.fire({ from: newSession, to: committedSession });
1692
1693
return committedSession;
1694
} catch (error) {
1695
this._currentNewSession = undefined;
1696
1697
if (error instanceof CancellationError) {
1698
// Session was stopped before the agent created a worktree.
1699
// Keep the temp session in the list so the user can review
1700
// whatever content the agent produced before cancellation.
1701
session.setStatus(SessionStatus.Completed);
1702
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [newSession] });
1703
return newSession;
1704
}
1705
1706
// Unexpected error — clean up the temp session entirely
1707
this._sessionCache.delete(key);
1708
this._invalidateGroupingCaches();
1709
this._sessionGroupCache.delete(session.id);
1710
this._onDidChangeSessions.fire({ added: [], removed: [this._chatToSession(session)], changed: [] });
1711
session.dispose();
1712
throw error;
1713
}
1714
}
1715
1716
/**
1717
* Sends the first chat for a Claude session using the controller API.
1718
*
1719
* Unlike the legacy untitled→commit→swap flow, this creates the real
1720
* session URI upfront via {@link IChatSessionsService.createNewChatSessionItem},
1721
* then sends the request directly to that URI. This avoids the commit
1722
* event race and ensures the session appears under the correct workspace
1723
* immediately.
1724
*/
1725
private async _sendFirstChatViaController(
1726
session: ClaudeCodeNewSession,
1727
query: string,
1728
sendOptions: IChatSendRequestOptions,
1729
): Promise<ISession> {
1730
// Create the real session item via the controller's newChatSessionItemHandler.
1731
// This returns a session with a real (non-untitled) URI.
1732
const newItem = await this.chatSessionsService.createNewChatSessionItem(
1733
session.target,
1734
{ prompt: query, initialSessionOptions: session.selectedOptions.size > 0 ? session.selectedOptions : undefined },
1735
CancellationToken.None,
1736
);
1737
if (!newItem) {
1738
throw new Error('[CopilotChatSessionsProvider] Failed to create Claude session item');
1739
}
1740
1741
const realResource = newItem.resource;
1742
1743
// Open chat session and widget with the real URI
1744
await this.chatSessionsService.getOrCreateChatSession(realResource, CancellationToken.None);
1745
const chatWidget = await this.chatWidgetService.openSession(realResource, ChatViewPaneTarget);
1746
if (!chatWidget) {
1747
throw new Error('[CopilotChatSessionsProvider] Failed to open chat widget');
1748
}
1749
1750
const permissionLevel = sendOptions.modeInfo?.permissionLevel;
1751
if (permissionLevel) {
1752
chatWidget.input.setPermissionLevel(permissionLevel);
1753
}
1754
1755
// Load session model and apply selected options
1756
await this._applySessionModelState(realResource, session);
1757
1758
// Send request to the real URI — sendRequest skips the
1759
// createNewChatSessionItem block since the URI is not untitled.
1760
this.logService.debug(`[CopilotChatSessionsProvider] Sending first Claude chat to ${realResource.toString()} with options:`, {
1761
userSelectedModelId: sendOptions.userSelectedModelId,
1762
});
1763
const result = await this.chatService.sendRequest(realResource, query, sendOptions);
1764
if (result.kind === 'rejected') {
1765
throw new Error(`[CopilotChatSessionsProvider] sendRequest rejected: ${result.reason}`);
1766
}
1767
1768
// Add the temp session to the cache immediately so it appears in the sessions list
1769
session.setTitle(newItem.label);
1770
session.setStatus(SessionStatus.InProgress);
1771
const tempKey = session.resource.toString();
1772
this._sessionCache.set(tempKey, session);
1773
const tempSession = this._chatToSession(session);
1774
this._onDidChangeSessions.fire({ added: [tempSession], removed: [], changed: [] });
1775
1776
// Extract response promises for cancellation detection
1777
const responseCreatedPromise = result.kind === 'sent'
1778
? result.data.responseCreatedPromise
1779
: undefined;
1780
const cts = new CancellationTokenSource();
1781
// TODO: Understand why we are not awaiting this an only handling the cancellation
1782
responseCreatedPromise?.then(r => {
1783
if (r?.isCanceled) {
1784
cts.cancel();
1785
}
1786
});
1787
1788
try {
1789
// Wait for the agent sessions model to pick up the real session,
1790
// racing against cancellation so we don't timeout when the user
1791
// stops the request before the agent creates a worktree.
1792
const committedChat = await this._waitForSessionInCache(realResource, cts.token);
1793
1794
// Clean up temp session and replace with the real adapter
1795
this._sessionCache.delete(tempKey);
1796
this._currentNewSession = undefined;
1797
session.dispose();
1798
1799
const committedSession = this._chatToSession(committedChat);
1800
this._sessionGroupCache.delete(session.id);
1801
this._onDidReplaceSession.fire({ from: tempSession, to: committedSession });
1802
1803
return committedSession;
1804
} catch (error) {
1805
this._currentNewSession = undefined;
1806
1807
if (error instanceof CancellationError) {
1808
// Keep the temp session visible so the user can review
1809
// whatever content the agent produced before the cancellation.
1810
session.setStatus(SessionStatus.Completed);
1811
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [tempSession] });
1812
return tempSession;
1813
}
1814
1815
// Unexpected error — clean up the temp session entirely
1816
this._sessionCache.delete(tempKey);
1817
this._sessionGroupCache.delete(session.id);
1818
this._onDidChangeSessions.fire({ added: [], removed: [tempSession], changed: [] });
1819
session.dispose();
1820
throw error;
1821
} finally {
1822
cts.dispose();
1823
}
1824
}
1825
1826
/**
1827
* Loads the session model for the given resource and applies the selected
1828
* language model, chat mode, and session options from the new session object.
1829
*/
1830
private async _applySessionModelState(
1831
resource: URI,
1832
session: { selectedModelId?: string; chatMode?: IChatMode; selectedOptions: Map<string, IChatSessionProviderOptionItem> },
1833
): Promise<void> {
1834
const modelRef = await this.chatService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None);
1835
if (!modelRef) {
1836
return;
1837
}
1838
const model = modelRef.object;
1839
if (session.selectedModelId) {
1840
const languageModel = this.languageModelsService.lookupLanguageModel(session.selectedModelId);
1841
if (languageModel) {
1842
model.inputModel.setState({ selectedModel: { identifier: session.selectedModelId, metadata: languageModel } });
1843
}
1844
}
1845
if (session.chatMode) {
1846
model.inputModel.setState({ mode: { id: session.chatMode.id, kind: session.chatMode.kind } });
1847
}
1848
if (session.selectedOptions.size > 0) {
1849
this.chatSessionsService.updateSessionOptions(resource, session.selectedOptions);
1850
}
1851
modelRef.dispose();
1852
}
1853
1854
/**
1855
* Sends a subsequent chat for an existing session that already has chats.
1856
* Creates a new {@link CopilotCLISession} from the existing workspace and
1857
* fires a `changed` event on the grouped session rather than an `added` event.
1858
*/
1859
private async _sendSubsequentChat(sessionId: string, options: ISendRequestOptions): Promise<ISession> {
1860
// Reuse a chat that was pre-created by addChat(), otherwise create one
1861
let newChatSession: CopilotCLISession;
1862
if (this._currentNewSession && this._getGroupIdForChat(this._currentNewSession) === sessionId) {
1863
newChatSession = this._currentNewSession as CopilotCLISession;
1864
} else {
1865
newChatSession = this._createNewSessionFrom(sessionId);
1866
newChatSession.setTitle(localize('new chat', "New Chat"));
1867
const key = newChatSession.resource.toString();
1868
this._sessionCache.set(key, newChatSession);
1869
this._invalidateGroupingCaches();
1870
this._sessionGroupCache.delete(sessionId);
1871
this._onDidGroupMembershipChange.fire({ sessionId });
1872
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(newChatSession)] });
1873
}
1874
1875
return this._sendExistingChat(sessionId, newChatSession, options);
1876
}
1877
1878
/**
1879
* Sends a request for an existing chat session that is already registered
1880
* in the cache.
1881
*/
1882
private async _sendExistingChat(sessionId: string, newChatSession: CopilotCLISession, options: ISendRequestOptions): Promise<ISession> {
1883
// Mark as in progress now that we're sending
1884
newChatSession.setStatus(SessionStatus.InProgress);
1885
const key = newChatSession.resource.toString();
1886
1887
// Invalidate the session group cache so it rebuilds with the new chat
1888
this._sessionGroupCache.delete(sessionId);
1889
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(newChatSession)] });
1890
1891
const { query, attachedContext } = options;
1892
1893
const contribution = this.chatSessionsService.getChatSessionContribution(newChatSession.target);
1894
1895
const sendOptions: IChatSendRequestOptions = {
1896
location: ChatAgentLocation.Chat,
1897
userSelectedModelId: newChatSession.selectedModelId,
1898
modeInfo: {
1899
kind: ChatModeKind.Agent,
1900
isBuiltin: true,
1901
modeInstructions: undefined,
1902
modeId: 'agent',
1903
applyCodeBlockSuggestionId: undefined,
1904
permissionLevel: newChatSession.permissionLevel.get(),
1905
},
1906
agentIdSilent: contribution?.type,
1907
attachedContext,
1908
};
1909
1910
// Open chat widget
1911
await this.chatSessionsService.getOrCreateChatSession(newChatSession.resource, CancellationToken.None);
1912
const chatWidget = await this.chatWidgetService.openSession(newChatSession.resource, ChatViewPaneTarget);
1913
if (!chatWidget) {
1914
this._sessionCache.delete(key);
1915
this._invalidateGroupingCaches();
1916
throw new Error('[DefaultCopilotProvider] Failed to open chat widget for subsequent chat');
1917
}
1918
1919
// Load session model with selected options
1920
await this._applySessionModelState(newChatSession.resource, newChatSession);
1921
1922
// Send request
1923
const result = await this.chatService.sendRequest(newChatSession.resource, query, sendOptions);
1924
if (result.kind === 'rejected') {
1925
this._sessionCache.delete(key);
1926
this._invalidateGroupingCaches();
1927
throw new Error(`[DefaultCopilotProvider] sendRequest rejected: ${result.reason}`);
1928
}
1929
1930
// Extract promises to detect cancellation vs normal completion
1931
const responseCompletePromise = result.kind === 'sent'
1932
? result.data.responseCompletePromise
1933
: undefined;
1934
const responseCreatedPromise = result.kind === 'sent'
1935
? result.data.responseCreatedPromise
1936
: undefined;
1937
1938
try {
1939
// Wait for the session to be committed
1940
const committedResource = await this._waitForCommittedSession(newChatSession.resource, responseCompletePromise, responseCreatedPromise);
1941
1942
const committedChat = await this._waitForSessionInCache(committedResource);
1943
1944
// Clean up temp
1945
this._sessionCache.delete(key);
1946
this._invalidateGroupingCaches();
1947
this._currentNewSession = undefined;
1948
newChatSession.dispose();
1949
1950
// Invalidate the session group cache so it rebuilds with the committed chat
1951
this._sessionGroupCache.delete(sessionId);
1952
this._onDidGroupMembershipChange.fire({ sessionId });
1953
const updatedSession = this._chatToSession(committedChat);
1954
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [updatedSession] });
1955
1956
return updatedSession;
1957
} catch (error) {
1958
this._currentNewSession = undefined;
1959
1960
if (error instanceof CancellationError) {
1961
// Cancelled before commit — keep the chat in the group so the
1962
// user can review the content the agent produced.
1963
newChatSession.setStatus(SessionStatus.Completed);
1964
this._sessionGroupCache.delete(sessionId);
1965
const updatedSession = this._chatToSession(newChatSession);
1966
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [updatedSession] });
1967
return updatedSession;
1968
}
1969
1970
// Unexpected error — clean up on error, fire changed on the parent session group
1971
this._sessionCache.delete(key);
1972
this._invalidateGroupingCaches();
1973
this._sessionGroupCache.delete(sessionId);
1974
newChatSession.dispose();
1975
// Find the parent session's primary chat to fire a valid changed event
1976
const parentChatIds = this._getChatIdsInGroup(sessionId);
1977
const parentChatId = parentChatIds[0];
1978
const parentChat = parentChatId ? this._sessionCache.get(this._localIdFromchatId(parentChatId)) : undefined;
1979
if (parentChat) {
1980
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(parentChat)] });
1981
}
1982
throw error;
1983
}
1984
}
1985
1986
/**
1987
* Creates a new {@link CopilotCLISession} from an existing session's workspace.
1988
* Used for subsequent chats that share the same workspace but are independent conversations.
1989
*/
1990
private _createNewSessionFrom(sessionId: string): CopilotCLISession {
1991
// Find the primary chat for this session
1992
const chatIds = this._getChatIdsInGroup(sessionId);
1993
const firstChatId = chatIds[0] ?? sessionId;
1994
const chat = this._sessionCache.get(this._localIdFromchatId(firstChatId));
1995
if (!chat) {
1996
throw new Error(`Session '${sessionId}' not found`);
1997
}
1998
1999
if (chat.sessionType === AgentSessionProviders.Cloud) {
2000
throw new Error('Multiple chats per session is not supported for cloud sessions');
2001
}
2002
2003
if (chat.sessionType === AgentSessionProviders.Claude) {
2004
throw new Error('Multiple chats per session is not supported for Claude sessions');
2005
}
2006
2007
const workspace = chat.workspace.get();
2008
if (!workspace) {
2009
throw new Error('Chat session has no associated workspace');
2010
}
2011
2012
const repository = workspace.repositories[0];
2013
if (!repository) {
2014
throw new Error('Workspace has no repository');
2015
}
2016
2017
if (this._currentNewSession) {
2018
this._currentNewSession.dispose();
2019
this._currentNewSession = undefined;
2020
}
2021
2022
const newWorkspace = this.resolveWorkspace(repository.workingDirectory || repository.uri);
2023
if (!newWorkspace) {
2024
throw new Error(`Cannot resolve workspace for URI: ${(repository.workingDirectory || repository.uri).toString()}`);
2025
}
2026
const resource = URI.from({ scheme: AgentSessionProviders.Background, path: `/untitled-${generateUuid()}` });
2027
const session = this.instantiationService.createInstance(CopilotCLISession, resource, newWorkspace, this.id);
2028
session.setIsolationMode('workspace');
2029
session.setOption(PARENT_SESSION_OPTION_ID, chat.resource.path.slice(1));
2030
this._currentNewSession = session;
2031
return session;
2032
}
2033
2034
/**
2035
* Waits for the committed (real) URI for a session by listening to the
2036
* {@link IChatSessionsService.onDidCommitSession} event.
2037
*
2038
* When {@link responseCompletePromise} is provided, the wait is bounded by
2039
* response completion. If the response finishes before the commit event,
2040
* the commit may still be in-flight (e.g. the user cancelled after the
2041
* worktree was initiated but before the commit IPC finished, or the
2042
* extension fired the commit mid-turn but it hasn't been delivered yet).
2043
* In both cases we wait with the safety timeout. Only if the timeout
2044
* expires *and* the response was cancelled do we throw a
2045
* {@link CancellationError} — signalling that the commit will never come.
2046
*/
2047
private async _waitForCommittedSession(
2048
untitledResource: URI,
2049
responseCompletePromise?: Promise<void>,
2050
responseCreatedPromise?: Promise<IChatResponseModel>,
2051
): Promise<URI> {
2052
const disposables = new DisposableStore();
2053
try {
2054
const commitPromise = new Promise<URI>(resolve => {
2055
disposables.add(this.chatSessionsService.onDidCommitSession(e => {
2056
if (isEqual(e.original, untitledResource)) {
2057
resolve(e.committed);
2058
}
2059
}));
2060
});
2061
2062
if (responseCompletePromise) {
2063
// Race the commit event against the response completing.
2064
const committed = await Promise.race([
2065
commitPromise.then(uri => ({ committed: true as const, uri })),
2066
responseCompletePromise.then(() => ({ committed: false as const })),
2067
]);
2068
2069
if (committed.committed) {
2070
return committed.uri;
2071
}
2072
2073
// Response finished before the commit event arrived.
2074
// The commit may still be in-flight — the agent could have
2075
// initiated the worktree before the user cancelled, and the
2076
// async IPC chain hasn't delivered the event yet. Fall through
2077
// to the safety timeout to give it a chance to arrive.
2078
}
2079
2080
// Race commit against a safety timeout. If a response-created
2081
// promise is available, also race it so we can detect
2082
// cancellation immediately instead of waiting for the timeout.
2083
const candidates: Promise<{ kind: 'commit'; uri: URI } | { kind: 'timeout' } | { kind: 'cancelled' }>[] = [
2084
raceTimeout(commitPromise, 5_000).then(uri => uri ? { kind: 'commit' as const, uri } : { kind: 'timeout' as const }),
2085
];
2086
if (responseCreatedPromise) {
2087
candidates.push(responseCreatedPromise.then(r => r?.isCanceled ? { kind: 'cancelled' as const } : new Promise<never>(() => { /* never resolves */ })));
2088
}
2089
const outcome = await Promise.race(candidates);
2090
if (outcome.kind === 'commit') {
2091
return outcome.uri;
2092
}
2093
if (outcome.kind === 'cancelled') {
2094
throw new CancellationError();
2095
}
2096
// Timed out — last-resort check for cancellation
2097
const response = responseCreatedPromise ? await responseCreatedPromise : undefined;
2098
if (response?.isCanceled) {
2099
throw new CancellationError();
2100
}
2101
throw new Error('Timed out waiting for session commit');
2102
} finally {
2103
disposables.dispose();
2104
}
2105
}
2106
2107
/**
2108
* Waits for an {@link AgentSessionAdapter} with the given resource to appear
2109
* in the session cache (populated by {@link _refreshSessionCache}).
2110
* Only called once during session initialisation (after the commit event),
2111
* so the timeout has no performance impact on steady-state operations.
2112
*/
2113
private async _waitForSessionInCache(resource: URI, token?: CancellationToken): Promise<AgentSessionAdapter> {
2114
const key = resource.toString();
2115
const existing = this._sessionCache.get(key);
2116
if (existing instanceof AgentSessionAdapter) {
2117
return existing;
2118
}
2119
2120
const disposables = new DisposableStore();
2121
try {
2122
const sessionPromise = new Promise<AgentSessionAdapter>(resolve => {
2123
disposables.add(this.onDidChangeSessions(e => {
2124
const cached = this._sessionCache.get(key);
2125
if (cached instanceof AgentSessionAdapter) {
2126
resolve(cached);
2127
}
2128
}));
2129
});
2130
2131
// The adapter should appear almost immediately after the commit
2132
// event via _refreshSessionCache; use a short safety timeout.
2133
const result = await raceTimeout(
2134
token ? raceCancellationError(sessionPromise, token) : sessionPromise,
2135
5_000,
2136
);
2137
if (!result) {
2138
throw new Error('Timed out waiting for committed session in cache');
2139
}
2140
return result;
2141
} finally {
2142
disposables.dispose();
2143
}
2144
}
2145
2146
// -- Private --
2147
2148
private async _browseForRepo(): Promise<ISessionWorkspace | undefined> {
2149
const repoId = await this.commandService.executeCommand<string>(OPEN_REPO_COMMAND);
2150
if (repoId) {
2151
const uri = URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: `/${repoId}/HEAD` });
2152
return {
2153
label: this._labelFromUri(uri),
2154
icon: this._iconFromUri(uri),
2155
group: SESSION_WORKSPACE_GROUP_GITHUB,
2156
repositories: [{ uri, workingDirectory: undefined, detail: undefined, baseBranchName: undefined }],
2157
requiresWorkspaceTrust: false,
2158
};
2159
}
2160
return undefined;
2161
}
2162
2163
resolveWorkspace(repositoryUri: URI): ISessionWorkspace | undefined {
2164
if (repositoryUri.scheme !== Schemas.file && repositoryUri.scheme !== GITHUB_REMOTE_FILE_SCHEME) {
2165
return undefined;
2166
}
2167
return {
2168
label: this._labelFromUri(repositoryUri),
2169
description: this._descriptionFromUri(repositoryUri),
2170
group: repositoryUri.scheme === GITHUB_REMOTE_FILE_SCHEME ? SESSION_WORKSPACE_GROUP_GITHUB : SESSION_WORKSPACE_GROUP_LOCAL,
2171
icon: this._iconFromUri(repositoryUri),
2172
repositories: [{ uri: repositoryUri, workingDirectory: undefined, detail: undefined, baseBranchName: undefined }],
2173
requiresWorkspaceTrust: repositoryUri.scheme !== GITHUB_REMOTE_FILE_SCHEME
2174
};
2175
}
2176
2177
private _labelFromUri(uri: URI): string {
2178
if (uri.scheme === GITHUB_REMOTE_FILE_SCHEME) {
2179
return uri.path.substring(1).replace(/\/HEAD$/, '');
2180
}
2181
return basename(uri);
2182
}
2183
2184
private _descriptionFromUri(uri: URI): string | undefined {
2185
if (uri.scheme === GITHUB_REMOTE_FILE_SCHEME) {
2186
// For GitHub URIs the path is "/<owner>/<repo>", return the owner as description
2187
const parts = uri.path.substring(1).split('/');
2188
return parts.length >= 2 ? parts[0] : undefined;
2189
}
2190
// For local file URIs, return the tildified parent directory path
2191
return this.labelService.getUriLabel(dirname(uri), { relative: false });
2192
}
2193
2194
private _iconFromUri(uri: URI): ThemeIcon {
2195
if (uri.scheme === GITHUB_REMOTE_FILE_SCHEME) {
2196
return Codicon.repo;
2197
}
2198
return Codicon.folder;
2199
}
2200
2201
private _ensureSessionCache(): void {
2202
if (this._sessionCache.size > 0) {
2203
return;
2204
}
2205
this._refreshSessionCache();
2206
}
2207
2208
private _invalidateGroupingCaches(): void {
2209
this._chatByRawSessionIdCache = undefined;
2210
this._groupIdByChatIdCache = undefined;
2211
this._chatIdsByGroupIdCache = undefined;
2212
}
2213
2214
private _ensureGroupingCaches(): void {
2215
if (this._chatByRawSessionIdCache && this._groupIdByChatIdCache && this._chatIdsByGroupIdCache) {
2216
return;
2217
}
2218
2219
const chats = Array.from(this._sessionCache.values());
2220
const chatByRawSessionId = new Map<string, ICopilotChatSession>();
2221
for (const chat of chats) {
2222
chatByRawSessionId.set(chat.resource.path.slice(1), chat);
2223
}
2224
2225
const groupIdByChatId = new Map<string, string>();
2226
const chatsByGroupId = new Map<string, ICopilotChatSession[]>();
2227
2228
const resolveGroupId = (chat: ICopilotChatSession): string => {
2229
const cachedGroupId = groupIdByChatId.get(chat.id);
2230
if (cachedGroupId) {
2231
return cachedGroupId;
2232
}
2233
2234
const trail: ICopilotChatSession[] = [];
2235
const seen = new Set<string>();
2236
let current: ICopilotChatSession = chat;
2237
2238
for (let depth = 0; depth < 100; depth++) {
2239
const currentCachedGroupId = groupIdByChatId.get(current.id);
2240
if (currentCachedGroupId) {
2241
for (const trailChat of trail) {
2242
groupIdByChatId.set(trailChat.id, currentCachedGroupId);
2243
}
2244
return currentCachedGroupId;
2245
}
2246
2247
if (seen.has(current.id)) {
2248
for (const trailChat of trail) {
2249
groupIdByChatId.set(trailChat.id, current.id);
2250
}
2251
return current.id;
2252
}
2253
2254
trail.push(current);
2255
seen.add(current.id);
2256
2257
const parentRawSessionId = this._getDirectParentRawSessionId(current);
2258
if (!parentRawSessionId) {
2259
for (const trailChat of trail) {
2260
groupIdByChatId.set(trailChat.id, current.id);
2261
}
2262
return current.id;
2263
}
2264
2265
const parentChat = chatByRawSessionId.get(parentRawSessionId);
2266
if (!parentChat) {
2267
const syntheticGroupId = this._getSyntheticGroupId(parentRawSessionId);
2268
for (const trailChat of trail) {
2269
groupIdByChatId.set(trailChat.id, syntheticGroupId);
2270
}
2271
return syntheticGroupId;
2272
}
2273
2274
current = parentChat;
2275
}
2276
2277
groupIdByChatId.set(chat.id, chat.id);
2278
return chat.id;
2279
};
2280
2281
for (const chat of chats) {
2282
const groupId = resolveGroupId(chat);
2283
const groupChats = chatsByGroupId.get(groupId) ?? [];
2284
groupChats.push(chat);
2285
chatsByGroupId.set(groupId, groupChats);
2286
}
2287
2288
const chatIdsByGroupId = new Map<string, string[]>();
2289
for (const [groupId, groupChats] of chatsByGroupId) {
2290
groupChats.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
2291
chatIdsByGroupId.set(groupId, groupChats.map(chat => chat.id));
2292
}
2293
2294
this._chatByRawSessionIdCache = chatByRawSessionId;
2295
this._groupIdByChatIdCache = groupIdByChatId;
2296
this._chatIdsByGroupIdCache = chatIdsByGroupId;
2297
}
2298
2299
/**
2300
* Cleans up a temp session (one that hasn't been committed) from the cache.
2301
* Used when delete/archive is invoked on a session that is still pending
2302
* commit (e.g. was stopped before the agent created a worktree).
2303
*/
2304
private _cleanupTempSession(sessionId: string): void {
2305
const chatSession = this._findChatSession(sessionId);
2306
if (!chatSession) {
2307
return;
2308
}
2309
2310
const key = chatSession.resource.toString();
2311
this._sessionCache.delete(key);
2312
this._invalidateGroupingCaches();
2313
this._sessionGroupCache.delete(chatSession.id);
2314
if (this._currentNewSession?.id === chatSession.id) {
2315
this._currentNewSession = undefined;
2316
}
2317
const removedSession = this._chatToSession(chatSession);
2318
this._sessionGroupCache.delete(chatSession.id);
2319
this._onDidChangeSessions.fire({ added: [], removed: [removedSession], changed: [] });
2320
if (isNewSession(chatSession)) {
2321
chatSession.dispose();
2322
}
2323
}
2324
2325
private _refreshSessionCache(): void {
2326
const currentKeys = new Set<string>();
2327
const addedData: ICopilotChatSession[] = [];
2328
const changedData: ICopilotChatSession[] = [];
2329
let cacheChanged = false;
2330
2331
for (const session of this.agentSessionsService.model.sessions) {
2332
if (session.providerType !== AgentSessionProviders.Background
2333
&& session.providerType !== AgentSessionProviders.Cloud
2334
&& session.providerType !== AgentSessionProviders.Claude) {
2335
continue;
2336
}
2337
2338
if (session.providerType === AgentSessionProviders.Claude && !this._claudeEnabled) {
2339
continue;
2340
}
2341
2342
const key = session.resource.toString();
2343
currentKeys.add(key);
2344
2345
const existing = this._sessionCache.get(key);
2346
if (existing) {
2347
existing.update(session);
2348
changedData.push(existing);
2349
} else {
2350
const adapter = new AgentSessionAdapter(session, this.id, this.gitHubService);
2351
this._sessionCache.set(key, adapter);
2352
addedData.push(adapter);
2353
cacheChanged = true;
2354
}
2355
}
2356
2357
const removedData: ICopilotChatSession[] = [];
2358
for (const [key, adapter] of this._sessionCache) {
2359
if (!currentKeys.has(key) && adapter instanceof AgentSessionAdapter) {
2360
removedData.push(adapter);
2361
cacheChanged = true;
2362
}
2363
}
2364
2365
// Resolve group IDs for removed sessions BEFORE removing them from the
2366
// cache and invalidating grouping caches, so that child sessions are
2367
// correctly mapped to their parent group.
2368
let removedGroupIds: Map<ICopilotChatSession, string> | undefined;
2369
if (removedData.length > 0 && this._isMultiChatEnabled()) {
2370
removedGroupIds = new Map();
2371
for (const removed of removedData) {
2372
removedGroupIds.set(removed, this._getGroupIdForChat(removed));
2373
}
2374
}
2375
2376
// Now remove from cache and invalidate grouping caches
2377
for (const removed of removedData) {
2378
this._sessionCache.delete(removed.resource.toString());
2379
}
2380
2381
if (cacheChanged) {
2382
this._invalidateGroupingCaches();
2383
}
2384
2385
if (addedData.length > 0 || removedData.length > 0 || changedData.length > 0) {
2386
if (this._isMultiChatEnabled()) {
2387
this._refreshSessionCacheMultiChat(addedData, removedData, changedData, removedGroupIds!);
2388
} else {
2389
this._onDidChangeSessions.fire({
2390
added: addedData.map(d => this._chatToSession(d)),
2391
removed: removedData.map(d => this._chatToSession(d)),
2392
changed: changedData.map(d => this._chatToSession(d)),
2393
});
2394
}
2395
}
2396
}
2397
2398
private _refreshSessionCacheMultiChat(
2399
addedData: ICopilotChatSession[],
2400
removedData: ICopilotChatSession[],
2401
changedData: ICopilotChatSession[],
2402
removedGroupIds: Map<ICopilotChatSession, string>,
2403
): void {
2404
2405
// Handle removed chats: if a removed chat belongs to a group with
2406
// remaining siblings, treat it as a changed event on the parent session
2407
// instead of a removed session.
2408
const trulyRemovedSessions: { chat: ICopilotChatSession; groupId: string }[] = [];
2409
const changedSessionIds = new Set<string>();
2410
for (const removed of removedData) {
2411
const sessionId = removedGroupIds.get(removed)!;
2412
2413
// Check if the group still has chats after removal
2414
const remainingChatIds = this._getChatIdsInGroup(sessionId);
2415
if (remainingChatIds.length > 0) {
2416
// Group still has other chats — invalidate cache and treat as changed
2417
this._sessionGroupCache.delete(sessionId);
2418
this._onDidGroupMembershipChange.fire({ sessionId });
2419
if (!changedSessionIds.has(sessionId)) {
2420
changedSessionIds.add(sessionId);
2421
const primaryChat = this._sessionCache.get(this._localIdFromchatId(remainingChatIds[0]));
2422
if (primaryChat) {
2423
changedData.push(primaryChat);
2424
}
2425
}
2426
} else {
2427
this._sessionGroupCache.delete(sessionId);
2428
trulyRemovedSessions.push({ chat: removed, groupId: sessionId });
2429
}
2430
}
2431
2432
// Separate truly new sessions from chats added to existing groups.
2433
// Grouping is derived from sessionParentId in metadata.
2434
const newSessions: ICopilotChatSession[] = [];
2435
for (const added of addedData) {
2436
const groupId = this._getGroupIdForChat(added);
2437
const groupChatIds = this._getChatIdsInGroup(groupId);
2438
if (groupChatIds.length > 1) {
2439
// This chat belongs to an existing session group — treat as changed
2440
this._sessionGroupCache.delete(groupId);
2441
this._onDidGroupMembershipChange.fire({ sessionId: groupId });
2442
if (!changedSessionIds.has(groupId)) {
2443
changedSessionIds.add(groupId);
2444
changedData.push(added);
2445
}
2446
} else {
2447
newSessions.push(added);
2448
}
2449
}
2450
2451
// Deduplicate changed sessions by group ID
2452
const seenChanged = new Set<string>();
2453
const deduplicatedChanged: ICopilotChatSession[] = [];
2454
for (const d of changedData) {
2455
const groupId = this._getGroupIdForChat(d);
2456
if (!seenChanged.has(groupId)) {
2457
seenChanged.add(groupId);
2458
deduplicatedChanged.push(d);
2459
}
2460
}
2461
2462
this._onDidChangeSessions.fire({
2463
added: newSessions.map(d => this._chatToSession(d)),
2464
removed: trulyRemovedSessions.map(({ chat, groupId }) => {
2465
const session = this._sessionGroupCache.get(groupId);
2466
this._sessionGroupCache.delete(groupId);
2467
return session ?? this._chatToSession(chat);
2468
}),
2469
changed: deduplicatedChanged.map(d => this._chatToSession(d)),
2470
});
2471
}
2472
2473
private _findChatSession(chatId: string): ICopilotChatSession | undefined {
2474
const directMatch = this._sessionCache.get(this._localIdFromchatId(chatId));
2475
if (directMatch) {
2476
return directMatch;
2477
}
2478
2479
const groupChatIds = this._getChatIdsInGroup(chatId);
2480
const firstChatId = groupChatIds[0];
2481
return firstChatId ? this._sessionCache.get(this._localIdFromchatId(firstChatId)) : undefined;
2482
}
2483
2484
private _findAgentSession(chatId: string): IAgentSession | undefined {
2485
const adapter = this._findChatSession(chatId);
2486
if (!adapter) {
2487
return undefined;
2488
}
2489
return this.agentSessionsService.getSession(adapter.resource);
2490
}
2491
2492
/**
2493
* Returns the group ID for a given chat.
2494
* Grouping is derived from `sessionParentId` in metadata (for committed sessions)
2495
* or from `PARENT_SESSION_OPTION_ID` in selected options (for uncommitted sessions).
2496
* If the root chat is not loaded, a synthetic provider-scoped group ID is used.
2497
*/
2498
private _getGroupIdForChat(chat: ICopilotChatSession): string {
2499
this._ensureGroupingCaches();
2500
return this._groupIdByChatIdCache?.get(chat.id) ?? chat.id;
2501
}
2502
2503
/**
2504
* Returns all chat IDs that belong to the given group,
2505
* ordered by creation time (root session first).
2506
*/
2507
private _getChatIdsInGroup(groupId: string): string[] {
2508
this._ensureGroupingCaches();
2509
return this._chatIdsByGroupIdCache?.get(groupId) ?? [];
2510
}
2511
2512
private _getDirectParentRawSessionId(chat: ICopilotChatSession): string | undefined {
2513
const agentSession = this.agentSessionsService.getSession(chat.resource);
2514
const sessionParentId = agentSession?.metadata?.sessionParentId;
2515
if (typeof sessionParentId === 'string' && sessionParentId.length > 0) {
2516
return sessionParentId;
2517
}
2518
2519
if (isNewSession(chat)) {
2520
const parentOption = chat.selectedOptions.get(PARENT_SESSION_OPTION_ID);
2521
if (parentOption?.id) {
2522
return parentOption.id;
2523
}
2524
}
2525
2526
return undefined;
2527
}
2528
2529
private _getSyntheticGroupId(rawSessionId: string): string {
2530
return `${this.id}:group:${rawSessionId}`;
2531
}
2532
2533
private _findSession(sessionId: string): ISession | undefined {
2534
return this._sessionGroupCache.get(sessionId);
2535
}
2536
2537
private _localIdFromchatId(chatId: string): string {
2538
const prefix = `${this.id}:`;
2539
return chatId.startsWith(prefix) ? chatId.substring(prefix.length) : chatId;
2540
}
2541
2542
/**
2543
* Wraps a primary {@link ICopilotChatSession} and its sibling chats into an {@link ISession}.
2544
* When multi-chat is enabled, the `chats` observable is derived from `sessionParentId`
2545
* metadata and updates when group membership changes.
2546
* When disabled, each session has exactly one chat.
2547
*/
2548
private _chatToSession(chat: ICopilotChatSession): ISession {
2549
if (!this._isMultiChatEnabled()) {
2550
return this._chatToSingleChatSession(chat);
2551
}
2552
2553
const sessionId = this._getGroupIdForChat(chat);
2554
2555
const cached = this._sessionGroupCache.get(sessionId);
2556
if (cached) {
2557
return cached;
2558
}
2559
2560
// Resolve the main (first) chat in the group — session-level properties come from it
2561
const mainChatIds = this._getChatIdsInGroup(sessionId);
2562
const firstChatId = mainChatIds[0];
2563
const primaryChat = firstChatId
2564
? this._sessionCache.get(this._localIdFromchatId(firstChatId)) ?? chat
2565
: chat;
2566
2567
const chatsObs = observableFromEvent<readonly IChat[]>(
2568
this,
2569
Event.filter(this._onDidGroupMembershipChange.event, e => e.sessionId === sessionId),
2570
() => {
2571
const chatIds = this._getChatIdsInGroup(sessionId);
2572
if (chatIds.length === 0) {
2573
return [this._toChat(chat)];
2574
}
2575
const resolved: ICopilotChatSession[] = [];
2576
for (const id of chatIds) {
2577
const c = this._sessionCache.get(this._localIdFromchatId(id));
2578
if (c) {
2579
resolved.push(c);
2580
}
2581
}
2582
if (resolved.length === 0) {
2583
return [this._toChat(chat)];
2584
}
2585
return resolved.map(c => this._toChat(c));
2586
},
2587
);
2588
2589
const mainChat = this._toChat(primaryChat);
2590
const session: ISession = {
2591
sessionId,
2592
resource: primaryChat.resource,
2593
providerId: primaryChat.providerId,
2594
sessionType: primaryChat.sessionType,
2595
icon: primaryChat.icon,
2596
createdAt: primaryChat.createdAt,
2597
workspace: primaryChat.workspace,
2598
title: primaryChat.title,
2599
updatedAt: chatsObs.map((chats, reader) => this._latestDate(chats, c => c.updatedAt.read(reader))!),
2600
status: chatsObs.map((chats, reader) => this._aggregateStatus(chats, reader)),
2601
changes: primaryChat.changes,
2602
modelId: primaryChat.modelId,
2603
mode: primaryChat.mode,
2604
loading: primaryChat.loading,
2605
isArchived: primaryChat.isArchived,
2606
isRead: chatsObs.map((chats, reader) => chats.every(c => c.isRead.read(reader))),
2607
description: primaryChat.description,
2608
lastTurnEnd: chatsObs.map((chats, reader) => this._latestDate(chats, c => c.lastTurnEnd.read(reader))),
2609
gitHubInfo: primaryChat.gitHubInfo,
2610
chats: chatsObs,
2611
mainChat,
2612
capabilities: { supportsMultipleChats: primaryChat.sessionType === CopilotCLISessionType.id && this._isMultiChatEnabled() },
2613
};
2614
this._sessionGroupCache.set(sessionId, session);
2615
return session;
2616
}
2617
2618
private _chatToSingleChatSession(chat: ICopilotChatSession): ISession {
2619
const mainChat = this._toChat(chat);
2620
return {
2621
sessionId: chat.id,
2622
resource: chat.resource,
2623
providerId: chat.providerId,
2624
sessionType: chat.sessionType,
2625
icon: chat.icon,
2626
createdAt: chat.createdAt,
2627
workspace: chat.workspace,
2628
title: chat.title,
2629
updatedAt: chat.updatedAt,
2630
status: chat.status,
2631
changes: chat.changes,
2632
modelId: chat.modelId,
2633
mode: chat.mode,
2634
loading: chat.loading,
2635
isArchived: chat.isArchived,
2636
isRead: chat.isRead,
2637
description: chat.description,
2638
lastTurnEnd: chat.lastTurnEnd,
2639
gitHubInfo: chat.gitHubInfo,
2640
chats: constObservable([mainChat]),
2641
mainChat,
2642
capabilities: { supportsMultipleChats: false },
2643
};
2644
}
2645
2646
private _toChat(chat: ICopilotChatSession): IChat {
2647
return {
2648
resource: chat.resource,
2649
createdAt: chat.createdAt,
2650
title: chat.title,
2651
updatedAt: chat.updatedAt,
2652
status: chat.status,
2653
changes: chat.changes,
2654
modelId: chat.modelId,
2655
mode: chat.mode,
2656
isArchived: chat.isArchived,
2657
isRead: chat.isRead,
2658
description: chat.description,
2659
lastTurnEnd: chat.lastTurnEnd,
2660
};
2661
}
2662
2663
private _latestDate(chats: readonly IChat[], getter: (chat: IChat) => Date | undefined): Date | undefined {
2664
let latest: Date | undefined;
2665
for (const chat of chats) {
2666
const d = getter(chat);
2667
if (d && (!latest || d > latest)) {
2668
latest = d;
2669
}
2670
}
2671
return latest;
2672
}
2673
2674
private _aggregateStatus(chats: readonly IChat[], reader: IReader): SessionStatus {
2675
for (const c of chats) {
2676
if (c.status.read(reader) === SessionStatus.NeedsInput) {
2677
return SessionStatus.NeedsInput;
2678
}
2679
}
2680
for (const c of chats) {
2681
if (c.status.read(reader) === SessionStatus.InProgress) {
2682
return SessionStatus.InProgress;
2683
}
2684
}
2685
return chats[0].status.read(reader);
2686
}
2687
2688
private _isMultiChatEnabled(): boolean {
2689
return this._multiChatEnabled;
2690
}
2691
}
2692
2693