Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts
13399 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 * as vscode from 'vscode';6import { ChatExtendedRequestHandler } from 'vscode';7import { PermissionMode } from '@anthropic-ai/claude-agent-sdk';8import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';9import { INativeEnvService } from '../../../platform/env/common/envService';10import { getGitHubRepoInfoFromContext, IGitService } from '../../../platform/git/common/gitService';11import { ILogService } from '../../../platform/log/common/logService';12import { IChatEndpoint } from '../../../platform/networking/common/networking';13import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';14import { CancellationToken } from '../../../util/vs/base/common/cancellation';15import { Emitter, Event } from '../../../util/vs/base/common/event';16import { Disposable, DisposableStore } from '../../../util/vs/base/common/lifecycle';17import { autorun, derived, IObservable, ISettableObservable, observableFromEvent, observableValue } from '../../../util/vs/base/common/observable';18import { basename } from '../../../util/vs/base/common/resources';19import { URI } from '../../../util/vs/base/common/uri';20import { generateUuid } from '../../../util/vs/base/common/uuid';21import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';22import { ClaudeFolderInfo } from '../claude/common/claudeFolderInfo';23import { ClaudeSessionUri } from '../claude/common/claudeSessionUri';24import { ClaudeAgentManager } from '../claude/node/claudeCodeAgent';25import { CLAUDE_REASONING_EFFORT_PROPERTY, formatClaudeModelDetails, IClaudeCodeModels, pickReasoningEffort } from '../claude/node/claudeCodeModels';26import { IClaudeCodeSdkService } from '../claude/node/claudeCodeSdkService';27import { parseClaudeModelId } from '../claude/node/claudeModelId';28import { IClaudeSessionStateService } from '../claude/common/claudeSessionStateService';29import { IClaudeCodeSessionService } from '../claude/node/sessionParser/claudeCodeSessionService';30import { IClaudeCodeSessionInfo, IClaudeCodeSession, SYNTHETIC_MODEL_ID } from '../claude/node/sessionParser/claudeSessionSchema';31import { IClaudeSlashCommandService } from '../claude/vscode-node/claudeSlashCommandService';32import { IChatFolderMruService } from '../common/folderRepositoryManager';33import { builtinSlashCommands } from '../common/builtinSlashCommands';34import { IClaudeWorkspaceFolderService } from '../common/claudeWorkspaceFolderService';35import { buildChatHistory } from './chatHistoryBuilder';36import { ClaudeSessionOptionBuilder, buildPermissionModeItems, FOLDER_OPTION_ID, isPermissionMode, PERMISSION_MODE_OPTION_ID } from './claudeSessionOptionBuilder';37import { toWorkspaceFolderOptionItem } from './sessionOptionGroupBuilder';3839// Import the tool permission handlers40import '../claude/vscode-node/toolPermissionHandlers/index';4142interface SessionMetadata {43readonly workingDirectoryPath: string;44readonly repositoryPath?: string;45readonly branchName?: string;46readonly upstreamBranchName?: string;47readonly hasGitHubRemote?: boolean;48readonly incomingChanges?: number;49readonly outgoingChanges?: number;50readonly uncommittedChanges?: number;51}5253function getSessionResource(sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri): vscode.Uri | undefined {54return sessionItemOrResource instanceof vscode.Uri55? sessionItemOrResource56: sessionItemOrResource?.resource;57}5859// Import the MCP server contributors to trigger self-registration60import '../claude/vscode-node/mcpServers/index';6162interface InputStateReactivePipeline {63readonly permissionMode: ISettableObservable<PermissionMode>;64readonly folderUri: ISettableObservable<URI | undefined>;65readonly folderItems: ISettableObservable<readonly vscode.ChatSessionProviderOptionItem[]>;66readonly isSessionStarted: ISettableObservable<boolean>;67readonly store: DisposableStore;68}6970function getSelectedFolderUri(inputState: vscode.ChatSessionInputState | undefined): URI | undefined {71const selectedFolderId = inputState?.groups.find(group => group.id === FOLDER_OPTION_ID)?.selected?.id;72return selectedFolderId ? URI.file(selectedFolderId) : undefined;73}7475export class ClaudeChatSessionContentProvider extends Disposable implements vscode.ChatSessionContentProvider {76private readonly _controller: ClaudeChatSessionItemController;7778constructor(79private readonly claudeAgentManager: ClaudeAgentManager,80@IClaudeCodeSessionService private readonly sessionService: IClaudeCodeSessionService,81@IClaudeSessionStateService private readonly sessionStateService: IClaudeSessionStateService,82@IClaudeSlashCommandService private readonly slashCommandService: IClaudeSlashCommandService,83@IClaudeCodeModels private readonly claudeModels: IClaudeCodeModels,84@IInstantiationService instantiationService: IInstantiationService85) {86super();87this._controller = this._register(instantiationService.createInstance(ClaudeChatSessionItemController));88}8990// #region Chat Participant Handler9192provideHandleOptionsChange(resource: vscode.Uri, updates: ReadonlyArray<vscode.ChatSessionOptionUpdate>, _token: vscode.CancellationToken): void {93const sessionId = ClaudeSessionUri.getSessionId(resource);94for (const update of updates) {95const value = update.value;96if (update.optionId === PERMISSION_MODE_OPTION_ID && value && isPermissionMode(value)) {97this.sessionStateService.setPermissionModeForSession(sessionId, value);98}99}100}101102createHandler(): ChatExtendedRequestHandler {103return async (request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise<vscode.ChatResult | void> => {104const { chatSessionContext } = context;105if (!chatSessionContext) {106/* Via @claude */107// TODO: Think about how this should work108stream.markdown(vscode.l10n.t("Start a new Claude Agent session"));109stream.button({ command: `workbench.action.chat.openNewSessionEditor.${ClaudeSessionUri.scheme}`, title: vscode.l10n.t("Start Session") });110return {};111}112113// Try to handle as a slash command first114const slashResult = await this.slashCommandService.tryHandleCommand(request, stream, token);115if (slashResult.handled) {116return slashResult.result ?? {};117}118119const effectiveSessionId = ClaudeSessionUri.getSessionId(chatSessionContext.chatSessionItem.resource);120const yieldRequested = () => context.yieldRequested;121122// Determine whether this is a new session by checking if a session123// already exists on disk via the session service.124const sessionUri = ClaudeSessionUri.forSessionId(effectiveSessionId);125const existingSession = await this.sessionService.getSession(sessionUri, token);126const isNewSession = !existingSession;127128const modelId = parseClaudeModelId(request.model.id);129const selectedPermissionId = chatSessionContext.inputState.groups.find(group => group.id === PERMISSION_MODE_OPTION_ID)?.selected?.id;130if (!selectedPermissionId || !isPermissionMode(selectedPermissionId)) {131throw new Error(`Permission mode not set for session ${effectiveSessionId}`);132}133const permissionMode = selectedPermissionId;134const selectedFolderUri = getSelectedFolderUri(chatSessionContext.inputState);135const folderInfo = await this._controller.getFolderInfoForSession(effectiveSessionId, selectedFolderUri);136137// Commit UI state to session state service before invoking agent manager138this.sessionStateService.setModelIdForSession(effectiveSessionId, modelId);139this.sessionStateService.setPermissionModeForSession(effectiveSessionId, permissionMode);140this.sessionStateService.setFolderInfoForSession(effectiveSessionId, folderInfo);141142// Resolve the endpoint once and reuse it for both reasoning effort143// and the response footer details — they otherwise both call144// `resolveEndpoint` (which hits the cached endpoint list, then145// re-filters), which is wasted work and risks divergence.146const endpoint = await this._resolveEndpointForRequest(modelId.toEndpointModelId());147const rawReasoningEffort = request.modelConfiguration?.[CLAUDE_REASONING_EFFORT_PROPERTY];148const reasoningEffort = pickReasoningEffort(endpoint, typeof rawReasoningEffort === 'string' ? rawReasoningEffort : undefined);149this.sessionStateService.setReasoningEffortForSession(effectiveSessionId, reasoningEffort);150151// Set usage handler to report token usage for context window widget152this.sessionStateService.setUsageHandlerForSession(effectiveSessionId, (usage) => {153stream.usage(usage);154});155156const prompt = request.prompt;157await this._controller.updateItemStatus(effectiveSessionId, vscode.ChatSessionStatus.InProgress, prompt);158const result = await this.claudeAgentManager.handleRequest(effectiveSessionId, request, stream, token, isNewSession, yieldRequested);159await this._controller.updateItemStatus(effectiveSessionId, vscode.ChatSessionStatus.Completed, prompt);160161// Clear usage handler after request completes162this.sessionStateService.setUsageHandlerForSession(effectiveSessionId, undefined);163164const details = endpoint ? formatClaudeModelDetails(endpoint) : undefined;165return {166...(details ? { details } : {}),167...(result.errorDetails ? { errorDetails: result.errorDetails } : {}),168};169};170}171172// #endregion173174async provideChatSessionContent(sessionResource: vscode.Uri, token: vscode.CancellationToken, context?: { readonly inputState: vscode.ChatSessionInputState }): Promise<vscode.ChatSession> {175const existingSession = await this.sessionService.getSession(sessionResource, token);176const detailsByModelId = existingSession ? await this._buildModelDetailsLookup(existingSession, token) : undefined;177const history = existingSession ?178buildChatHistory(existingSession, detailsByModelId ? id => detailsByModelId.get(id) : undefined) :179[];180181const options: Record<string, string | vscode.ChatSessionProviderOptionItem> = {};182const groups = context?.inputState.groups ?? [];183for (const group of groups) {184if (group.selected) {185// Only lock the folder group — permission mode must stay editable186const locked = group.id === FOLDER_OPTION_ID;187options[group.id] = locked188? { ...group.selected, locked: true }189: group.selected.id;190}191}192193return {194title: existingSession?.label,195history,196activeResponseCallback: undefined,197requestHandler: undefined,198options,199};200}201202/**203* Resolves a Claude model id to its endpoint. Wraps `resolveEndpoint` in a204* try/catch so transient failures degrade gracefully (return `undefined`)205* instead of breaking the response or session-load path.206*/207private async _resolveEndpointForRequest(modelId: string): Promise<IChatEndpoint | undefined> {208try {209return await this.claudeModels.resolveEndpoint(modelId, undefined);210} catch {211return undefined;212}213}214215/**216* Resolves the display string for each unique non-synthetic model id observed in the217* session's assistant messages. Returns `undefined` (not an empty map) when no model218* ids are present, when the caller has cancelled, or when no ids resolve to known219* endpoints — so callers can skip the per-turn details work entirely.220*/221private async _buildModelDetailsLookup(session: IClaudeCodeSession, token: vscode.CancellationToken): Promise<Map<string, string> | undefined> {222if (token.isCancellationRequested) {223return undefined;224}225const modelIds = new Set<string>();226for (const msg of session.messages) {227if (msg.type === 'assistant' && msg.message.role === 'assistant') {228const model = msg.message.model;229if (model && model !== SYNTHETIC_MODEL_ID) {230modelIds.add(model);231}232}233}234if (modelIds.size === 0) {235return undefined;236}237const detailsByModelId = new Map<string, string>();238await Promise.all([...modelIds].map(async modelId => {239if (token.isCancellationRequested) {240return;241}242const endpoint = await this._resolveEndpointForRequest(modelId);243if (endpoint) {244detailsByModelId.set(modelId, formatClaudeModelDetails(endpoint));245}246}));247if (token.isCancellationRequested) {248return undefined;249}250return detailsByModelId.size > 0 ? detailsByModelId : undefined;251}252}253254/**255* Chat session item controller wrapper for Claude Agent.256* Reads sessions from ~/.claude/projects/<folder-slug>/, where each file name is a session id (GUID).257*258* Owns the input state (getChatSessionInputState) lifecycle: wiring external259* state listeners and resolving folder info for sessions. Group construction260* is delegated to {@link ClaudeSessionOptionBuilder}.261*/262export class ClaudeChatSessionItemController extends Disposable {263private readonly _controller: vscode.ChatSessionItemController;264private readonly _optionBuilder: ClaudeSessionOptionBuilder;265private readonly _inProgressItems = new Map<string, vscode.ChatSessionItem>();266private _showBadge: boolean;267268// #region Shared Observable State269270/** Whether the "bypass permissions" config is enabled — controls permission mode items. */271private readonly _bypassPermissionsEnabled: IObservable<boolean>;272273/** Current workspace folders — controls folder group items and visibility. */274private readonly _workspaceFolders: IObservable<URI[]>;275276277// #endregion278279constructor(280@IClaudeCodeSessionService private readonly _claudeCodeSessionService: IClaudeCodeSessionService,281@IClaudeSessionStateService private readonly _sessionStateService: IClaudeSessionStateService,282@IConfigurationService _configurationService: IConfigurationService,283@IChatFolderMruService folderMruService: IChatFolderMruService,284@IWorkspaceService private readonly _workspaceService: IWorkspaceService,285@INativeEnvService private readonly _envService: INativeEnvService,286@IGitService private readonly _gitService: IGitService,287@IClaudeCodeSdkService private readonly _sdkService: IClaudeCodeSdkService,288@ILogService private readonly _logService: ILogService,289@IClaudeWorkspaceFolderService private readonly _claudeWorkspaceFolderService: IClaudeWorkspaceFolderService,290) {291super();292this._optionBuilder = new ClaudeSessionOptionBuilder(_configurationService, folderMruService, _workspaceService);293294this._bypassPermissionsEnabled = observableFromEvent(295this,296Event.filter(_configurationService.onDidChangeConfiguration,297e => e.affectsConfiguration(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions.fullyQualifiedId)),298() => _configurationService.getConfig(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions) as boolean,299);300301// Bridge vscode.Event → internal Event for workspace folder changes302const workspaceFoldersEmitter = this._register(new Emitter<void>());303const workspaceFoldersSubscription = _workspaceService.onDidChangeWorkspaceFolders(() => workspaceFoldersEmitter.fire());304this._register({ dispose: () => workspaceFoldersSubscription.dispose() });305this._workspaceFolders = observableFromEvent(306this,307workspaceFoldersEmitter.event,308() => _workspaceService.getWorkspaceFolders(),309);310311this._registerCommands();312this._controller = this._register(vscode.chat.createChatSessionItemController(313ClaudeSessionUri.scheme,314() => this._refreshItems(CancellationToken.None)315));316317this._controller.newChatSessionItemHandler = async (context, _token) => {318const newSessionId = generateUuid();319const item = this._controller.createChatSessionItem(320ClaudeSessionUri.forSessionId(newSessionId),321context.request.prompt,322);323item.iconPath = new vscode.ThemeIcon('claude');324item.timing = { created: Date.now() };325326// Set workspace metadata for correct session grouping327const selectedFolderUri = getSelectedFolderUri(context.inputState);328const folderInfo = await this.getFolderInfoForSession(newSessionId, selectedFolderUri);329if (folderInfo.cwd) {330item.metadata = await this._buildSessionMetadata(folderInfo.cwd);331}332333this._inProgressItems.set(newSessionId, item);334return item;335};336337this._controller.forkHandler = async (sessionResource: vscode.Uri, request: vscode.ChatRequestTurn2 | undefined, token: CancellationToken): Promise<vscode.ChatSessionItem> => {338const item = this._controller.items.get(sessionResource);339const title = vscode.l10n.t('Forked: {0}', item?.label ?? request?.prompt ?? 'Claude Session');340341// Fork whole history if no request specified342let upToMessageId: string | undefined = undefined;343if (request) {344// we need to get the message right before the `request`345const session = await this._claudeCodeSessionService.getSession(sessionResource, token);346if (!session) {347// This shouldn't happen348this._logService.error(`Failed to fork session: session not found for resource ${sessionResource.toString()}`);349throw new Error('Unable to fork: session not found.');350} else {351const messageIndex = session.messages.findIndex(m => m.uuid === request.id);352if (messageIndex === -1) {353this._logService.error(`Failed to fork session: request with id ${request.id} not found in session ${sessionResource.toString()}`);354throw new Error('Unable to fork: the selected message could not be found.');355}356if (messageIndex === 0) {357this._logService.error(`Failed to fork session: cannot fork at the first message`);358throw new Error('Cannot fork from the first message.');359}360const forkMessage = session.messages[messageIndex - 1];361upToMessageId = forkMessage.uuid;362}363}364const result = await this._sdkService.forkSession(365ClaudeSessionUri.getSessionId(sessionResource),366{ upToMessageId, title }367);368const newItem = this._controller.createChatSessionItem(ClaudeSessionUri.forSessionId(result.sessionId), title);369newItem.iconPath = new vscode.ThemeIcon('claude');370newItem.timing = { created: Date.now() };371// FYI, dropping any other metadata fields here...372if (item?.metadata?.workingDirectoryPath) {373newItem.metadata = await this._buildSessionMetadata(item.metadata.workingDirectoryPath);374}375376// Copy parent session state to the forked session377const parentSessionId = ClaudeSessionUri.getSessionId(sessionResource);378const parentPermission = this._sessionStateService.getPermissionModeForSession(parentSessionId);379const parentFolder = this._sessionStateService.getFolderInfoForSession(parentSessionId);380this._sessionStateService.setPermissionModeForSession(result.sessionId, parentPermission);381if (parentFolder) {382this._sessionStateService.setFolderInfoForSession(result.sessionId, {383...parentFolder,384additionalDirectories: [...(parentFolder.additionalDirectories ?? [])],385});386}387388this._controller.items.add(newItem);389return newItem;390};391392this._showBadge = this._computeShowBadge();393394// Refresh session items and recompute badge when repositories change.395// _computeShowBadge() reads gitService.repositories synchronously, which396// may be incomplete while the git extension is still initializing.397this._register(_gitService.onDidOpenRepository(() => {398this._showBadge = this._computeShowBadge();399void this._refreshItems(CancellationToken.None);400}));401this._register(_gitService.onDidCloseRepository(() => {402this._showBadge = this._computeShowBadge();403void this._refreshItems(CancellationToken.None);404}));405406this._setupInputState();407}408409// #region Input State410411/**412* Creates a reactive pipeline for a single input state.413*414* Per-state observables (`permissionMode`, `folderUri`, `isSessionStarted`) are415* combined with shared observables (`_bypassPermissionsEnabled`, `_workspaceFolders`)416* into derived group computations. An autorun reads the derived groups and pushes417* the result to `state.groups`, which is the "UI".418*419* The returned `DisposableStore` owns the autorun lifecycle and is disposed via420* `state.onDidDispose` in the caller.421*422* Returns the per-state observables so callers can drive external updates, plus a423* `DisposableStore` that owns the autorun lifecycle.424*/425private _createInputStateReactivePipeline(426state: vscode.ChatSessionInputState,427): InputStateReactivePipeline {428const store = new DisposableStore();429430// Seed values are computed up front so that the first autorun pass431// observes fully-seeded observables and does not clobber `initialGroups`.432const seed = this._computeSeedValues(state.groups);433434const permissionMode = observableValue<PermissionMode>(this, seed.permissionMode);435const folderUri = observableValue<URI | undefined>(this, seed.folderUri);436const folderItems = observableValue<readonly vscode.ChatSessionProviderOptionItem[]>(this, seed.folderItems);437const isSessionStarted = observableValue<boolean>(this, seed.isSessionStarted);438439// When workspace folders change, update folder items reactively.440// Falls back to the async MRU list when the workspace becomes empty,441// matching the old imperative `buildNewFolderGroup` behavior.442store.add(autorun(reader => {443/** @description syncWorkspaceFolderItems */444const folders = this._workspaceFolders.read(reader);445if (folders.length !== 0) {446folderItems.set(447folders.map(f => toWorkspaceFolderOptionItem(f, this._workspaceService.getWorkspaceFolderName(f) || basename(f))),448undefined,449);450} else {451this._optionBuilder.getFolderOptionItems()452.then(items => folderItems.set(items, undefined))453.catch(e => this._logService.error(e));454}455}));456457const permissionModeGroup = derived(reader => {458/** @description permissionModeGroup */459const bypassEnabled = this._bypassPermissionsEnabled.read(reader);460const selectedMode = permissionMode.read(reader);461const group = buildPermissionModeItems(bypassEnabled);462const selectedItem = group.items.find(i => i.id === selectedMode) ?? group.items[0];463return { ...group, selected: selectedItem };464});465466const folderGroup = derived<vscode.ChatSessionProviderOptionGroup | undefined>(reader => {467/** @description folderGroup */468const items = folderItems.read(reader);469const folders = this._workspaceFolders.read(reader);470// Hide folder group when there's exactly one workspace folder (implicit)471if (folders.length === 1) {472return undefined;473}474const selectedFolder = folderUri.read(reader);475const locked = isSessionStarted.read(reader);476const lockedItems = locked ? items.map(i => ({ ...i, locked: true })) : items;477const selectedItem = selectedFolder478? lockedItems.find(i => i.id === selectedFolder.fsPath)479: lockedItems[0];480return {481id: FOLDER_OPTION_ID,482name: vscode.l10n.t('Folder'),483description: vscode.l10n.t('Pick Folder'),484items: lockedItems,485selected: selectedItem ? (locked ? { ...selectedItem, locked: true } : selectedItem) : undefined,486};487});488489const allGroups = derived(reader => {490/** @description allGroups */491const groups: vscode.ChatSessionProviderOptionGroup[] = [];492const folder = folderGroup.read(reader);493if (folder) {494groups.push(folder);495}496groups.push(permissionModeGroup.read(reader));497return groups;498});499500store.add(autorun(reader => {501/** @description syncInputStateGroups */502state.groups = allGroups.read(reader);503}));504505return { permissionMode, folderUri, folderItems, isSessionStarted, store };506}507508private _setupInputState(): void {509this._controller.getChatSessionInputState = async (sessionResource, context, token) => {510let state: vscode.ChatSessionInputState;511let pipeline: InputStateReactivePipeline;512513if (context.previousInputState) {514state = this._controller.createChatSessionInputState([...context.previousInputState.groups]);515pipeline = this._createInputStateReactivePipeline(state);516} else {517const isExistingSession = sessionResource && await this._claudeCodeSessionService.getSession(sessionResource, token) !== undefined;518const initialGroups = isExistingSession519? await this._buildExistingSessionGroups(sessionResource)520: await this._optionBuilder.buildNewSessionGroups();521state = this._controller.createChatSessionInputState(initialGroups);522pipeline = this._createInputStateReactivePipeline(state);523}524525if (sessionResource) {526pipeline.isSessionStarted.set(true, undefined);527528// React to external permission mode changes for this session.529// Runs for both previousInputState and new-state paths so that530// EnterPlanMode / ExitPlanMode tool calls always update the input UI.531const sessionId = ClaudeSessionUri.getSessionId(sessionResource);532const externalPermissionMode = observableFromEvent(533this,534Event.filter(this._sessionStateService.onDidChangeSessionState,535e => e.sessionId === sessionId && e.permissionMode !== undefined),536() => this._sessionStateService.getPermissionModeForSession(sessionId),537);538pipeline.store.add(autorun(reader => {539/** @description syncExternalPermissionMode */540pipeline.permissionMode.set(externalPermissionMode.read(reader), undefined);541}));542}543544pipeline.store.add(state.onDidDispose(() => pipeline.store.dispose()));545return state;546};547}548549/**550* Extracts seed values for the per-state observables from the input groups.551* Pure and synchronous — runs before any autoruns are attached so the first552* autorun pass observes fully-seeded values and does not overwrite the553* carefully-constructed initial groups.554*555* Also recovers the `isSessionStarted` signal from `locked` items — required to556* preserve lock state when restoring a previously-started session.557*/558private _computeSeedValues(groups: readonly vscode.ChatSessionProviderOptionGroup[]): {559readonly permissionMode: PermissionMode;560readonly folderUri: URI | undefined;561readonly folderItems: readonly vscode.ChatSessionProviderOptionItem[];562readonly isSessionStarted: boolean;563} {564let permissionMode: PermissionMode = this._optionBuilder.lastUsedPermissionMode;565const permissionGroup = groups.find(g => g.id === PERMISSION_MODE_OPTION_ID);566if (permissionGroup?.selected && isPermissionMode(permissionGroup.selected.id)) {567permissionMode = permissionGroup.selected.id;568}569570let folderUri: URI | undefined;571let folderItems: readonly vscode.ChatSessionProviderOptionItem[] = [];572let isSessionStarted = false;573const folderGroup = groups.find(g => g.id === FOLDER_OPTION_ID);574if (folderGroup) {575if (folderGroup.items.length > 0) {576folderItems = folderGroup.items;577}578if (folderGroup.selected) {579folderUri = URI.file(folderGroup.selected.id);580}581// Restore the "started" signal: if any items (or the selected item) carry582// `locked: true`, the session was previously started and must stay locked.583if (folderGroup.selected?.locked || folderGroup.items.some(i => i.locked)) {584isSessionStarted = true;585}586}587588return { permissionMode, folderUri, folderItems, isSessionStarted };589}590591private async _buildExistingSessionGroups(sessionResource: vscode.Uri): Promise<vscode.ChatSessionProviderOptionGroup[]> {592const sessionId = ClaudeSessionUri.getSessionId(sessionResource);593const permissionMode = this._sessionStateService.getPermissionModeForSession(sessionId);594595const workspaceFolders = this._workspaceService.getWorkspaceFolders();596let folderUri: URI | undefined;597if (workspaceFolders.length !== 1) {598const stateFolder = this._sessionStateService.getFolderInfoForSession(sessionId);599if (stateFolder) {600folderUri = URI.file(stateFolder.cwd);601} else {602const session = await this._claudeCodeSessionService.getSession(sessionResource, CancellationToken.None);603if (session?.cwd) {604folderUri = URI.file(session.cwd);605} else {606folderUri = await this._optionBuilder.getDefaultFolder();607}608}609}610return this._optionBuilder.buildExistingSessionGroups(permissionMode, folderUri);611}612613// #endregion614615// #region Folder Resolution616617async getFolderInfoForSession(sessionId: string, selectedFolderUri?: URI): Promise<ClaudeFolderInfo> {618const workspaceFolders = this._workspaceService.getWorkspaceFolders();619620if (workspaceFolders.length === 1) {621return {622cwd: workspaceFolders[0].fsPath,623additionalDirectories: [],624};625}626627// Multi-root or empty workspace: resolve selected folder from inputState, sessionStateService, or session file628const folderUri = selectedFolderUri ?? await this._resolveSessionFolder(sessionId);629630if (workspaceFolders.length > 1) {631const cwd = folderUri?.fsPath ?? workspaceFolders[0].fsPath;632const additionalDirectories = workspaceFolders633.map(f => f.fsPath)634.filter(p => p !== cwd);635return { cwd, additionalDirectories };636}637638// Empty workspace639if (folderUri) {640return {641cwd: folderUri.fsPath,642additionalDirectories: [],643};644}645646// Fallback for empty workspace with no selection: try MRU647const defaultFolder = await this._optionBuilder.getDefaultFolder();648if (defaultFolder) {649return {650cwd: defaultFolder.fsPath,651additionalDirectories: [],652};653}654655// No folder available at all — fall back to the user's home directory656return {657cwd: this._envService.userHome.fsPath,658additionalDirectories: [],659};660}661662private async _resolveSessionFolder(sessionId: string): Promise<URI | undefined> {663const stateFolder = this._sessionStateService.getFolderInfoForSession(sessionId);664if (stateFolder) {665return URI.file(stateFolder.cwd);666}667668const sessionResource = ClaudeSessionUri.forSessionId(sessionId);669const session = await this._claudeCodeSessionService.getSession(sessionResource, CancellationToken.None);670if (session?.cwd) {671return URI.file(session.cwd);672}673674return this._optionBuilder.getDefaultFolder();675}676677// #endregion678679updateItemLabel(sessionId: string, label: string): void {680const resource = ClaudeSessionUri.forSessionId(sessionId);681const item = this._controller.items.get(resource);682if (item) {683item.label = label;684}685}686687async updateItemStatus(sessionId: string, status: vscode.ChatSessionStatus, newItemLabel: string): Promise<void> {688const resource = ClaudeSessionUri.forSessionId(sessionId);689let item = this._controller.items.get(resource);690if (!item) {691const session = await this._claudeCodeSessionService.getSession(resource, CancellationToken.None);692if (session) {693item = await this._createClaudeChatSessionItem(session);694} else {695const newlyCreatedSessionInfo: IClaudeCodeSessionInfo = {696id: sessionId,697label: newItemLabel,698created: Date.now(),699lastRequestEnded: Date.now(),700folderName: undefined701};702item = await this._createClaudeChatSessionItem(newlyCreatedSessionInfo);703}704705this._controller.items.add(item);706}707708item.status = status;709if (status === vscode.ChatSessionStatus.InProgress) {710const timing = item.timing ? { ...item.timing } : { created: Date.now() };711timing.lastRequestStarted = Date.now();712// Clear lastRequestEnded while a request is in progress713timing.lastRequestEnded = undefined;714item.timing = timing;715this._inProgressItems.set(sessionId, item);716} else {717this._inProgressItems.delete(sessionId);718if (status === vscode.ChatSessionStatus.Completed) {719if (!item.timing) {720item.timing = {721created: Date.now(),722lastRequestEnded: Date.now()723};724} else {725item.timing = { ...item.timing, lastRequestEnded: Date.now() };726}727const session = await this._claudeCodeSessionService.getSession(resource, CancellationToken.None);728if (session?.cwd && await this._workspaceService.isResourceTrusted(URI.file(session.cwd))) {729item.changes = await this._claudeWorkspaceFolderService.getWorkspaceChanges(730session.cwd,731session.gitBranch,732undefined,733true,734);735}736}737}738}739740private async _refreshItems(token: vscode.CancellationToken): Promise<void> {741const sessions = await this._claudeCodeSessionService.getAllSessions(token);742const results = await Promise.allSettled(sessions.map(session => this._createClaudeChatSessionItem(session)));743const items: vscode.ChatSessionItem[] = [];744for (let i = 0; i < results.length; i++) {745const result = results[i];746if (result.status === 'fulfilled') {747items.push(result.value);748} else {749const session = sessions[i];750this._logService.warn(`Failed to create Claude chat session item for ${session.id} (${session.label}) ${result.reason}`);751}752}753items.push(...this._inProgressItems.values());754this._controller.items.replace(items);755}756757private async _createClaudeChatSessionItem(session: IClaudeCodeSessionInfo): Promise<vscode.ChatSessionItem> {758let badge: vscode.MarkdownString | undefined;759if (session.folderName && this._showBadge) {760badge = new vscode.MarkdownString(`$(folder) ${session.folderName}`);761badge.supportThemeIcons = true;762}763764const item = this._controller.createChatSessionItem(ClaudeSessionUri.forSessionId(session.id), session.label);765item.badge = badge;766item.tooltip = `Claude Code session: ${session.label}`;767item.timing = {768created: session.created,769lastRequestStarted: session.lastRequestStarted,770lastRequestEnded: session.lastRequestEnded,771};772item.iconPath = new vscode.ThemeIcon('claude');773if (session.cwd) {774const isTrusted = await this._workspaceService.isResourceTrusted(URI.file(session.cwd));775if (isTrusted) {776const [metadata, changes] = await Promise.all([777this._buildSessionMetadata(session.cwd, isTrusted),778this._claudeWorkspaceFolderService.getWorkspaceChanges(779session.cwd,780session.gitBranch,781undefined,782),783]);784item.metadata = metadata;785item.changes = changes;786} else {787item.metadata = await this._buildSessionMetadata(session.cwd, isTrusted);788}789}790return item;791}792793private _computeShowBadge(): boolean {794const workspaceFolders = this._workspaceService.getWorkspaceFolders();795if (workspaceFolders.length === 0) {796return true; // Empty window797}798if (workspaceFolders.length > 1) {799return true; // Multi-root workspace800}801802// Single-root workspace with multiple git repositories803const repositories = this._gitService.repositories804.filter(repository => repository.kind !== 'worktree');805return repositories.length > 1;806}807808private async _buildSessionMetadata(cwd: string, isTrusted?: boolean): Promise<SessionMetadata> {809const cwdUri = URI.file(cwd);810if (!(isTrusted ?? await this._workspaceService.isResourceTrusted(cwdUri))) {811return { workingDirectoryPath: cwd };812}813814const repoContext = await this._gitService.getRepository(cwdUri);815if (!repoContext) {816return { workingDirectoryPath: cwd };817}818819const changes = repoContext.changes;820const uncommittedChanges = changes821? changes.mergeChanges.length + changes.indexChanges.length + changes.workingTree.length + changes.untrackedChanges.length822: 0;823824return {825workingDirectoryPath: cwd,826repositoryPath: repoContext.rootUri.fsPath,827branchName: repoContext.headBranchName,828upstreamBranchName: repoContext.upstreamRemote && repoContext.upstreamBranchName829? `${repoContext.upstreamRemote}/${repoContext.upstreamBranchName}`830: undefined,831hasGitHubRemote: getGitHubRepoInfoFromContext(repoContext) !== undefined,832incomingChanges: repoContext.headIncomingChanges ?? 0,833outgoingChanges: repoContext.headOutgoingChanges ?? 0,834uncommittedChanges,835};836}837838private _registerPromptCommand(commandId: string, prompt: string): void {839this._register(vscode.commands.registerCommand(commandId, async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {840const resource = getSessionResource(sessionItemOrResource);841if (!resource) {842return;843}844await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.claude-code', {845resource,846prompt,847});848}));849}850851private _registerCommands(): void {852this._register(vscode.commands.registerCommand('github.copilot.claude.sessions.rename', async (sessionItem?: vscode.ChatSessionItem) => {853if (!sessionItem?.resource) {854return;855}856857const sessionId = ClaudeSessionUri.getSessionId(sessionItem.resource);858const newTitle = await vscode.window.showInputBox({859prompt: vscode.l10n.t('New agent session title'),860value: sessionItem.label,861validateInput: value => {862if (!value.trim()) {863return vscode.l10n.t('Title cannot be empty');864}865return undefined;866}867});868869if (newTitle) {870const trimmedTitle = newTitle.trim();871if (trimmedTitle) {872try {873await this._sdkService.renameSession(sessionId, trimmedTitle);874this.updateItemLabel(sessionId, trimmedTitle);875} catch (e) {876this._logService.error(e, `[ClaudeChatSessionItemController] Failed to rename session: ${sessionId}`);877}878}879}880}));881882this._registerPromptCommand('github.copilot.claude.sessions.commit', builtinSlashCommands.commit);883this._registerPromptCommand('github.copilot.claude.sessions.commitAndSync', `${builtinSlashCommands.commit} and ${builtinSlashCommands.sync}`);884this._registerPromptCommand('github.copilot.claude.sessions.sync', builtinSlashCommands.sync);885886this._register(vscode.commands.registerCommand('github.copilot.claude.sessions.initializeRepository', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => {887const resource = getSessionResource(sessionItemOrResource);888if (!resource) {889return;890}891892const sessionId = ClaudeSessionUri.getSessionId(resource);893const folderInfo = await this.getFolderInfoForSession(sessionId);894const workspaceFolder = URI.file(folderInfo.cwd);895896const repository = await this._gitService.initRepository(workspaceFolder);897if (!repository) {898return;899}900901void this._refreshItems(CancellationToken.None);902}));903}904}905906907