Path: blob/main/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Emitter, Event } from '../../../../base/common/event.js';6import { raceCancellationError, raceTimeout } from '../../../../base/common/async.js';7import { Codicon } from '../../../../base/common/codicons.js';8import { CancellationError } from '../../../../base/common/errors.js';9import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js';10import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js';11import { Schemas } from '../../../../base/common/network.js';12import { autorun, constObservable, derived, IObservable, IReader, observableFromEvent, observableValue, transaction } from '../../../../base/common/observable.js';13import { ThemeIcon } from '../../../../base/common/themables.js';14import { URI } from '../../../../base/common/uri.js';15import { ICommandService } from '../../../../platform/commands/common/commands.js';16import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';17import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';18import { IAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js';19import { getRepositoryName } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.js';20import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js';21import { AgentSessionProviders, AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js';22import { IChatService, IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js';23import { IChatResponseModel } from '../../../../workbench/contrib/chat/common/model/chatModel.js';24import { ChatSessionStatus, IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, SessionType } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';25import { 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';26import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js';27import { basename, dirname, isEqual } from '../../../../base/common/resources.js';28import { ISendRequestOptions, ISessionChangeEvent, ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js';29import { ISessionOptionGroup } from '../../chat/browser/newSession.js';30import { IsolationMode } from './isolationPicker.js';31import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js';32import { ILanguageModelToolsService } from '../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js';33import { isBuiltinChatMode, IChatMode } from '../../../../workbench/contrib/chat/common/chatModes.js';34import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';35import { generateUuid } from '../../../../base/common/uuid.js';36import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js';37import { IGitService, IGitRepository } from '../../../../workbench/contrib/git/common/gitService.js';38import { IContextKeyService, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';39import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js';40import { localize } from '../../../../nls.js';41import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';42import { ILabelService } from '../../../../platform/label/common/label.js';43import { ILogService } from '../../../../platform/log/common/log.js';44import { IGitHubService } from '../../github/browser/githubService.js';45import { computePullRequestIcon, GitHubPullRequestState } from '../../github/common/types.js';4647const SESSION_WORKSPACE_GROUP_GITHUB = localize('sessionWorkspaceGroup.github', "GitHub");4849export interface ICopilotChatSession {50/** Globally unique session ID (`providerId:localId`). */51readonly id: string;52/** Resource URI identifying this session. */53readonly resource: URI;54/** ID of the provider that owns this session. */55readonly providerId: string;56/** Session type ID (e.g., 'copilot-cli', 'copilot-cloud'). */57readonly sessionType: string;58/** Icon for this session. */59readonly icon: ThemeIcon;60/** When the session was created. */61readonly createdAt: Date;62/** Workspace this session operates on. */63readonly workspace: IObservable<ISessionWorkspace | undefined>;6465// Reactive properties6667/** Session display title (changes when auto-titled or renamed). */68readonly title: IObservable<string>;69/** When the session was last updated. */70readonly updatedAt: IObservable<Date>;71/** Current session status. */72readonly status: IObservable<SessionStatus>;73/** File changes produced by the session. */74readonly changes: IObservable<readonly ISessionFileChange[]>;75/** Currently selected model identifier. */76readonly modelId: IObservable<string | undefined>;77/** Currently selected mode identifier and kind. */78readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined>;79/** Whether the session is still initializing (e.g., resolving git repository). */80readonly loading: IObservable<boolean>;81/** Whether the session is archived. */82readonly isArchived: IObservable<boolean>;83/** Whether the session has been read. */84readonly isRead: IObservable<boolean>;85/** Status description shown while the session is active (e.g., current agent action). */86readonly description: IObservable<IMarkdownString | undefined>;87/** Timestamp of when the last agent turn ended, if any. */88readonly lastTurnEnd: IObservable<Date | undefined>;89/** GitHub information associated with this session, if any. */90readonly gitHubInfo: IObservable<IGitHubInfo | undefined>;9192readonly permissionLevel: IObservable<ChatPermissionLevel>;93setPermissionLevel(level: ChatPermissionLevel): void;9495readonly branch: IObservable<string | undefined>;96setBranch(branch: string | undefined): void;9798readonly isolationMode: IObservable<IsolationMode | undefined>;99setIsolationMode(mode: IsolationMode): void;100101setModelId(modelId: string): void;102setMode(chatMode: IChatMode | undefined): void;103setOption?(optionId: string, value: IChatSessionProviderOptionItem | string): void;104105readonly gitRepository?: IGitRepository;106readonly branches: IObservable<readonly string[]>;107}108109const OPEN_REPO_COMMAND = 'github.copilot.chat.cloudSessions.openRepository';110111/** Provider ID for the Copilot Chat Sessions provider. */112export const COPILOT_PROVIDER_ID = 'default-copilot';113114/** Setting key controlling whether the Copilot provider supports multiple chats per session. */115export const COPILOT_MULTI_CHAT_SETTING = 'sessions.github.copilot.multiChatSessions';116117/** Setting key controlling whether Claude agent sessions are available. */118export const CLAUDE_CODE_ENABLED_SETTING = 'sessions.chat.claudeAgent.enabled';119120const REPOSITORY_OPTION_ID = 'repository';121const PARENT_SESSION_OPTION_ID = 'parentSessionId';122const BRANCH_OPTION_ID = 'branch';123const ISOLATION_OPTION_ID = 'isolation';124const AGENT_OPTION_ID = 'agent';125126type NewSession = CopilotCLISession | RemoteNewSession | ClaudeCodeNewSession;127128function isNewSession(session: ICopilotChatSession): session is NewSession {129return session instanceof CopilotCLISession || session instanceof RemoteNewSession || session instanceof ClaudeCodeNewSession;130}131132/**133* Local new session for Background agent sessions.134* Implements {@link ICopilotChatSession} (session facade) and provides135* pre-send configuration methods for the new-session flow.136*/137class CopilotCLISession extends Disposable implements ICopilotChatSession {138139static readonly COPILOT_WORKTREE_PATTERN = 'copilot-worktree-';140141// -- ISessionData fields --142143readonly id: string;144readonly providerId: string;145readonly sessionType: string;146readonly icon: ThemeIcon;147readonly createdAt: Date;148149private readonly _title = observableValue(this, '');150readonly title: IObservable<string> = this._title;151152private readonly _description: ReturnType<typeof observableValue<IMarkdownString | undefined>>;153readonly description: IObservable<IMarkdownString | undefined>;154155private readonly _updatedAt = observableValue(this, new Date());156readonly updatedAt: IObservable<Date> = this._updatedAt;157158private readonly _status = observableValue(this, SessionStatus.Untitled);159readonly status: IObservable<SessionStatus> = this._status;160161private readonly _permissionLevel = observableValue(this, ChatPermissionLevel.Default);162readonly permissionLevel: IObservable<ChatPermissionLevel> = this._permissionLevel;163164private readonly _workspaceData = observableValue<ISessionWorkspace | undefined>(this, undefined);165readonly workspace: IObservable<ISessionWorkspace | undefined> = this._workspaceData;166167private readonly _branchObservable = observableValue<string | undefined>(this, undefined);168readonly branch: IObservable<string | undefined> = this._branchObservable;169170private readonly _isolationModeObservable = observableValue<IsolationMode | undefined>(this, 'worktree');171readonly isolationMode: IObservable<IsolationMode | undefined> = this._isolationModeObservable;172173private readonly _modelIdObservable = observableValue<string | undefined>(this, undefined);174readonly modelId: IObservable<string | undefined> = this._modelIdObservable;175176private readonly _modeObservable = observableValue<{ readonly id: string; readonly kind: string } | undefined>(this, undefined);177readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined> = this._modeObservable;178179private readonly _loading = observableValue(this, true);180readonly loading: IObservable<boolean> = this._loading;181182private readonly _changes: ReturnType<typeof observableValue<readonly ISessionFileChange[]>>;183readonly changes: IObservable<readonly ISessionFileChange[]>;184185private readonly _isArchived = observableValue(this, false);186readonly isArchived: IObservable<boolean> = this._isArchived;187readonly isRead: IObservable<boolean> = observableValue(this, true);188readonly lastTurnEnd: IObservable<Date | undefined> = observableValue(this, undefined);189readonly gitHubInfo: IObservable<IGitHubInfo | undefined> = observableValue(this, undefined);190191private _gitRepository: IGitRepository | undefined;192private readonly _loadBranchesCts = this._register(new MutableDisposable<CancellationTokenSource>());193194// -- Branch state --195196private readonly _branches = observableValue<readonly string[]>(this, []);197readonly branches: IObservable<readonly string[]> = this._branches;198199private _defaultBranch: string | undefined;200201// -- New session configuration fields --202203private _repoUri: URI | undefined;204private _isolationMode: IsolationMode;205private _branch: string | undefined;206private _modelId: string | undefined;207private _mode: IChatMode | undefined;208private _query: string | undefined;209private _attachedContext: IChatRequestVariableEntry[] | undefined;210211readonly target = AgentSessionProviders.Background;212readonly selectedOptions = new Map<string, IChatSessionProviderOptionItem>();213214get selectedModelId(): string | undefined { return this._modelId; }215get chatMode(): IChatMode | undefined { return this._mode; }216get query(): string | undefined { return this._query; }217get attachedContext(): IChatRequestVariableEntry[] | undefined { return this._attachedContext; }218get gitRepository(): IGitRepository | undefined { return this._gitRepository; }219get disabled(): boolean {220if (!this._repoUri) {221return true;222}223if (this._isolationMode === 'worktree' && !this._branch) {224return true;225}226return false;227}228229constructor(230readonly resource: URI,231readonly sessionWorkspace: ISessionWorkspace,232providerId: string,233@IChatSessionsService private readonly chatSessionsService: IChatSessionsService,234@IGitService private readonly gitService: IGitService,235) {236super();237this.id = toSessionId(providerId, resource);238this.providerId = providerId;239this.sessionType = AgentSessionProviders.Background;240this.icon = CopilotCLISessionType.icon;241this.createdAt = new Date();242243const repoUri = sessionWorkspace.repositories[0]?.uri;244if (repoUri) {245this._repoUri = repoUri;246this.setOption(REPOSITORY_OPTION_ID, repoUri.fsPath);247}248249// Set ISessionData workspace observable250this._workspaceData.set(sessionWorkspace, undefined);251252this._isolationMode = 'worktree';253this.setOption(ISOLATION_OPTION_ID, 'worktree');254255// Resolve git repository asynchronously256this._resolveGitRepository();257258this._description = observableValue(this, undefined);259this.description = this._description;260261this._changes = observableValue<readonly ISessionFileChange[]>(this, []);262this.changes = this._changes;263}264265private async _resolveGitRepository(): Promise<void> {266const repoUri = this.sessionWorkspace.repositories[0]?.uri;267if (repoUri) {268try {269this._gitRepository = await this.gitService.openRepository(repoUri);270if (!this._gitRepository) {271this.setIsolationMode('workspace');272} else if (!this._gitRepository.state.get().HEAD?.commit) {273// Empty repositories have no HEAD commit and cannot run worktree isolation.274this.setIsolationMode('workspace');275}276} catch {277// No git repository available278this.setIsolationMode('workspace');279}280}281if (this._gitRepository) {282this._loadBranches(this._gitRepository);283284// Automatically update the selected branch when the repository285// state changes. This is done only for the Folder sessions.286const currentBranchName = derived(reader => {287const state = this._gitRepository?.state.read(reader);288return state?.HEAD?.commit ? state.HEAD.name : undefined;289});290291this._register(autorun(reader => {292const isolationMode = this.isolationMode.read(reader);293if (isolationMode === 'worktree') {294return;295}296297const currentBranch = currentBranchName.read(reader);298this.setBranch(currentBranch ?? this._defaultBranch);299}));300}301this._loading.set(false, undefined);302}303304private _loadBranches(repo: IGitRepository): void {305this._loadBranchesCts.value?.cancel();306const cts = this._loadBranchesCts.value = new CancellationTokenSource();307308repo.getRefs({ pattern: 'refs/heads' }, cts.token).then(refs => {309if (cts.token.isCancellationRequested) {310return;311}312const hasHeadCommit = !!repo.state.get().HEAD?.commit;313const branches = refs314.map(r => r.name)315.filter((name): name is string => !!name)316.filter(name => !name.includes(CopilotCLISession.COPILOT_WORKTREE_PATTERN));317318const defaultBranch = hasHeadCommit319? (branches.find(b => b === 'main')320?? branches.find(b => b === 'master')321?? branches.find(b => b === repo.state.get().HEAD?.name)322?? branches[0])323: undefined;324325this._defaultBranch = defaultBranch;326327transaction(tx => {328this._branches.set(branches, tx);329});330331if (defaultBranch && !this._branch) {332this.setBranch(defaultBranch);333}334}).catch(() => {335if (!cts.token.isCancellationRequested) {336transaction(tx => {337this._branches.set([], tx);338});339}340});341}342343setIsolationMode(mode: IsolationMode): void {344if (this._isolationMode !== mode) {345this._isolationMode = mode;346this._isolationModeObservable.set(mode, undefined);347this.setOption(ISOLATION_OPTION_ID, mode);348349if (mode === 'workspace') {350// When switching to workspace mode, update the branch351// selection to reflect the current branch as that is352// what will be used for the folder session353const head = this._gitRepository?.state.get().HEAD;354const currentBranch = head?.commit ? head.name : undefined;355this.setBranch(currentBranch ?? this._defaultBranch);356} else {357this.setBranch(this._defaultBranch);358}359}360}361362setBranch(branch: string | undefined): void {363if (this._branch !== branch) {364this._branch = branch;365this._branchObservable.set(branch, undefined);366this.setOption(BRANCH_OPTION_ID, branch ?? '');367}368}369370setModelId(modelId: string | undefined): void {371this._modelId = modelId;372this._modelIdObservable.set(modelId, undefined);373}374375setModeById(modeId: string, modeKind: string): void {376this._modeObservable.set({ id: modeId, kind: modeKind }, undefined);377}378379setPermissionLevel(level: ChatPermissionLevel): void {380this._permissionLevel.set(level, undefined);381}382383setTitle(title: string): void {384this._title.set(title, undefined);385}386387setStatus(status: SessionStatus): void {388this._status.set(status, undefined);389}390391setArchived(archived: boolean): void {392this._isArchived.set(archived, undefined);393}394395setMode(mode: IChatMode | undefined): void {396if (this._mode?.id !== mode?.id) {397this._mode = mode;398const modeName = mode?.isBuiltin ? undefined : mode?.name.get();399this.setOption(AGENT_OPTION_ID, modeName ?? '');400}401}402403setOption(optionId: string, value: IChatSessionProviderOptionItem | string): void {404if (typeof value === 'string') {405this.selectedOptions.set(optionId, { id: value, name: value });406} else {407this.selectedOptions.set(optionId, value);408}409this.chatSessionsService.setSessionOption(this.resource, optionId, value);410}411412update(agentSession: IAgentSession): void {413const session = new AgentSessionAdapter(agentSession, this.providerId, undefined);414this._workspaceData.set(session.workspace.get(), undefined);415this._title.set(session.title.get(), undefined);416this._status.set(session.status.get(), undefined);417this._updatedAt.set(session.updatedAt.get(), undefined);418this._changes.set(session.changes.get(), undefined);419this._description.set(session.description.get(), undefined);420}421}422423function isModelOptionGroup(group: IChatSessionProviderOptionGroup): boolean {424if (group.id === 'models') {425return true;426}427const nameLower = group.name.toLowerCase();428return nameLower === 'model' || nameLower === 'models';429}430431function isRepositoriesOptionGroup(group: IChatSessionProviderOptionGroup): boolean {432return group.id === 'repositories';433}434435/**436* Remote new session for Cloud agent sessions.437* Implements {@link ICopilotChatSession} (session facade) and provides438* pre-send configuration methods for the new-session flow.439*/440export class RemoteNewSession extends Disposable implements ICopilotChatSession {441442// -- ISessionData fields --443444readonly id: string;445readonly providerId: string;446readonly sessionType: string;447readonly icon: ThemeIcon;448readonly createdAt: Date;449450private readonly _title = observableValue(this, '');451readonly title: IObservable<string> = this._title;452453private readonly _updatedAt = observableValue(this, new Date());454readonly updatedAt: IObservable<Date> = this._updatedAt;455456private readonly _status = observableValue(this, SessionStatus.Untitled);457readonly status: IObservable<SessionStatus> = this._status;458459private readonly _permissionLevel = observableValue(this, ChatPermissionLevel.Default);460readonly permissionLevel: IObservable<ChatPermissionLevel> = this._permissionLevel;461462private readonly _workspaceData = observableValue<ISessionWorkspace | undefined>(this, undefined);463readonly workspace: IObservable<ISessionWorkspace | undefined> = this._workspaceData;464465readonly changes: IObservable<readonly ISessionFileChange[]> = observableValue<readonly ISessionFileChange[]>(this, []);466467private readonly _modelIdObservable = observableValue<string | undefined>(this, undefined);468readonly modelId: IObservable<string | undefined> = this._modelIdObservable;469470readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined> = observableValue(this, undefined);471472readonly loading: IObservable<boolean> = observableValue(this, false);473474private readonly _isArchived = observableValue(this, false);475readonly isArchived: IObservable<boolean> = this._isArchived;476readonly isRead: IObservable<boolean> = observableValue(this, true);477readonly description: IObservable<IMarkdownString | undefined> = constObservable(undefined);478readonly lastTurnEnd: IObservable<Date | undefined> = constObservable(undefined);479readonly gitHubInfo: IObservable<IGitHubInfo | undefined> = constObservable(undefined);480readonly branch: IObservable<string | undefined> = constObservable(undefined);481readonly isolationMode: IObservable<IsolationMode | undefined> = constObservable(undefined);482readonly branches: IObservable<readonly string[]> = constObservable([]);483readonly gitRepository?: IGitRepository | undefined;484485readonly _hasGitRepo = observableValue(this, false);486readonly hasGitRepo: IObservable<boolean> = this._hasGitRepo;487488// -- New session configuration fields --489490private _repoUri: URI | undefined;491private _project: ISessionWorkspace | undefined;492private _modelId: string | undefined;493private _query: string | undefined;494private _attachedContext: IChatRequestVariableEntry[] | undefined;495496private readonly _onDidChangeOptionGroups = this._register(new Emitter<void>());497readonly onDidChangeOptionGroups: Event<void> = this._onDidChangeOptionGroups.event;498499readonly selectedOptions = new Map<string, IChatSessionProviderOptionItem>();500501get project(): ISessionWorkspace | undefined { return this._project; }502get selectedModelId(): string | undefined { return this._modelId; }503get chatMode(): IChatMode | undefined { return undefined; }504get query(): string | undefined { return this._query; }505get attachedContext(): IChatRequestVariableEntry[] | undefined { return this._attachedContext; }506get disabled(): boolean {507return !this._repoUri && !this.selectedOptions.has('repositories');508}509510private readonly _whenClauseKeys = new Set<string>();511512constructor(513readonly resource: URI,514readonly sessionWorkspace: ISessionWorkspace,515readonly target: AgentSessionTarget,516providerId: string,517@IChatSessionsService private readonly chatSessionsService: IChatSessionsService,518@IContextKeyService private readonly contextKeyService: IContextKeyService,519) {520super();521this.id = toSessionId(providerId, resource);522this.providerId = providerId;523this.sessionType = target;524this.icon = CopilotCloudSessionType.icon;525this.createdAt = new Date();526527this._updateWhenClauseKeys();528this._register(this.chatSessionsService.onDidChangeOptionGroups(() => {529this._updateWhenClauseKeys();530this._onDidChangeOptionGroups.fire();531}));532this._register(this.contextKeyService.onDidChangeContext(e => {533if (this._whenClauseKeys.size > 0 && e.affectsSome(this._whenClauseKeys)) {534this._onDidChangeOptionGroups.fire();535}536}));537538// Set workspace data539this._workspaceData.set(sessionWorkspace, undefined);540this._repoUri = sessionWorkspace.repositories[0]?.uri;541if (this._repoUri) {542const id = this._repoUri.path.substring(1);543this.setOption('repositories', { id, name: id });544}545546}547setPermissionLevel(level: ChatPermissionLevel): void {548throw new Error('Method not implemented.');549}550551// -- New session configuration methods --552553setIsolationMode(_mode: IsolationMode): void {554// No-op for remote sessions555}556557setBranch(_branch: string | undefined): void {558// No-op for remote sessions559}560561setModelId(modelId: string | undefined): void {562this._modelId = modelId;563}564565setTitle(title: string): void {566this._title.set(title, undefined);567}568569setStatus(status: SessionStatus): void {570this._status.set(status, undefined);571}572573setArchived(archived: boolean): void {574this._isArchived.set(archived, undefined);575}576577setMode(_mode: IChatMode | undefined): void {578// Intentionally a no-op: remote sessions do not support client-side mode selection.579}580581setOption(optionId: string, value: IChatSessionProviderOptionItem | string): void {582if (typeof value !== 'string') {583this.selectedOptions.set(optionId, value);584}585this.chatSessionsService.setSessionOption(this.resource, optionId, value);586}587588// --- Option group accessors ---589590getModelOptionGroup(): ISessionOptionGroup | undefined {591const groups = this._getOptionGroups();592if (!groups) {593return undefined;594}595const group = groups.find(g => isModelOptionGroup(g));596if (!group) {597return undefined;598}599return { group, value: this._getValueForGroup(group) };600}601602getOtherOptionGroups(): ISessionOptionGroup[] {603const groups = this._getOptionGroups();604if (!groups) {605return [];606}607return groups608.filter(g => !isModelOptionGroup(g) && !isRepositoriesOptionGroup(g) && this._isOptionGroupVisible(g))609.map(g => ({ group: g, value: this._getValueForGroup(g) }));610}611612getOptionValue(groupId: string): IChatSessionProviderOptionItem | undefined {613return this.selectedOptions.get(groupId);614}615616setOptionValue(groupId: string, value: IChatSessionProviderOptionItem): void {617this.setOption(groupId, value);618}619620// --- Internals ---621622private _getOptionGroups(): IChatSessionProviderOptionGroup[] | undefined {623return this.chatSessionsService.getOptionGroupsForSessionType(this.target);624}625626private _isOptionGroupVisible(group: IChatSessionProviderOptionGroup): boolean {627if (!group.when) {628return true;629}630const expr = ContextKeyExpr.deserialize(group.when);631return !expr || this.contextKeyService.contextMatchesRules(expr);632}633634private _updateWhenClauseKeys(): void {635this._whenClauseKeys.clear();636const groups = this._getOptionGroups();637if (!groups) {638return;639}640for (const group of groups) {641if (group.when) {642const expr = ContextKeyExpr.deserialize(group.when);643if (expr) {644for (const key of expr.keys()) {645this._whenClauseKeys.add(key);646}647}648}649}650}651652private _getValueForGroup(group: IChatSessionProviderOptionGroup): IChatSessionProviderOptionItem | undefined {653const selected = this.selectedOptions.get(group.id);654if (selected) {655return selected;656}657// Check for extension-set session option658const sessionOption = this.chatSessionsService.getSessionOption(this.resource, group.id);659if (sessionOption && typeof sessionOption !== 'string') {660return sessionOption;661}662if (typeof sessionOption === 'string') {663const item = group.items.find(i => i.id === sessionOption.trim());664if (item) {665return item;666}667}668// Default to first item marked as default, or first item669return group.items.find(i => i.default === true) ?? group.items[0];670}671672update(_session: IAgentSession): void { }673}674675/**676* New session for Claude agent sessions.677* Implements {@link ICopilotChatSession} (session facade) and provides678* pre-send configuration methods for the new-session flow.679* Simpler than {@link CopilotCLISession} because the Claude agent manages680* its own worktrees and branches at runtime.681*/682class ClaudeCodeNewSession extends Disposable implements ICopilotChatSession {683684// -- ISessionData fields --685686readonly id: string;687readonly providerId: string;688readonly sessionType: string;689readonly icon: ThemeIcon;690readonly createdAt: Date;691692private readonly _title = observableValue(this, '');693readonly title: IObservable<string> = this._title;694695private readonly _updatedAt = observableValue(this, new Date());696readonly updatedAt: IObservable<Date> = this._updatedAt;697698private readonly _status = observableValue(this, SessionStatus.Untitled);699readonly status: IObservable<SessionStatus> = this._status;700701private readonly _permissionLevel = observableValue(this, ChatPermissionLevel.Default);702readonly permissionLevel: IObservable<ChatPermissionLevel> = this._permissionLevel;703704private readonly _workspaceData = observableValue<ISessionWorkspace | undefined>(this, undefined);705readonly workspace: IObservable<ISessionWorkspace | undefined> = this._workspaceData;706707readonly changes: IObservable<readonly ISessionFileChange[]> = observableValue<readonly ISessionFileChange[]>(this, []);708709private readonly _modelIdObservable = observableValue<string | undefined>(this, undefined);710readonly modelId: IObservable<string | undefined> = this._modelIdObservable;711712private readonly _modeObservable = observableValue<{ readonly id: string; readonly kind: string } | undefined>(this, undefined);713readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined> = this._modeObservable;714715readonly loading: IObservable<boolean> = observableValue(this, false);716717private readonly _isArchived = observableValue(this, false);718readonly isArchived: IObservable<boolean> = this._isArchived;719readonly isRead: IObservable<boolean> = observableValue(this, true);720readonly description: IObservable<IMarkdownString | undefined> = constObservable(undefined);721readonly lastTurnEnd: IObservable<Date | undefined> = constObservable(undefined);722readonly gitHubInfo: IObservable<IGitHubInfo | undefined> = constObservable(undefined);723readonly branch: IObservable<string | undefined> = constObservable(undefined);724readonly isolationMode: IObservable<IsolationMode | undefined> = constObservable(undefined);725readonly branches: IObservable<readonly string[]> = constObservable([]);726readonly gitRepository?: IGitRepository | undefined;727728// -- New session configuration fields --729730private _modelId: string | undefined;731private _mode: IChatMode | undefined;732733readonly target = AgentSessionProviders.Claude;734readonly selectedOptions = new Map<string, IChatSessionProviderOptionItem>();735736get selectedModelId(): string | undefined { return this._modelId; }737get chatMode(): IChatMode | undefined { return this._mode; }738get query(): string | undefined { return undefined; }739get attachedContext(): IChatRequestVariableEntry[] | undefined { return undefined; }740get disabled(): boolean { return false; }741742constructor(743readonly resource: URI,744readonly sessionWorkspace: ISessionWorkspace,745providerId: string,746) {747super();748this.id = toSessionId(providerId, resource);749this.providerId = providerId;750this.sessionType = AgentSessionProviders.Claude;751this.icon = ClaudeCodeSessionType.icon;752this.createdAt = new Date();753754this._workspaceData.set(sessionWorkspace, undefined);755}756757setOption(optionId: string, value: IChatSessionProviderOptionItem | string): void {758if (typeof value === 'string') {759this.selectedOptions.set(optionId, { id: value, name: value });760} else {761this.selectedOptions.set(optionId, value);762}763}764765setPermissionLevel(level: ChatPermissionLevel): void {766this._permissionLevel.set(level, undefined);767}768769setIsolationMode(_mode: IsolationMode): void {770// No-op — Claude agent manages its own worktrees771}772773setBranch(_branch: string | undefined): void {774// No-op — Claude agent manages branches at runtime775}776777setModelId(modelId: string | undefined): void {778this._modelId = modelId;779this._modelIdObservable.set(modelId, undefined);780}781782setTitle(title: string): void {783this._title.set(title, undefined);784}785786setStatus(status: SessionStatus): void {787this._status.set(status, undefined);788}789790setArchived(archived: boolean): void {791this._isArchived.set(archived, undefined);792}793794setMode(mode: IChatMode | undefined): void {795this._mode = mode;796if (mode) {797this._modeObservable.set({ id: mode.id, kind: mode.kind }, undefined);798} else {799this._modeObservable.set(undefined, undefined);800}801}802803update(_session: IAgentSession): void { }804}805806/**807* Maps the existing {@link ChatSessionStatus} to the new {@link SessionStatus}.808*/809function toSessionStatus(status: ChatSessionStatus): SessionStatus {810switch (status) {811case ChatSessionStatus.InProgress:812return SessionStatus.InProgress;813case ChatSessionStatus.NeedsInput:814return SessionStatus.NeedsInput;815case ChatSessionStatus.Completed:816return SessionStatus.Completed;817case ChatSessionStatus.Failed:818return SessionStatus.Error;819}820}821822/**823* Adapts an existing {@link IAgentSession} from the chat layer into the new {@link ICopilotChatSession} facade.824*/825class AgentSessionAdapter implements ICopilotChatSession {826827readonly id: string;828readonly resource: URI;829readonly providerId: string;830readonly sessionType: string;831readonly icon: ThemeIcon;832readonly createdAt: Date;833834private readonly _workspace: ReturnType<typeof observableValue<ISessionWorkspace | undefined>>;835readonly workspace: IObservable<ISessionWorkspace | undefined>;836837private readonly _title: ReturnType<typeof observableValue<string>>;838readonly title: IObservable<string>;839840private readonly _updatedAt: ReturnType<typeof observableValue<Date>>;841readonly updatedAt: IObservable<Date>;842843private readonly _status: ReturnType<typeof observableValue<SessionStatus>>;844readonly status: IObservable<SessionStatus>;845846private readonly _changes: ReturnType<typeof observableValue<readonly ISessionFileChange[]>>;847readonly changes: IObservable<readonly ISessionFileChange[]>;848849readonly modelId: IObservable<string | undefined>;850readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined>;851readonly loading: IObservable<boolean>;852853private readonly _isArchived: ReturnType<typeof observableValue<boolean>>;854readonly isArchived: IObservable<boolean>;855856private readonly _isRead: ReturnType<typeof observableValue<boolean>>;857readonly isRead: IObservable<boolean>;858859private readonly _description: ReturnType<typeof observableValue<IMarkdownString | undefined>>;860readonly description: IObservable<IMarkdownString | undefined>;861862private readonly _lastTurnEnd: ReturnType<typeof observableValue<Date | undefined>>;863readonly lastTurnEnd: IObservable<Date | undefined>;864865private readonly _baseGitHubInfo: ReturnType<typeof observableValue<IGitHubInfo | undefined>>;866readonly gitHubInfo: IObservable<IGitHubInfo | undefined>;867868readonly permissionLevel: IObservable<ChatPermissionLevel> = constObservable(ChatPermissionLevel.Default);869readonly branch: IObservable<string | undefined> = constObservable(undefined);870readonly isolationMode: IObservable<IsolationMode | undefined> = constObservable(undefined);871readonly gitRepository?: IGitRepository | undefined;872readonly branches: IObservable<readonly string[]> = constObservable([]);873874constructor(875session: IAgentSession,876providerId: string,877private readonly _gitHubService: IGitHubService | undefined,878) {879this.id = toSessionId(providerId, session.resource);880this.resource = session.resource;881this.providerId = providerId;882this.sessionType = session.providerType;883this.icon = this._getSessionTypeIcon(session);884this.createdAt = new Date(session.timing.created);885this._workspace = observableValue(this, this._buildWorkspace(session));886this.workspace = this._workspace;887888this._title = observableValue(this, session.label);889this.title = this._title;890891const updatedTime = session.timing.lastRequestEnded ?? session.timing.lastRequestStarted ?? session.timing.created;892this._updatedAt = observableValue(this, new Date(updatedTime));893this.updatedAt = this._updatedAt;894895this._status = observableValue(this, toSessionStatus(session.status));896this.status = this._status;897898this._changes = observableValue<readonly ISessionFileChange[]>(this, this._extractChanges(session));899this.changes = this._changes;900901this.modelId = observableValue(this, undefined);902this.mode = observableValue(this, undefined);903this.loading = observableValue(this, false);904905this._isArchived = observableValue(this, session.isArchived());906this.isArchived = this._isArchived;907this._isRead = observableValue(this, session.isRead());908this.isRead = this._isRead;909this._description = observableValue(this, this._extractDescription(session));910this.description = this._description;911this._lastTurnEnd = observableValue(this, session.timing.lastRequestEnded ? new Date(session.timing.lastRequestEnded) : undefined);912this.lastTurnEnd = this._lastTurnEnd;913this._baseGitHubInfo = observableValue(this, this._extractGitHubInfo(session));914this.gitHubInfo = this._gitHubService915? derived(this, reader => {916const base = this._baseGitHubInfo.read(reader);917if (!base?.pullRequest || !this._gitHubService) {918return base;919}920const prModel = this._gitHubService.getPullRequest(base.owner, base.repo, base.pullRequest.number);921const livePR = prModel.pullRequest.read(reader);922if (!livePR) {923return base;924}925return { ...base, pullRequest: { ...base.pullRequest, icon: computePullRequestIcon(livePR.isDraft ? 'draft' : livePR.state) } };926})927: this._baseGitHubInfo;928}929930setPermissionLevel(level: ChatPermissionLevel): void {931throw new Error('Method not implemented.');932}933setBranch(branch: string | undefined): void {934throw new Error('Method not implemented.');935}936setIsolationMode(mode: IsolationMode): void {937throw new Error('Method not implemented.');938}939setModelId(modelId: string): void {940throw new Error('Method not implemented.');941}942setMode(chatMode: IChatMode | undefined): void {943throw new Error('Method not implemented.');944}945946/**947* Update reactive properties from a refreshed agent session.948*/949update(session: IAgentSession): void {950transaction(tx => {951this._title.set(session.label, tx);952const updatedTime = session.timing.lastRequestEnded ?? session.timing.lastRequestStarted ?? session.timing.created;953this._updatedAt.set(new Date(updatedTime), tx);954this._status.set(toSessionStatus(session.status), tx);955this._changes.set(this._extractChanges(session), tx);956this._isArchived.set(session.isArchived(), tx);957this._isRead.set(session.isRead(), tx);958this._description.set(this._extractDescription(session), tx);959this._lastTurnEnd.set(session.timing.lastRequestEnded ? new Date(session.timing.lastRequestEnded) : undefined, tx);960this._baseGitHubInfo.set(this._extractGitHubInfo(session), tx);961});962}963964private _getSessionTypeIcon(session: IAgentSession): ThemeIcon {965switch (session.providerType) {966case AgentSessionProviders.Background:967return CopilotCLISessionType.icon;968case AgentSessionProviders.Cloud:969return CopilotCloudSessionType.icon;970case AgentSessionProviders.Claude:971return ClaudeCodeSessionType.icon;972default:973return session.icon;974}975}976977private _extractDescription(session: IAgentSession): IMarkdownString | undefined {978if (!session.description) {979return undefined;980}981return typeof session.description === 'string' ? new MarkdownString(session.description) : session.description;982}983984private _extractGitHubInfo(session: IAgentSession): IGitHubInfo | undefined {985const metadata = session.metadata;986if (!metadata) {987return undefined;988}989990const { owner, repo } = this._extractOwnerRepo(session);991if (!owner || !repo) {992return undefined;993}994995const pullRequestUri = this._extractPullRequestUri(session);996if (!pullRequestUri) {997return { owner, repo };998}9991000const prNumber = this._extractPullRequestNumber(session, pullRequestUri);1001if (prNumber === undefined) {1002return { owner, repo };1003}10041005return { owner, repo, pullRequest: { number: prNumber, uri: pullRequestUri, icon: this._extractPullRequestStateIcon(session) } };1006}10071008private _extractPullRequestNumber(session: IAgentSession, pullRequestUri: URI): number | undefined {1009const metadata = session.metadata;1010if (typeof metadata?.pullRequestNumber === 'number') {1011return metadata.pullRequestNumber as number;1012}1013const match = /\/pull\/(\d+)/.exec(pullRequestUri.path);1014if (match) {1015return parseInt(match[1], 10);1016}1017return undefined;1018}10191020private _extractOwnerRepo(session: IAgentSession): { owner: string | undefined; repo: string | undefined } {1021const metadata = session.metadata;1022if (!metadata) {1023return { owner: undefined, repo: undefined };1024}10251026// Direct owner + name fields1027if (typeof metadata.owner === 'string' && typeof metadata.name === 'string') {1028return { owner: metadata.owner, repo: metadata.name };1029}10301031// repositoryNwo: "owner/repo"1032if (typeof metadata.repositoryNwo === 'string') {1033const parts = (metadata.repositoryNwo as string).split('/');1034if (parts.length === 2) {1035return { owner: parts[0], repo: parts[1] };1036}1037}10381039// Parse from workspace repository URI (cloud sessions)1040const repoUri = this._buildWorkspace(session)?.repositories[0]?.uri;1041if (repoUri && repoUri.scheme === GITHUB_REMOTE_FILE_SCHEME) {1042const parts = repoUri.path.split('/').filter(Boolean);1043if (parts.length >= 2) {1044return { owner: decodeURIComponent(parts[0]), repo: decodeURIComponent(parts[1]) };1045}1046}10471048// Parse from pullRequestUrl1049if (typeof metadata.pullRequestUrl === 'string') {1050const match = /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(metadata.pullRequestUrl as string);1051if (match) {1052return { owner: match[1], repo: match[2] };1053}1054}10551056return { owner: undefined, repo: undefined };1057}10581059private _extractPullRequestStateIcon(session: IAgentSession): ThemeIcon | undefined {1060const metadata = session.metadata;1061const state = metadata?.pullRequestState;1062if (typeof state === 'string') {1063return computePullRequestIcon(state as GitHubPullRequestState | 'draft');1064}1065return undefined;1066}10671068private _extractPullRequestUri(session: IAgentSession): URI | undefined {1069const metadata = session.metadata;1070if (!metadata) {1071return undefined;1072}10731074const url = metadata.pullRequestUrl as string | undefined;1075if (url) {1076try {1077return URI.parse(url);1078} catch {1079// fall through1080}1081}10821083// Construct from pullRequestNumber + owner/repo1084const prNumber = metadata.pullRequestNumber as number | undefined;1085if (typeof prNumber === 'number') {1086const owner = metadata.owner as string | undefined;1087const name = metadata.name as string | undefined;1088if (owner && name) {1089return URI.parse(`https://github.com/${owner}/${name}/pull/${prNumber}`);1090}1091}10921093return undefined;1094}10951096private _extractChanges(session: IAgentSession): readonly ISessionFileChange[] {1097if (!session.changes) {1098return [];1099}1100if (Array.isArray(session.changes)) {1101return session.changes as ISessionFileChange[];1102}1103// Summary object — create a synthetic entry for total insertions/deletions1104const summary = session.changes as { readonly files: number; readonly insertions: number; readonly deletions: number };1105if (summary.insertions > 0 || summary.deletions > 0) {1106return [{1107modifiedUri: URI.parse('summary://changes'),1108insertions: summary.insertions,1109deletions: summary.deletions,1110}];1111}1112return [];1113}11141115private _buildWorkspace(session: IAgentSession): ISessionWorkspace | undefined {1116const [repoUri, worktreeUri, branchName, baseBranchName] = this._extractRepositoryFromMetadata(session);11171118const repository: ISessionRepository = {1119uri: repoUri ?? URI.parse('unknown:///'),1120workingDirectory: worktreeUri,1121detail: branchName,1122baseBranchName,1123};11241125return {1126label: getRepositoryName(session) ?? basename(repository.uri),1127icon: repoUri?.scheme === GITHUB_REMOTE_FILE_SCHEME ? Codicon.repo : Codicon.folder,1128group: repoUri?.scheme === GITHUB_REMOTE_FILE_SCHEME ? SESSION_WORKSPACE_GROUP_GITHUB : SESSION_WORKSPACE_GROUP_LOCAL,1129repositories: [repository],1130requiresWorkspaceTrust: session.providerType !== AgentSessionProviders.Cloud,1131};1132}11331134/**1135* Extract repository/worktree information from session metadata.1136* Mirrors the logic in sessionsManagementService.getRepositoryFromMetadata().1137*/1138private _extractRepositoryFromMetadata(session: IAgentSession): [URI | undefined, URI | undefined, string | undefined, string | undefined] {1139const metadata = session.metadata;1140if (!metadata) {1141return [undefined, undefined, undefined, undefined];1142}11431144if (session.providerType === AgentSessionProviders.Cloud) {1145const branch = typeof metadata.branch === 'string' ? metadata.branch : 'HEAD';1146const repositoryUri = URI.from({1147scheme: GITHUB_REMOTE_FILE_SCHEME,1148authority: 'github',1149path: `/${metadata.owner}/${metadata.name}/${encodeURIComponent(branch)}`1150});1151return [repositoryUri, undefined, undefined, undefined];1152}11531154// Background/CLI sessions: check workingDirectoryPath first1155const workingDirectoryPath = metadata?.workingDirectoryPath as string | undefined;1156if (workingDirectoryPath) {1157return [URI.file(workingDirectoryPath), undefined, undefined, undefined];1158}11591160// Fall back to repositoryPath + worktreePath1161const repositoryPath = metadata?.repositoryPath as string | undefined;1162const repositoryPathUri = typeof repositoryPath === 'string' ? URI.file(repositoryPath) : undefined;11631164const worktreePath = metadata?.worktreePath as string | undefined;1165const worktreePathUri = typeof worktreePath === 'string' ? URI.file(worktreePath) : undefined;11661167const worktreeBranchName = metadata?.branchName as string | undefined;1168const worktreeBaseBranchName = metadata?.baseBranchName as string | undefined;11691170return [1171URI.isUri(repositoryPathUri) ? repositoryPathUri : undefined,1172URI.isUri(worktreePathUri) ? worktreePathUri : undefined,1173worktreeBranchName,1174worktreeBaseBranchName,1175];1176}1177}11781179/**1180* Default sessions provider for Copilot CLI and Cloud session types.1181* Wraps the existing session infrastructure into the extensible provider model.1182*/1183export class CopilotChatSessionsProvider extends Disposable implements ISessionsProvider {11841185readonly id = COPILOT_PROVIDER_ID;1186readonly label = localize('copilotChatSessionsProvider', "Copilot Chat");1187readonly icon = Codicon.copilot;1188get sessionTypes(): readonly ISessionType[] {1189const types: ISessionType[] = [CopilotCLISessionType, CopilotCloudSessionType];1190if (this._claudeEnabled) {1191types.push(ClaudeCodeSessionType);1192}1193return types;1194}11951196private readonly _onDidChangeSessionTypes = this._register(new Emitter<void>());1197readonly onDidChangeSessionTypes: Event<void> = this._onDidChangeSessionTypes.event;11981199private readonly _onDidChangeSessions = this._register(new Emitter<ISessionChangeEvent>());1200readonly onDidChangeSessions: Event<ISessionChangeEvent> = this._onDidChangeSessions.event;12011202private readonly _onDidReplaceSession = this._register(new Emitter<{ readonly from: ISession; readonly to: ISession }>());1203readonly onDidReplaceSession: Event<{ readonly from: ISession; readonly to: ISession }> = this._onDidReplaceSession.event;12041205/** Cache of adapted sessions, keyed by resource URI string. */1206private readonly _sessionCache = new Map<string, AgentSessionAdapter | CopilotCLISession | RemoteNewSession | ClaudeCodeNewSession>();12071208/** Cache of ISession wrappers, keyed by session group ID. */1209private readonly _sessionGroupCache = new Map<string, ISession>();12101211/** Cache of chats keyed by raw session ID (resource path without leading slash). */1212private _chatByRawSessionIdCache: Map<string, ICopilotChatSession> | undefined;12131214/** Cache of derived group IDs keyed by chat ID. */1215private _groupIdByChatIdCache: Map<string, string> | undefined;12161217/** Cache of sorted chat IDs keyed by group ID. */1218private _chatIdsByGroupIdCache: Map<string, string[]> | undefined;12191220/**1221* Emitter fired when the set of chats in a group changes,1222* used to update the chats observable in `_chatToSession`.1223*/1224private readonly _onDidGroupMembershipChange = this._register(new Emitter<{ sessionId: string }>());12251226private readonly _multiChatEnabled: boolean;1227private _claudeEnabled: boolean;12281229readonly browseActions: readonly ISessionWorkspaceBrowseAction[];1230readonly supportsLocalWorkspaces = true;12311232constructor(1233@IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService,1234@IChatService private readonly chatService: IChatService,1235@IChatSessionsService private readonly chatSessionsService: IChatSessionsService,1236@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,1237@IDialogService private readonly dialogService: IDialogService,1238@ICommandService private readonly commandService: ICommandService,1239@IInstantiationService private readonly instantiationService: IInstantiationService,1240@ILanguageModelsService private readonly languageModelsService: ILanguageModelsService,1241@ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService,1242@IConfigurationService private readonly configurationService: IConfigurationService,1243@ILogService private readonly logService: ILogService,1244@IGitHubService private readonly gitHubService: IGitHubService,1245@ILabelService private readonly labelService: ILabelService,1246) {1247super();12481249this._multiChatEnabled = this.configurationService.getValue<boolean>(COPILOT_MULTI_CHAT_SETTING) ?? true;1250this._claudeEnabled = this.configurationService.getValue<boolean>(CLAUDE_CODE_ENABLED_SETTING);12511252this._register(this.configurationService.onDidChangeConfiguration(e => {1253if (e.affectsConfiguration(CLAUDE_CODE_ENABLED_SETTING)) {1254const claudeEnabled = this.configurationService.getValue<boolean>(CLAUDE_CODE_ENABLED_SETTING);1255if (this._claudeEnabled !== claudeEnabled) {1256this._claudeEnabled = claudeEnabled;1257this._onDidChangeSessionTypes.fire();1258this._refreshSessionCache();1259}1260}1261}));12621263this.browseActions = [1264{1265label: localize('repositories', "Repositories"),1266group: SESSION_WORKSPACE_GROUP_GITHUB,1267icon: Codicon.library,1268providerId: this.id,1269run: () => this._browseForRepo(),1270},1271];12721273// Forward session changes from the underlying model1274this._register(this.agentSessionsService.model.onDidChangeSessions(() => {1275this._refreshSessionCache();1276}));1277}12781279// -- Sessions --12801281getSessionTypes(workspaceUri: URI): ISessionType[] {1282if (workspaceUri.scheme === GITHUB_REMOTE_FILE_SCHEME || workspaceUri.scheme === SessionType.CopilotCloud) {1283return [CopilotCloudSessionType];1284}1285const types: ISessionType[] = [CopilotCLISessionType];1286if (this._claudeEnabled) {1287types.push(ClaudeCodeSessionType);1288}1289return types;1290}12911292getSessions(): ISession[] {1293this._ensureSessionCache();12941295if (!this._isMultiChatEnabled()) {1296return Array.from(this._sessionCache.values()).map(chat => this._chatToSession(chat));1297}12981299const allChats = Array.from(this._sessionCache.values()).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());13001301// Group chats using sessionParentId from metadata1302const seen = new Set<string>();1303const sessions: ISession[] = [];13041305for (const chat of allChats) {1306const groupId = this._getGroupIdForChat(chat);1307if (!seen.has(groupId)) {1308seen.add(groupId);1309sessions.push(this._chatToSession(chat));1310}1311}1312return sessions;1313}13141315// -- Session Lifecycle --13161317private _currentNewSession: NewSession | undefined;13181319getSession(sessionId: string): ICopilotChatSession | undefined {1320if (this._currentNewSession?.id === sessionId) {1321return this._currentNewSession;1322}1323return this._findChatSession(sessionId);1324}13251326createNewSession(workspaceUri: URI, sessionTypeId: string): ISession {1327if (this._currentNewSession) {1328this._currentNewSession.dispose();1329this._currentNewSession = undefined;1330}13311332const workspace = this.resolveWorkspace(workspaceUri);1333if (!workspace) {1334throw new Error(`Cannot resolve workspace for URI: ${workspaceUri.toString()}`);1335}13361337if (workspaceUri.scheme === GITHUB_REMOTE_FILE_SCHEME) {1338if (sessionTypeId !== CopilotCloudSessionType.id) {1339throw new Error('Only Copilot Cloud sessions can be created for GitHub repositories');1340}1341const resource = URI.from({ scheme: AgentSessionProviders.Cloud, path: `/untitled-${generateUuid()}` });1342const session = this.instantiationService.createInstance(RemoteNewSession, resource, workspace, AgentSessionProviders.Cloud, this.id);1343this._currentNewSession = session;1344return this._chatToSession(session);1345}13461347if (sessionTypeId === ClaudeCodeSessionType.id) {1348const resource = URI.from({ scheme: AgentSessionProviders.Claude, path: `/untitled-${generateUuid()}` });1349const session = this.instantiationService.createInstance(ClaudeCodeNewSession, resource, workspace, this.id);1350this._currentNewSession = session;1351return this._chatToSession(session);1352}13531354if (sessionTypeId !== CopilotCLISessionType.id) {1355throw new Error(`Unsupported session type '${sessionTypeId}' for local workspaces`);1356}1357const resource = URI.from({ scheme: AgentSessionProviders.Background, path: `/untitled-${generateUuid()}` });1358const session = this.instantiationService.createInstance(CopilotCLISession, resource, workspace, this.id);1359this._currentNewSession = session;1360return this._chatToSession(session);1361}13621363setModel(sessionId: string, modelId: string): void {1364if (this._currentNewSession?.id === sessionId) {1365this._currentNewSession.setModelId(modelId);1366}1367}13681369// -- Session Actions --13701371async archiveSession(sessionId: string): Promise<void> {1372const agentSession = this._findAgentSession(sessionId);1373if (agentSession) {1374agentSession.setArchived(true);1375return;1376}13771378// Temp session that hasn't been committed — archive it in-place1379// so the user can still review whatever content was produced.1380const chatSession = this._findChatSession(sessionId);1381if (chatSession && isNewSession(chatSession)) {1382chatSession.setArchived(true);1383this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(chatSession)] });1384return;1385}1386}13871388async unarchiveSession(sessionId: string): Promise<void> {1389const agentSession = this._findAgentSession(sessionId);1390if (agentSession) {1391agentSession.setArchived(false);1392return;1393}13941395// Temp session that hasn't been committed — unarchive it in-place1396const chatSession = this._findChatSession(sessionId);1397if (chatSession && isNewSession(chatSession)) {1398chatSession.setArchived(false);1399this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(chatSession)] });1400}1401}14021403async deleteSession(sessionId: string): Promise<void> {1404const chatIds = this._getChatIdsInGroup(sessionId);14051406// Collect all agent sessions to delete (primary + group members)1407const allChatIds = new Set([sessionId, ...chatIds]);1408const agentSessions: IAgentSession[] = [];1409for (const chatId of allChatIds) {1410const agentSession = this._findAgentSession(chatId);1411if (agentSession) {1412agentSessions.push(agentSession);1413}1414}14151416if (agentSessions.length === 0) {1417// Temp session that hasn't been committed — remove it directly1418this._cleanupTempSession(sessionId);1419return;1420}14211422// Confirm deletion1423const confirmed = await this.dialogService.confirm({1424message: localize('deleteSession.confirm', "Are you sure you want to delete this session?"),1425detail: agentSessions.length > 11426? localize('deleteSession.detailMultiple', "This will delete all {0} chats in this session. This action cannot be undone.", agentSessions.length)1427: localize('deleteSession.detail', "This action cannot be undone."),1428primaryButton: localize('deleteSession.delete', "Delete")1429});1430if (!confirmed.confirmed) {1431return;1432}14331434await this._deleteAgentSessions(agentSessions);14351436this._sessionGroupCache.delete(sessionId);1437this._refreshSessionCache();1438}14391440async renameChat(sessionId: string, chatUri: URI, title: string): Promise<void> {1441const agentSession = this.agentSessionsService.getSession(chatUri);1442if (agentSession?.providerType === CopilotCLISessionType.id) {1443await this.commandService.executeCommand('github.copilot.cli.sessions.setTitle', { resource: chatUri }, title);1444return;1445}1446if (agentSession?.providerType === AgentSessionProviders.Claude) {1447await this.commandService.executeCommand('github.copilot.claude.sessions.rename', { resource: chatUri }, title);1448return;1449}1450throw new Error('Renaming is not supported for this session type');1451}14521453async deleteChat(sessionId: string, chatUri: URI): Promise<void> {1454const session = this._findSession(sessionId);14551456if (!session?.capabilities.supportsMultipleChats) {1457throw new Error('Deleting individual chats is not supported when multi-chat is disabled');1458}14591460const chatIds = this._getChatIdsInGroup(sessionId);14611462// Find the chat matching the URI first, before deciding whether to1463// delete the entire session. This prevents accidentally deleting the1464// whole session when the grouping cache is stale and chatIds doesn't1465// include the chat being closed.1466const chatId = chatIds.find(id => {1467const chat = this._sessionCache.get(this._localIdFromchatId(id));1468return chat && chat.resource.toString() === chatUri.toString();1469});1470if (!chatId) {1471return;1472}14731474if (chatIds.length <= 1) {1475// This is the only chat in the session — delete the entire session1476return this.deleteSession(sessionId);1477}14781479// Delete the underlying agent session first.1480// _refreshSessionCacheMultiChat handles the removed chat gracefully:1481// it detects the chat belongs to a group with remaining siblings and1482// fires a changed event on the parent session instead of a removed event.1483const agentSession = this._findAgentSession(chatId);1484if (agentSession) {1485// Confirm deletion1486const confirmed = await this.dialogService.confirm({1487message: localize('deleteChat.confirm', "Are you sure you want to delete this chat?"),1488detail: localize('deleteChat.detail', "This action cannot be undone."),1489primaryButton: localize('deleteChat.delete', "Delete")1490});1491if (!confirmed.confirmed) {1492return;1493}14941495await this._deleteAgentSessions([agentSession]);1496} else {1497// Untitled chat (not yet committed) - clean up directly1498const chat = this._findChatSession(chatId);1499if (chat) {1500const key = chat.resource.toString();1501this._sessionCache.delete(key);1502this._invalidateGroupingCaches();1503if (this._currentNewSession?.id === chatId) {1504this._currentNewSession.dispose();1505this._currentNewSession = undefined;1506}1507}1508this._sessionGroupCache.delete(sessionId);1509this._onDidGroupMembershipChange.fire({ sessionId });1510const remainingChatIds = this._getChatIdsInGroup(sessionId);1511const primaryChatId = remainingChatIds[0];1512const primaryChat = primaryChatId ? this._sessionCache.get(this._localIdFromchatId(primaryChatId)) : undefined;1513if (primaryChat) {1514this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(primaryChat)] });1515}1516}1517}15181519private async _deleteAgentSessions(agentSessions: IAgentSession[]): Promise<void> {1520const cliSessionItems: { resource: URI }[] = [];1521for (const agentSession of agentSessions) {1522if (agentSession.providerType === CopilotCLISessionType.id) {1523cliSessionItems.push({ resource: agentSession.resource });1524} else {1525await this.chatService.removeHistoryEntry(agentSession.resource);1526}1527}1528if (cliSessionItems.length > 0) {1529await this.commandService.executeCommand('agents.github.copilot.cli.deleteSessions', cliSessionItems, { skipConfirmation: true });1530}1531}15321533// -- Send --15341535async sendAndCreateChat(sessionId: string, options: ISendRequestOptions): Promise<ISession> {1536// Determine if this is the first chat or a subsequent chat1537const session = this._currentNewSession;1538if (session && session.id === sessionId) {1539// First chat — use the existing new-session flow1540return this._sendFirstChat(session, options);1541}15421543if (!this._isMultiChatEnabled()) {1544throw new Error(`Session '${sessionId}' not found or not a new session`);1545}15461547// Subsequent chat — create a new chat within the existing session1548return this._sendSubsequentChat(sessionId, options);1549}15501551addChat(sessionId: string): IChat {1552const session = this._findSession(sessionId);1553if (!session?.capabilities.supportsMultipleChats) {1554throw new Error('Multiple chats per session is not supported');1555}15561557const newChatSession = this._createNewSessionFrom(sessionId);15581559newChatSession.setTitle(localize('new chat', "New Chat"));1560const key = newChatSession.resource.toString();1561this._sessionCache.set(key, newChatSession);1562this._invalidateGroupingCaches();15631564// Invalidate the session group cache so it rebuilds with the new chat1565this._sessionGroupCache.delete(sessionId);1566this._onDidGroupMembershipChange.fire({ sessionId });1567this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(newChatSession)] });15681569return this._toChat(newChatSession);1570}15711572async sendRequest(sessionId: string, chatResource: URI, options: ISendRequestOptions): Promise<ISession> {1573if (!this._isMultiChatEnabled()) {1574throw new Error('Multiple chats per session is not supported');1575}15761577// The chat must already exist (created via addChat)1578const key = chatResource.toString();1579const chatSession = this._sessionCache.get(key);1580if (!chatSession || !(chatSession instanceof CopilotCLISession)) {1581throw new Error(`Chat '${chatResource.toString()}' not found in session '${sessionId}'`);1582}15831584return this._sendExistingChat(sessionId, chatSession, options);1585}15861587/**1588* Sends the first chat for a newly created session.1589* Adds the temp session to the cache, waits for commit, then replaces it.1590*/1591private async _sendFirstChat(session: CopilotCLISession | RemoteNewSession | ClaudeCodeNewSession, options: ISendRequestOptions): Promise<ISession> {15921593const { query, attachedContext } = options;15941595const contribution = this.chatSessionsService.getChatSessionContribution(session.target);15961597// Resolve mode1598const modeKind = session.chatMode?.kind ?? ChatModeKind.Agent;1599const modeIsBuiltin = session.chatMode ? isBuiltinChatMode(session.chatMode) : true;1600const modeId: 'ask' | 'agent' | 'edit' | 'custom' | undefined = modeIsBuiltin ? modeKind : 'custom';16011602const rawModeInstructions = session.chatMode?.modeInstructions?.get();1603const modeInstructions = rawModeInstructions ? {1604name: session.chatMode!.name.get(),1605content: rawModeInstructions.content,1606toolReferences: this.toolsService.toToolReferences(rawModeInstructions.toolReferences),1607metadata: rawModeInstructions.metadata,1608} : undefined;16091610const permissionLevel = session.permissionLevel.get();16111612const sendOptions: IChatSendRequestOptions = {1613location: ChatAgentLocation.Chat,1614userSelectedModelId: session.selectedModelId,1615modeInfo: {1616kind: modeKind,1617isBuiltin: modeIsBuiltin,1618modeInstructions,1619modeId,1620applyCodeBlockSuggestionId: undefined,1621permissionLevel,1622},1623agentIdSilent: contribution?.type,1624attachedContext,1625};16261627// Claude sessions use the ChatSessionItemController API which creates1628// real session URIs upfront, bypassing the untitled→commit→swap flow.1629if (session instanceof ClaudeCodeNewSession) {1630return this._sendFirstChatViaController(session, query, sendOptions);1631}16321633// Open chat widget and set permission level1634await this.chatSessionsService.getOrCreateChatSession(session.resource, CancellationToken.None);1635const chatWidget = await this.chatWidgetService.openSession(session.resource, ChatViewPaneTarget);1636if (!chatWidget) {1637throw new Error('[DefaultCopilotProvider] Failed to open chat widget');1638}16391640if (permissionLevel) {1641chatWidget.input.setPermissionLevel(permissionLevel);1642}16431644// Load session model with selected options1645await this._applySessionModelState(session.resource, session);16461647// Send request1648this.logService.debug(`[CopilotChatSessionsProvider] Sending first chat for session ${session.id} with options:`, {1649userSelectedModelId: sendOptions.userSelectedModelId,1650});1651const result = await this.chatService.sendRequest(session.resource, query, sendOptions);1652if (result.kind === 'rejected') {1653throw new Error(`[DefaultCopilotProvider] sendRequest rejected: ${result.reason}`);1654}16551656// Extract promises to detect cancellation vs normal completion1657const responseCompletePromise = result.kind === 'sent'1658? result.data.responseCompletePromise1659: undefined;1660const responseCreatedPromise = result.kind === 'sent'1661? result.data.responseCreatedPromise1662: undefined;16631664// Add the new session to the sessions model immediately so it appears in the sessions list1665session.setTitle(localize('new session', "New Session"));1666session.setStatus(SessionStatus.InProgress);1667const key = session.resource.toString();1668this._sessionCache.set(key, session);1669this._invalidateGroupingCaches();1670const newSession = this._chatToSession(session);1671this._onDidChangeSessions.fire({ added: [newSession], removed: [], changed: [] });16721673try {16741675// Wait for the session to be committed (URI swapped from untitled to real)1676const committedResource = await this._waitForCommittedSession(session.resource, responseCompletePromise, responseCreatedPromise);16771678// Wait for _refreshSessionCache to populate the committed adapter1679const committedChat = await this._waitForSessionInCache(committedResource);16801681// Remove the temp from the cache (the adapter now owns the committed key)1682this._sessionCache.delete(key);1683this._currentNewSession = undefined;1684session.dispose();16851686const committedSession = this._chatToSession(committedChat);16871688// Notify listeners that the temp session was replaced by the committed one1689this._sessionGroupCache.delete(session.id);1690this._onDidReplaceSession.fire({ from: newSession, to: committedSession });16911692return committedSession;1693} catch (error) {1694this._currentNewSession = undefined;16951696if (error instanceof CancellationError) {1697// Session was stopped before the agent created a worktree.1698// Keep the temp session in the list so the user can review1699// whatever content the agent produced before cancellation.1700session.setStatus(SessionStatus.Completed);1701this._onDidChangeSessions.fire({ added: [], removed: [], changed: [newSession] });1702return newSession;1703}17041705// Unexpected error — clean up the temp session entirely1706this._sessionCache.delete(key);1707this._invalidateGroupingCaches();1708this._sessionGroupCache.delete(session.id);1709this._onDidChangeSessions.fire({ added: [], removed: [this._chatToSession(session)], changed: [] });1710session.dispose();1711throw error;1712}1713}17141715/**1716* Sends the first chat for a Claude session using the controller API.1717*1718* Unlike the legacy untitled→commit→swap flow, this creates the real1719* session URI upfront via {@link IChatSessionsService.createNewChatSessionItem},1720* then sends the request directly to that URI. This avoids the commit1721* event race and ensures the session appears under the correct workspace1722* immediately.1723*/1724private async _sendFirstChatViaController(1725session: ClaudeCodeNewSession,1726query: string,1727sendOptions: IChatSendRequestOptions,1728): Promise<ISession> {1729// Create the real session item via the controller's newChatSessionItemHandler.1730// This returns a session with a real (non-untitled) URI.1731const newItem = await this.chatSessionsService.createNewChatSessionItem(1732session.target,1733{ prompt: query, initialSessionOptions: session.selectedOptions.size > 0 ? session.selectedOptions : undefined },1734CancellationToken.None,1735);1736if (!newItem) {1737throw new Error('[CopilotChatSessionsProvider] Failed to create Claude session item');1738}17391740const realResource = newItem.resource;17411742// Open chat session and widget with the real URI1743await this.chatSessionsService.getOrCreateChatSession(realResource, CancellationToken.None);1744const chatWidget = await this.chatWidgetService.openSession(realResource, ChatViewPaneTarget);1745if (!chatWidget) {1746throw new Error('[CopilotChatSessionsProvider] Failed to open chat widget');1747}17481749const permissionLevel = sendOptions.modeInfo?.permissionLevel;1750if (permissionLevel) {1751chatWidget.input.setPermissionLevel(permissionLevel);1752}17531754// Load session model and apply selected options1755await this._applySessionModelState(realResource, session);17561757// Send request to the real URI — sendRequest skips the1758// createNewChatSessionItem block since the URI is not untitled.1759this.logService.debug(`[CopilotChatSessionsProvider] Sending first Claude chat to ${realResource.toString()} with options:`, {1760userSelectedModelId: sendOptions.userSelectedModelId,1761});1762const result = await this.chatService.sendRequest(realResource, query, sendOptions);1763if (result.kind === 'rejected') {1764throw new Error(`[CopilotChatSessionsProvider] sendRequest rejected: ${result.reason}`);1765}17661767// Add the temp session to the cache immediately so it appears in the sessions list1768session.setTitle(newItem.label);1769session.setStatus(SessionStatus.InProgress);1770const tempKey = session.resource.toString();1771this._sessionCache.set(tempKey, session);1772const tempSession = this._chatToSession(session);1773this._onDidChangeSessions.fire({ added: [tempSession], removed: [], changed: [] });17741775// Extract response promises for cancellation detection1776const responseCreatedPromise = result.kind === 'sent'1777? result.data.responseCreatedPromise1778: undefined;1779const cts = new CancellationTokenSource();1780// TODO: Understand why we are not awaiting this an only handling the cancellation1781responseCreatedPromise?.then(r => {1782if (r?.isCanceled) {1783cts.cancel();1784}1785});17861787try {1788// Wait for the agent sessions model to pick up the real session,1789// racing against cancellation so we don't timeout when the user1790// stops the request before the agent creates a worktree.1791const committedChat = await this._waitForSessionInCache(realResource, cts.token);17921793// Clean up temp session and replace with the real adapter1794this._sessionCache.delete(tempKey);1795this._currentNewSession = undefined;1796session.dispose();17971798const committedSession = this._chatToSession(committedChat);1799this._sessionGroupCache.delete(session.id);1800this._onDidReplaceSession.fire({ from: tempSession, to: committedSession });18011802return committedSession;1803} catch (error) {1804this._currentNewSession = undefined;18051806if (error instanceof CancellationError) {1807// Keep the temp session visible so the user can review1808// whatever content the agent produced before the cancellation.1809session.setStatus(SessionStatus.Completed);1810this._onDidChangeSessions.fire({ added: [], removed: [], changed: [tempSession] });1811return tempSession;1812}18131814// Unexpected error — clean up the temp session entirely1815this._sessionCache.delete(tempKey);1816this._sessionGroupCache.delete(session.id);1817this._onDidChangeSessions.fire({ added: [], removed: [tempSession], changed: [] });1818session.dispose();1819throw error;1820} finally {1821cts.dispose();1822}1823}18241825/**1826* Loads the session model for the given resource and applies the selected1827* language model, chat mode, and session options from the new session object.1828*/1829private async _applySessionModelState(1830resource: URI,1831session: { selectedModelId?: string; chatMode?: IChatMode; selectedOptions: Map<string, IChatSessionProviderOptionItem> },1832): Promise<void> {1833const modelRef = await this.chatService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None);1834if (!modelRef) {1835return;1836}1837const model = modelRef.object;1838if (session.selectedModelId) {1839const languageModel = this.languageModelsService.lookupLanguageModel(session.selectedModelId);1840if (languageModel) {1841model.inputModel.setState({ selectedModel: { identifier: session.selectedModelId, metadata: languageModel } });1842}1843}1844if (session.chatMode) {1845model.inputModel.setState({ mode: { id: session.chatMode.id, kind: session.chatMode.kind } });1846}1847if (session.selectedOptions.size > 0) {1848this.chatSessionsService.updateSessionOptions(resource, session.selectedOptions);1849}1850modelRef.dispose();1851}18521853/**1854* Sends a subsequent chat for an existing session that already has chats.1855* Creates a new {@link CopilotCLISession} from the existing workspace and1856* fires a `changed` event on the grouped session rather than an `added` event.1857*/1858private async _sendSubsequentChat(sessionId: string, options: ISendRequestOptions): Promise<ISession> {1859// Reuse a chat that was pre-created by addChat(), otherwise create one1860let newChatSession: CopilotCLISession;1861if (this._currentNewSession && this._getGroupIdForChat(this._currentNewSession) === sessionId) {1862newChatSession = this._currentNewSession as CopilotCLISession;1863} else {1864newChatSession = this._createNewSessionFrom(sessionId);1865newChatSession.setTitle(localize('new chat', "New Chat"));1866const key = newChatSession.resource.toString();1867this._sessionCache.set(key, newChatSession);1868this._invalidateGroupingCaches();1869this._sessionGroupCache.delete(sessionId);1870this._onDidGroupMembershipChange.fire({ sessionId });1871this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(newChatSession)] });1872}18731874return this._sendExistingChat(sessionId, newChatSession, options);1875}18761877/**1878* Sends a request for an existing chat session that is already registered1879* in the cache.1880*/1881private async _sendExistingChat(sessionId: string, newChatSession: CopilotCLISession, options: ISendRequestOptions): Promise<ISession> {1882// Mark as in progress now that we're sending1883newChatSession.setStatus(SessionStatus.InProgress);1884const key = newChatSession.resource.toString();18851886// Invalidate the session group cache so it rebuilds with the new chat1887this._sessionGroupCache.delete(sessionId);1888this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(newChatSession)] });18891890const { query, attachedContext } = options;18911892const contribution = this.chatSessionsService.getChatSessionContribution(newChatSession.target);18931894const sendOptions: IChatSendRequestOptions = {1895location: ChatAgentLocation.Chat,1896userSelectedModelId: newChatSession.selectedModelId,1897modeInfo: {1898kind: ChatModeKind.Agent,1899isBuiltin: true,1900modeInstructions: undefined,1901modeId: 'agent',1902applyCodeBlockSuggestionId: undefined,1903permissionLevel: newChatSession.permissionLevel.get(),1904},1905agentIdSilent: contribution?.type,1906attachedContext,1907};19081909// Open chat widget1910await this.chatSessionsService.getOrCreateChatSession(newChatSession.resource, CancellationToken.None);1911const chatWidget = await this.chatWidgetService.openSession(newChatSession.resource, ChatViewPaneTarget);1912if (!chatWidget) {1913this._sessionCache.delete(key);1914this._invalidateGroupingCaches();1915throw new Error('[DefaultCopilotProvider] Failed to open chat widget for subsequent chat');1916}19171918// Load session model with selected options1919await this._applySessionModelState(newChatSession.resource, newChatSession);19201921// Send request1922const result = await this.chatService.sendRequest(newChatSession.resource, query, sendOptions);1923if (result.kind === 'rejected') {1924this._sessionCache.delete(key);1925this._invalidateGroupingCaches();1926throw new Error(`[DefaultCopilotProvider] sendRequest rejected: ${result.reason}`);1927}19281929// Extract promises to detect cancellation vs normal completion1930const responseCompletePromise = result.kind === 'sent'1931? result.data.responseCompletePromise1932: undefined;1933const responseCreatedPromise = result.kind === 'sent'1934? result.data.responseCreatedPromise1935: undefined;19361937try {1938// Wait for the session to be committed1939const committedResource = await this._waitForCommittedSession(newChatSession.resource, responseCompletePromise, responseCreatedPromise);19401941const committedChat = await this._waitForSessionInCache(committedResource);19421943// Clean up temp1944this._sessionCache.delete(key);1945this._invalidateGroupingCaches();1946this._currentNewSession = undefined;1947newChatSession.dispose();19481949// Invalidate the session group cache so it rebuilds with the committed chat1950this._sessionGroupCache.delete(sessionId);1951this._onDidGroupMembershipChange.fire({ sessionId });1952const updatedSession = this._chatToSession(committedChat);1953this._onDidChangeSessions.fire({ added: [], removed: [], changed: [updatedSession] });19541955return updatedSession;1956} catch (error) {1957this._currentNewSession = undefined;19581959if (error instanceof CancellationError) {1960// Cancelled before commit — keep the chat in the group so the1961// user can review the content the agent produced.1962newChatSession.setStatus(SessionStatus.Completed);1963this._sessionGroupCache.delete(sessionId);1964const updatedSession = this._chatToSession(newChatSession);1965this._onDidChangeSessions.fire({ added: [], removed: [], changed: [updatedSession] });1966return updatedSession;1967}19681969// Unexpected error — clean up on error, fire changed on the parent session group1970this._sessionCache.delete(key);1971this._invalidateGroupingCaches();1972this._sessionGroupCache.delete(sessionId);1973newChatSession.dispose();1974// Find the parent session's primary chat to fire a valid changed event1975const parentChatIds = this._getChatIdsInGroup(sessionId);1976const parentChatId = parentChatIds[0];1977const parentChat = parentChatId ? this._sessionCache.get(this._localIdFromchatId(parentChatId)) : undefined;1978if (parentChat) {1979this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(parentChat)] });1980}1981throw error;1982}1983}19841985/**1986* Creates a new {@link CopilotCLISession} from an existing session's workspace.1987* Used for subsequent chats that share the same workspace but are independent conversations.1988*/1989private _createNewSessionFrom(sessionId: string): CopilotCLISession {1990// Find the primary chat for this session1991const chatIds = this._getChatIdsInGroup(sessionId);1992const firstChatId = chatIds[0] ?? sessionId;1993const chat = this._sessionCache.get(this._localIdFromchatId(firstChatId));1994if (!chat) {1995throw new Error(`Session '${sessionId}' not found`);1996}19971998if (chat.sessionType === AgentSessionProviders.Cloud) {1999throw new Error('Multiple chats per session is not supported for cloud sessions');2000}20012002if (chat.sessionType === AgentSessionProviders.Claude) {2003throw new Error('Multiple chats per session is not supported for Claude sessions');2004}20052006const workspace = chat.workspace.get();2007if (!workspace) {2008throw new Error('Chat session has no associated workspace');2009}20102011const repository = workspace.repositories[0];2012if (!repository) {2013throw new Error('Workspace has no repository');2014}20152016if (this._currentNewSession) {2017this._currentNewSession.dispose();2018this._currentNewSession = undefined;2019}20202021const newWorkspace = this.resolveWorkspace(repository.workingDirectory || repository.uri);2022if (!newWorkspace) {2023throw new Error(`Cannot resolve workspace for URI: ${(repository.workingDirectory || repository.uri).toString()}`);2024}2025const resource = URI.from({ scheme: AgentSessionProviders.Background, path: `/untitled-${generateUuid()}` });2026const session = this.instantiationService.createInstance(CopilotCLISession, resource, newWorkspace, this.id);2027session.setIsolationMode('workspace');2028session.setOption(PARENT_SESSION_OPTION_ID, chat.resource.path.slice(1));2029this._currentNewSession = session;2030return session;2031}20322033/**2034* Waits for the committed (real) URI for a session by listening to the2035* {@link IChatSessionsService.onDidCommitSession} event.2036*2037* When {@link responseCompletePromise} is provided, the wait is bounded by2038* response completion. If the response finishes before the commit event,2039* the commit may still be in-flight (e.g. the user cancelled after the2040* worktree was initiated but before the commit IPC finished, or the2041* extension fired the commit mid-turn but it hasn't been delivered yet).2042* In both cases we wait with the safety timeout. Only if the timeout2043* expires *and* the response was cancelled do we throw a2044* {@link CancellationError} — signalling that the commit will never come.2045*/2046private async _waitForCommittedSession(2047untitledResource: URI,2048responseCompletePromise?: Promise<void>,2049responseCreatedPromise?: Promise<IChatResponseModel>,2050): Promise<URI> {2051const disposables = new DisposableStore();2052try {2053const commitPromise = new Promise<URI>(resolve => {2054disposables.add(this.chatSessionsService.onDidCommitSession(e => {2055if (isEqual(e.original, untitledResource)) {2056resolve(e.committed);2057}2058}));2059});20602061if (responseCompletePromise) {2062// Race the commit event against the response completing.2063const committed = await Promise.race([2064commitPromise.then(uri => ({ committed: true as const, uri })),2065responseCompletePromise.then(() => ({ committed: false as const })),2066]);20672068if (committed.committed) {2069return committed.uri;2070}20712072// Response finished before the commit event arrived.2073// The commit may still be in-flight — the agent could have2074// initiated the worktree before the user cancelled, and the2075// async IPC chain hasn't delivered the event yet. Fall through2076// to the safety timeout to give it a chance to arrive.2077}20782079// Race commit against a safety timeout. If a response-created2080// promise is available, also race it so we can detect2081// cancellation immediately instead of waiting for the timeout.2082const candidates: Promise<{ kind: 'commit'; uri: URI } | { kind: 'timeout' } | { kind: 'cancelled' }>[] = [2083raceTimeout(commitPromise, 5_000).then(uri => uri ? { kind: 'commit' as const, uri } : { kind: 'timeout' as const }),2084];2085if (responseCreatedPromise) {2086candidates.push(responseCreatedPromise.then(r => r?.isCanceled ? { kind: 'cancelled' as const } : new Promise<never>(() => { /* never resolves */ })));2087}2088const outcome = await Promise.race(candidates);2089if (outcome.kind === 'commit') {2090return outcome.uri;2091}2092if (outcome.kind === 'cancelled') {2093throw new CancellationError();2094}2095// Timed out — last-resort check for cancellation2096const response = responseCreatedPromise ? await responseCreatedPromise : undefined;2097if (response?.isCanceled) {2098throw new CancellationError();2099}2100throw new Error('Timed out waiting for session commit');2101} finally {2102disposables.dispose();2103}2104}21052106/**2107* Waits for an {@link AgentSessionAdapter} with the given resource to appear2108* in the session cache (populated by {@link _refreshSessionCache}).2109* Only called once during session initialisation (after the commit event),2110* so the timeout has no performance impact on steady-state operations.2111*/2112private async _waitForSessionInCache(resource: URI, token?: CancellationToken): Promise<AgentSessionAdapter> {2113const key = resource.toString();2114const existing = this._sessionCache.get(key);2115if (existing instanceof AgentSessionAdapter) {2116return existing;2117}21182119const disposables = new DisposableStore();2120try {2121const sessionPromise = new Promise<AgentSessionAdapter>(resolve => {2122disposables.add(this.onDidChangeSessions(e => {2123const cached = this._sessionCache.get(key);2124if (cached instanceof AgentSessionAdapter) {2125resolve(cached);2126}2127}));2128});21292130// The adapter should appear almost immediately after the commit2131// event via _refreshSessionCache; use a short safety timeout.2132const result = await raceTimeout(2133token ? raceCancellationError(sessionPromise, token) : sessionPromise,21345_000,2135);2136if (!result) {2137throw new Error('Timed out waiting for committed session in cache');2138}2139return result;2140} finally {2141disposables.dispose();2142}2143}21442145// -- Private --21462147private async _browseForRepo(): Promise<ISessionWorkspace | undefined> {2148const repoId = await this.commandService.executeCommand<string>(OPEN_REPO_COMMAND);2149if (repoId) {2150const uri = URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: `/${repoId}/HEAD` });2151return {2152label: this._labelFromUri(uri),2153icon: this._iconFromUri(uri),2154group: SESSION_WORKSPACE_GROUP_GITHUB,2155repositories: [{ uri, workingDirectory: undefined, detail: undefined, baseBranchName: undefined }],2156requiresWorkspaceTrust: false,2157};2158}2159return undefined;2160}21612162resolveWorkspace(repositoryUri: URI): ISessionWorkspace | undefined {2163if (repositoryUri.scheme !== Schemas.file && repositoryUri.scheme !== GITHUB_REMOTE_FILE_SCHEME) {2164return undefined;2165}2166return {2167label: this._labelFromUri(repositoryUri),2168description: this._descriptionFromUri(repositoryUri),2169group: repositoryUri.scheme === GITHUB_REMOTE_FILE_SCHEME ? SESSION_WORKSPACE_GROUP_GITHUB : SESSION_WORKSPACE_GROUP_LOCAL,2170icon: this._iconFromUri(repositoryUri),2171repositories: [{ uri: repositoryUri, workingDirectory: undefined, detail: undefined, baseBranchName: undefined }],2172requiresWorkspaceTrust: repositoryUri.scheme !== GITHUB_REMOTE_FILE_SCHEME2173};2174}21752176private _labelFromUri(uri: URI): string {2177if (uri.scheme === GITHUB_REMOTE_FILE_SCHEME) {2178return uri.path.substring(1).replace(/\/HEAD$/, '');2179}2180return basename(uri);2181}21822183private _descriptionFromUri(uri: URI): string | undefined {2184if (uri.scheme === GITHUB_REMOTE_FILE_SCHEME) {2185// For GitHub URIs the path is "/<owner>/<repo>", return the owner as description2186const parts = uri.path.substring(1).split('/');2187return parts.length >= 2 ? parts[0] : undefined;2188}2189// For local file URIs, return the tildified parent directory path2190return this.labelService.getUriLabel(dirname(uri), { relative: false });2191}21922193private _iconFromUri(uri: URI): ThemeIcon {2194if (uri.scheme === GITHUB_REMOTE_FILE_SCHEME) {2195return Codicon.repo;2196}2197return Codicon.folder;2198}21992200private _ensureSessionCache(): void {2201if (this._sessionCache.size > 0) {2202return;2203}2204this._refreshSessionCache();2205}22062207private _invalidateGroupingCaches(): void {2208this._chatByRawSessionIdCache = undefined;2209this._groupIdByChatIdCache = undefined;2210this._chatIdsByGroupIdCache = undefined;2211}22122213private _ensureGroupingCaches(): void {2214if (this._chatByRawSessionIdCache && this._groupIdByChatIdCache && this._chatIdsByGroupIdCache) {2215return;2216}22172218const chats = Array.from(this._sessionCache.values());2219const chatByRawSessionId = new Map<string, ICopilotChatSession>();2220for (const chat of chats) {2221chatByRawSessionId.set(chat.resource.path.slice(1), chat);2222}22232224const groupIdByChatId = new Map<string, string>();2225const chatsByGroupId = new Map<string, ICopilotChatSession[]>();22262227const resolveGroupId = (chat: ICopilotChatSession): string => {2228const cachedGroupId = groupIdByChatId.get(chat.id);2229if (cachedGroupId) {2230return cachedGroupId;2231}22322233const trail: ICopilotChatSession[] = [];2234const seen = new Set<string>();2235let current: ICopilotChatSession = chat;22362237for (let depth = 0; depth < 100; depth++) {2238const currentCachedGroupId = groupIdByChatId.get(current.id);2239if (currentCachedGroupId) {2240for (const trailChat of trail) {2241groupIdByChatId.set(trailChat.id, currentCachedGroupId);2242}2243return currentCachedGroupId;2244}22452246if (seen.has(current.id)) {2247for (const trailChat of trail) {2248groupIdByChatId.set(trailChat.id, current.id);2249}2250return current.id;2251}22522253trail.push(current);2254seen.add(current.id);22552256const parentRawSessionId = this._getDirectParentRawSessionId(current);2257if (!parentRawSessionId) {2258for (const trailChat of trail) {2259groupIdByChatId.set(trailChat.id, current.id);2260}2261return current.id;2262}22632264const parentChat = chatByRawSessionId.get(parentRawSessionId);2265if (!parentChat) {2266const syntheticGroupId = this._getSyntheticGroupId(parentRawSessionId);2267for (const trailChat of trail) {2268groupIdByChatId.set(trailChat.id, syntheticGroupId);2269}2270return syntheticGroupId;2271}22722273current = parentChat;2274}22752276groupIdByChatId.set(chat.id, chat.id);2277return chat.id;2278};22792280for (const chat of chats) {2281const groupId = resolveGroupId(chat);2282const groupChats = chatsByGroupId.get(groupId) ?? [];2283groupChats.push(chat);2284chatsByGroupId.set(groupId, groupChats);2285}22862287const chatIdsByGroupId = new Map<string, string[]>();2288for (const [groupId, groupChats] of chatsByGroupId) {2289groupChats.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());2290chatIdsByGroupId.set(groupId, groupChats.map(chat => chat.id));2291}22922293this._chatByRawSessionIdCache = chatByRawSessionId;2294this._groupIdByChatIdCache = groupIdByChatId;2295this._chatIdsByGroupIdCache = chatIdsByGroupId;2296}22972298/**2299* Cleans up a temp session (one that hasn't been committed) from the cache.2300* Used when delete/archive is invoked on a session that is still pending2301* commit (e.g. was stopped before the agent created a worktree).2302*/2303private _cleanupTempSession(sessionId: string): void {2304const chatSession = this._findChatSession(sessionId);2305if (!chatSession) {2306return;2307}23082309const key = chatSession.resource.toString();2310this._sessionCache.delete(key);2311this._invalidateGroupingCaches();2312this._sessionGroupCache.delete(chatSession.id);2313if (this._currentNewSession?.id === chatSession.id) {2314this._currentNewSession = undefined;2315}2316const removedSession = this._chatToSession(chatSession);2317this._sessionGroupCache.delete(chatSession.id);2318this._onDidChangeSessions.fire({ added: [], removed: [removedSession], changed: [] });2319if (isNewSession(chatSession)) {2320chatSession.dispose();2321}2322}23232324private _refreshSessionCache(): void {2325const currentKeys = new Set<string>();2326const addedData: ICopilotChatSession[] = [];2327const changedData: ICopilotChatSession[] = [];2328let cacheChanged = false;23292330for (const session of this.agentSessionsService.model.sessions) {2331if (session.providerType !== AgentSessionProviders.Background2332&& session.providerType !== AgentSessionProviders.Cloud2333&& session.providerType !== AgentSessionProviders.Claude) {2334continue;2335}23362337if (session.providerType === AgentSessionProviders.Claude && !this._claudeEnabled) {2338continue;2339}23402341const key = session.resource.toString();2342currentKeys.add(key);23432344const existing = this._sessionCache.get(key);2345if (existing) {2346existing.update(session);2347changedData.push(existing);2348} else {2349const adapter = new AgentSessionAdapter(session, this.id, this.gitHubService);2350this._sessionCache.set(key, adapter);2351addedData.push(adapter);2352cacheChanged = true;2353}2354}23552356const removedData: ICopilotChatSession[] = [];2357for (const [key, adapter] of this._sessionCache) {2358if (!currentKeys.has(key) && adapter instanceof AgentSessionAdapter) {2359removedData.push(adapter);2360cacheChanged = true;2361}2362}23632364// Resolve group IDs for removed sessions BEFORE removing them from the2365// cache and invalidating grouping caches, so that child sessions are2366// correctly mapped to their parent group.2367let removedGroupIds: Map<ICopilotChatSession, string> | undefined;2368if (removedData.length > 0 && this._isMultiChatEnabled()) {2369removedGroupIds = new Map();2370for (const removed of removedData) {2371removedGroupIds.set(removed, this._getGroupIdForChat(removed));2372}2373}23742375// Now remove from cache and invalidate grouping caches2376for (const removed of removedData) {2377this._sessionCache.delete(removed.resource.toString());2378}23792380if (cacheChanged) {2381this._invalidateGroupingCaches();2382}23832384if (addedData.length > 0 || removedData.length > 0 || changedData.length > 0) {2385if (this._isMultiChatEnabled()) {2386this._refreshSessionCacheMultiChat(addedData, removedData, changedData, removedGroupIds!);2387} else {2388this._onDidChangeSessions.fire({2389added: addedData.map(d => this._chatToSession(d)),2390removed: removedData.map(d => this._chatToSession(d)),2391changed: changedData.map(d => this._chatToSession(d)),2392});2393}2394}2395}23962397private _refreshSessionCacheMultiChat(2398addedData: ICopilotChatSession[],2399removedData: ICopilotChatSession[],2400changedData: ICopilotChatSession[],2401removedGroupIds: Map<ICopilotChatSession, string>,2402): void {24032404// Handle removed chats: if a removed chat belongs to a group with2405// remaining siblings, treat it as a changed event on the parent session2406// instead of a removed session.2407const trulyRemovedSessions: { chat: ICopilotChatSession; groupId: string }[] = [];2408const changedSessionIds = new Set<string>();2409for (const removed of removedData) {2410const sessionId = removedGroupIds.get(removed)!;24112412// Check if the group still has chats after removal2413const remainingChatIds = this._getChatIdsInGroup(sessionId);2414if (remainingChatIds.length > 0) {2415// Group still has other chats — invalidate cache and treat as changed2416this._sessionGroupCache.delete(sessionId);2417this._onDidGroupMembershipChange.fire({ sessionId });2418if (!changedSessionIds.has(sessionId)) {2419changedSessionIds.add(sessionId);2420const primaryChat = this._sessionCache.get(this._localIdFromchatId(remainingChatIds[0]));2421if (primaryChat) {2422changedData.push(primaryChat);2423}2424}2425} else {2426this._sessionGroupCache.delete(sessionId);2427trulyRemovedSessions.push({ chat: removed, groupId: sessionId });2428}2429}24302431// Separate truly new sessions from chats added to existing groups.2432// Grouping is derived from sessionParentId in metadata.2433const newSessions: ICopilotChatSession[] = [];2434for (const added of addedData) {2435const groupId = this._getGroupIdForChat(added);2436const groupChatIds = this._getChatIdsInGroup(groupId);2437if (groupChatIds.length > 1) {2438// This chat belongs to an existing session group — treat as changed2439this._sessionGroupCache.delete(groupId);2440this._onDidGroupMembershipChange.fire({ sessionId: groupId });2441if (!changedSessionIds.has(groupId)) {2442changedSessionIds.add(groupId);2443changedData.push(added);2444}2445} else {2446newSessions.push(added);2447}2448}24492450// Deduplicate changed sessions by group ID2451const seenChanged = new Set<string>();2452const deduplicatedChanged: ICopilotChatSession[] = [];2453for (const d of changedData) {2454const groupId = this._getGroupIdForChat(d);2455if (!seenChanged.has(groupId)) {2456seenChanged.add(groupId);2457deduplicatedChanged.push(d);2458}2459}24602461this._onDidChangeSessions.fire({2462added: newSessions.map(d => this._chatToSession(d)),2463removed: trulyRemovedSessions.map(({ chat, groupId }) => {2464const session = this._sessionGroupCache.get(groupId);2465this._sessionGroupCache.delete(groupId);2466return session ?? this._chatToSession(chat);2467}),2468changed: deduplicatedChanged.map(d => this._chatToSession(d)),2469});2470}24712472private _findChatSession(chatId: string): ICopilotChatSession | undefined {2473const directMatch = this._sessionCache.get(this._localIdFromchatId(chatId));2474if (directMatch) {2475return directMatch;2476}24772478const groupChatIds = this._getChatIdsInGroup(chatId);2479const firstChatId = groupChatIds[0];2480return firstChatId ? this._sessionCache.get(this._localIdFromchatId(firstChatId)) : undefined;2481}24822483private _findAgentSession(chatId: string): IAgentSession | undefined {2484const adapter = this._findChatSession(chatId);2485if (!adapter) {2486return undefined;2487}2488return this.agentSessionsService.getSession(adapter.resource);2489}24902491/**2492* Returns the group ID for a given chat.2493* Grouping is derived from `sessionParentId` in metadata (for committed sessions)2494* or from `PARENT_SESSION_OPTION_ID` in selected options (for uncommitted sessions).2495* If the root chat is not loaded, a synthetic provider-scoped group ID is used.2496*/2497private _getGroupIdForChat(chat: ICopilotChatSession): string {2498this._ensureGroupingCaches();2499return this._groupIdByChatIdCache?.get(chat.id) ?? chat.id;2500}25012502/**2503* Returns all chat IDs that belong to the given group,2504* ordered by creation time (root session first).2505*/2506private _getChatIdsInGroup(groupId: string): string[] {2507this._ensureGroupingCaches();2508return this._chatIdsByGroupIdCache?.get(groupId) ?? [];2509}25102511private _getDirectParentRawSessionId(chat: ICopilotChatSession): string | undefined {2512const agentSession = this.agentSessionsService.getSession(chat.resource);2513const sessionParentId = agentSession?.metadata?.sessionParentId;2514if (typeof sessionParentId === 'string' && sessionParentId.length > 0) {2515return sessionParentId;2516}25172518if (isNewSession(chat)) {2519const parentOption = chat.selectedOptions.get(PARENT_SESSION_OPTION_ID);2520if (parentOption?.id) {2521return parentOption.id;2522}2523}25242525return undefined;2526}25272528private _getSyntheticGroupId(rawSessionId: string): string {2529return `${this.id}:group:${rawSessionId}`;2530}25312532private _findSession(sessionId: string): ISession | undefined {2533return this._sessionGroupCache.get(sessionId);2534}25352536private _localIdFromchatId(chatId: string): string {2537const prefix = `${this.id}:`;2538return chatId.startsWith(prefix) ? chatId.substring(prefix.length) : chatId;2539}25402541/**2542* Wraps a primary {@link ICopilotChatSession} and its sibling chats into an {@link ISession}.2543* When multi-chat is enabled, the `chats` observable is derived from `sessionParentId`2544* metadata and updates when group membership changes.2545* When disabled, each session has exactly one chat.2546*/2547private _chatToSession(chat: ICopilotChatSession): ISession {2548if (!this._isMultiChatEnabled()) {2549return this._chatToSingleChatSession(chat);2550}25512552const sessionId = this._getGroupIdForChat(chat);25532554const cached = this._sessionGroupCache.get(sessionId);2555if (cached) {2556return cached;2557}25582559// Resolve the main (first) chat in the group — session-level properties come from it2560const mainChatIds = this._getChatIdsInGroup(sessionId);2561const firstChatId = mainChatIds[0];2562const primaryChat = firstChatId2563? this._sessionCache.get(this._localIdFromchatId(firstChatId)) ?? chat2564: chat;25652566const chatsObs = observableFromEvent<readonly IChat[]>(2567this,2568Event.filter(this._onDidGroupMembershipChange.event, e => e.sessionId === sessionId),2569() => {2570const chatIds = this._getChatIdsInGroup(sessionId);2571if (chatIds.length === 0) {2572return [this._toChat(chat)];2573}2574const resolved: ICopilotChatSession[] = [];2575for (const id of chatIds) {2576const c = this._sessionCache.get(this._localIdFromchatId(id));2577if (c) {2578resolved.push(c);2579}2580}2581if (resolved.length === 0) {2582return [this._toChat(chat)];2583}2584return resolved.map(c => this._toChat(c));2585},2586);25872588const mainChat = this._toChat(primaryChat);2589const session: ISession = {2590sessionId,2591resource: primaryChat.resource,2592providerId: primaryChat.providerId,2593sessionType: primaryChat.sessionType,2594icon: primaryChat.icon,2595createdAt: primaryChat.createdAt,2596workspace: primaryChat.workspace,2597title: primaryChat.title,2598updatedAt: chatsObs.map((chats, reader) => this._latestDate(chats, c => c.updatedAt.read(reader))!),2599status: chatsObs.map((chats, reader) => this._aggregateStatus(chats, reader)),2600changes: primaryChat.changes,2601modelId: primaryChat.modelId,2602mode: primaryChat.mode,2603loading: primaryChat.loading,2604isArchived: primaryChat.isArchived,2605isRead: chatsObs.map((chats, reader) => chats.every(c => c.isRead.read(reader))),2606description: primaryChat.description,2607lastTurnEnd: chatsObs.map((chats, reader) => this._latestDate(chats, c => c.lastTurnEnd.read(reader))),2608gitHubInfo: primaryChat.gitHubInfo,2609chats: chatsObs,2610mainChat,2611capabilities: { supportsMultipleChats: primaryChat.sessionType === CopilotCLISessionType.id && this._isMultiChatEnabled() },2612};2613this._sessionGroupCache.set(sessionId, session);2614return session;2615}26162617private _chatToSingleChatSession(chat: ICopilotChatSession): ISession {2618const mainChat = this._toChat(chat);2619return {2620sessionId: chat.id,2621resource: chat.resource,2622providerId: chat.providerId,2623sessionType: chat.sessionType,2624icon: chat.icon,2625createdAt: chat.createdAt,2626workspace: chat.workspace,2627title: chat.title,2628updatedAt: chat.updatedAt,2629status: chat.status,2630changes: chat.changes,2631modelId: chat.modelId,2632mode: chat.mode,2633loading: chat.loading,2634isArchived: chat.isArchived,2635isRead: chat.isRead,2636description: chat.description,2637lastTurnEnd: chat.lastTurnEnd,2638gitHubInfo: chat.gitHubInfo,2639chats: constObservable([mainChat]),2640mainChat,2641capabilities: { supportsMultipleChats: false },2642};2643}26442645private _toChat(chat: ICopilotChatSession): IChat {2646return {2647resource: chat.resource,2648createdAt: chat.createdAt,2649title: chat.title,2650updatedAt: chat.updatedAt,2651status: chat.status,2652changes: chat.changes,2653modelId: chat.modelId,2654mode: chat.mode,2655isArchived: chat.isArchived,2656isRead: chat.isRead,2657description: chat.description,2658lastTurnEnd: chat.lastTurnEnd,2659};2660}26612662private _latestDate(chats: readonly IChat[], getter: (chat: IChat) => Date | undefined): Date | undefined {2663let latest: Date | undefined;2664for (const chat of chats) {2665const d = getter(chat);2666if (d && (!latest || d > latest)) {2667latest = d;2668}2669}2670return latest;2671}26722673private _aggregateStatus(chats: readonly IChat[], reader: IReader): SessionStatus {2674for (const c of chats) {2675if (c.status.read(reader) === SessionStatus.NeedsInput) {2676return SessionStatus.NeedsInput;2677}2678}2679for (const c of chats) {2680if (c.status.read(reader) === SessionStatus.InProgress) {2681return SessionStatus.InProgress;2682}2683}2684return chats[0].status.read(reader);2685}26862687private _isMultiChatEnabled(): boolean {2688return this._multiChatEnabled;2689}2690}269126922693