diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts index fe1430a79a7..233ae18e424 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts @@ -681,6 +681,10 @@ export class ClaudeChatSessionItemController extends Disposable { lastRequestEnded: session.lastRequestEnded, }; item.iconPath = new vscode.ThemeIcon('claude'); + if (session.cwd) { + // Agents app needs this to decide the working directory for the session + item.metadata = { workingDirectoryPath: session.cwd }; + } return item; } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts index ea01bc986cc..2bd2a3126ab 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts @@ -1409,6 +1409,30 @@ describe('ClaudeChatSessionItemController', () => { // timing.created is derived from created expect(item!.timing!.created).toBe(new Date('2024-06-01T12:00:00Z').getTime()); }); + + it('sets metadata with workingDirectoryPath when session has cwd', async () => { + const diskSession: IClaudeCodeSessionInfo = { + id: 'cwd-session', + label: 'CWD Session', + created: Date.now(), + lastRequestEnded: Date.now(), + folderName: 'my-project', + cwd: '/home/user/my-project', + }; + vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any); + + await controller.updateItemStatus('cwd-session', ChatSessionStatus.InProgress, 'Prompt'); + + const item = getItem('cwd-session'); + expect(item!.metadata).toEqual({ workingDirectoryPath: '/home/user/my-project' }); + }); + + it('does not set metadata when session has no cwd', async () => { + await controller.updateItemStatus('no-cwd-session', ChatSessionStatus.InProgress, 'Prompt'); + + const item = getItem('no-cwd-session'); + expect(item!.metadata).toBeUndefined(); + }); }); // #endregion diff --git a/src/vs/sessions/SESSIONS_PROVIDER.md b/src/vs/sessions/SESSIONS_PROVIDER.md index dcc1f640213..1063819e6c0 100644 --- a/src/vs/sessions/SESSIONS_PROVIDER.md +++ b/src/vs/sessions/SESSIONS_PROVIDER.md @@ -79,6 +79,8 @@ The common session interface exposed by all providers. It is a self-contained fa - `repositories: ISessionRepository[]` — One or more repositories - `requiresWorkspaceTrust: boolean` — Whether workspace trust is required to operate +**Workspace label and session grouping:** The sessions list groups sessions by `workspace.label`. Sessions whose workspace is `undefined` or whose label is empty appear under "Unknown". For the `CopilotChatSessionsProvider`, the workspace label is derived from session metadata via `getRepositoryName()` (in `agentSessionsViewer.ts`), which checks these metadata keys in priority order: `remoteAgentHost`, `owner`+`name`, `repositoryNwo`, `repository`, `repositoryUrl`, `repositoryPath`, `worktreePath`, `workingDirectoryPath`, then `badge`. Extension-side `ChatSessionContentProvider` implementations must set `item.metadata` with at least `workingDirectoryPath` (for local sessions) so that sessions are grouped correctly. + **`ISessionRepository`** — A repository within a workspace: - `uri: URI` — Source repository URI (`file://` or `github-remote-file://`) - `workingDirectory: URI | undefined` — Worktree or checkout path diff --git a/src/vs/sessions/contrib/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md index b94223b688f..f62bc4c2fc1 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md @@ -2,7 +2,7 @@ **File:** `src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts` -The default sessions provider, registered with ID `'default-copilot'`. Wraps the existing agent session infrastructure into the extensible provider model. Supports two session types: **Copilot CLI** (local) and **Copilot Cloud** (remote). +The default sessions provider, registered with ID `'default-copilot'`. Wraps the existing agent session infrastructure into the extensible provider model. Supports three session types: **Copilot CLI** (local), **Copilot Cloud** (remote), and **Claude** (local, gated by `sessions.chatSessions.claude.enabled`). ## Registration @@ -28,7 +28,7 @@ class DefaultSessionsProviderContribution extends Disposable { | `id` | `'default-copilot'` | | `label` | `'Copilot Chat'` | | `icon` | `Codicon.copilot` | -| `sessionTypes` | `[CopilotCLISessionType, CopilotCloudSessionType]` | +| `sessionTypes` | `[CopilotCLISessionType, CopilotCloudSessionType]` (+ `ClaudeCodeSessionType` when enabled) | ## Browse Actions @@ -53,6 +53,12 @@ When `createNewSession(workspace)` is called, the provider creates one of two co - Provides `getModelOptionGroup()`, `getOtherOptionGroups()` for UI to render provider-specific pickers - Watches context key changes to dynamically show/hide option groups +**`ClaudeCodeNewSession`** — For Claude agent sessions (local `file://` workspaces): +- Implements `ISession` with simplified configuration (Claude manages its own worktrees and branches) +- No-ops for `setIsolationMode()` and `setBranch()` +- `setOption()` writes to `selectedOptions` map; options are propagated to `IChatSessionsService` during `_sendFirstChat()` via `updateSessionOptions()` +- Gated by the `sessions.chatSessions.claude.enabled` setting (default: `false`) + ## `AgentSessionAdapter` — Wrapping Existing Sessions Adapts an existing `IAgentSession` from the chat layer into the `ISession` facade: @@ -72,7 +78,7 @@ The provider maintains a `Map` cache keyed by resou ## Send Flow -1. Validate the session is a current new session (`CopilotCLISession` or `RemoteNewSession`) +1. Validate the session is a current new session (`CopilotCLISession`, `RemoteNewSession`, or `ClaudeCodeNewSession`) 2. For the first chat, call `_sendFirstChat()`: a. Resolve mode, permission level, and send options from session configuration b. Open the chat widget via `IChatWidgetService.openSession()` @@ -84,3 +90,70 @@ The provider maintains a `Map` cache keyed by resou 3. For subsequent chats (if `capabilities.supportsMultipleChats` enabled on the session), call `_sendSubsequentChat()` 4. Wrap the new agent session as `AgentSessionAdapter` and return it 5. Clear the current new session reference + +## New-Session Picker Contribution Model + +**File:** `src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts` + +The welcome/new-session view (`NewChatInputWidget`) renders three toolbar menus for configuration pickers. Each picker requires a three-part registration: + +1. **Menu action** — `registerAction2()` with a `when` clause gating it to the correct session type +2. **Action view item** — `actionViewItemService.register()` to provide a custom widget instead of a button +3. **Picker widget** — A `Disposable` class with a `render(container)` method, wrapped in `PickerActionViewItem` + +### Toolbar Menus + +| Menu | Purpose | Examples | +|------|---------|----------| +| `Menus.NewSessionConfig` | Session configuration (mode, model) | `ModePicker`, `CloudModelPicker`, local model picker | +| `Menus.NewSessionControl` | Session controls (permissions) | `PermissionPicker`, `ClaudePermissionModePicker` | +| `Menus.NewSessionRepositoryConfig` | Repository configuration | `IsolationPicker`, `BranchPicker` | + +### Context Key Gating + +Each picker action uses a `when` clause to show only for the correct session type: + +| Expression | Matches | +|------------|---------| +| `IsActiveSessionCopilotChatCLI` | Copilot CLI sessions | +| `IsActiveSessionCopilotChatCloud` | Copilot Cloud sessions | +| `IsActiveSessionCopilotChatClaudeCode` | Claude sessions | + +These are composed from `ActiveSessionTypeContext` (the session type ID) and `ActiveSessionProviderIdContext` (the provider ID). + +### Adding a New Picker + +```typescript +// 1. Register the menu action with a when clause +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'sessions.defaultCopilot.myPicker', + title: localize2('myPicker', "My Picker"), + f1: false, + menu: [{ + id: Menus.NewSessionControl, // or NewSessionConfig, NewSessionRepositoryConfig + group: 'navigation', + order: 1, + when: IsActiveSessionCopilotChatCLI, // gate to session type + }], + }); + } + override async run(): Promise { /* handled by action view item */ } +}); + +// 2. Register the action view item (in CopilotPickerActionViewItemContribution) +this._register(actionViewItemService.register( + Menus.NewSessionControl, 'sessions.defaultCopilot.myPicker', + () => { + const picker = instantiationService.createInstance(MyPicker); + return new PickerActionViewItem(picker); + }, +)); +``` + +### Current Limitations + +The picker model is currently **hardcoded per session type**. Each session type that needs pickers must register its own actions and widgets with appropriate `when` clauses. For example, the Copilot CLI permission picker (`PermissionPicker`) and the Claude permission mode picker (`ClaudePermissionModePicker`) are separate, hardcoded widgets even though they serve a similar purpose. + +Ideally, pickers would be **generic and contributable** — a session type would declare its option groups (as the Claude extension already does via `IChatSessionsService.setOptionGroupsForSessionType()`), and the welcome view would dynamically render pickers from those groups without needing per-type widget classes. The active-session chat widget (`chatInputPart.ts`) already has this generic infrastructure via `createChatSessionPickerWidgets()`, but the welcome view does not yet use it. Until the welcome view adopts this pattern, new session types must follow the hardcoded approach above. diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/claudePermissionModePicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/claudePermissionModePicker.ts new file mode 100644 index 00000000000..9f56fca68a8 --- /dev/null +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/claudePermissionModePicker.ts @@ -0,0 +1,158 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize } from '../../../../nls.js'; +import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListOptions } from '../../../../platform/actionWidget/browser/actionList.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { CopilotChatSessionsProvider } from './copilotChatSessionsProvider.js'; + +const PERMISSION_MODE_OPTION_ID = 'permissionMode'; + +interface IClaudePermissionModeItem { + readonly id: string; + readonly label: string; + readonly description: string; + readonly icon: ThemeIcon; +} + +const permissionModes: IClaudePermissionModeItem[] = [ + { + id: 'default', + label: localize('claude.permissionMode.default', "Ask Before Edits"), + description: localize('claude.permissionMode.default.description', "Claude asks for approval before making changes"), + icon: Codicon.shield, + }, + { + id: 'acceptEdits', + label: localize('claude.permissionMode.acceptEdits', "Edit Automatically"), + description: localize('claude.permissionMode.acceptEdits.description', "Claude edits files without asking"), + icon: Codicon.edit, + }, + { + id: 'plan', + label: localize('claude.permissionMode.plan', "Plan Mode"), + description: localize('claude.permissionMode.plan.description', "Claude creates a plan before making changes"), + icon: Codicon.lightbulb, + }, +]; + +export class ClaudePermissionModePicker extends Disposable { + + private _currentModeId = 'acceptEdits'; + private _triggerElement: HTMLElement | undefined; + private readonly _renderDisposables = this._register(new DisposableStore()); + + constructor( + @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, + ) { + super(); + } + + render(container: HTMLElement): HTMLElement { + this._renderDisposables.clear(); + + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); + this._renderDisposables.add({ dispose: () => slot.remove() }); + + const trigger = dom.append(slot, dom.$('a.action-label')); + trigger.tabIndex = 0; + trigger.role = 'button'; + this._triggerElement = trigger; + + this._updateTriggerLabel(trigger); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, (e) => { + dom.EventHelper.stop(e, true); + this._showPicker(); + })); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + dom.EventHelper.stop(e, true); + this._showPicker(); + } + })); + + return slot; + } + + private _showPicker(): void { + if (!this._triggerElement || this.actionWidgetService.isVisible) { + return; + } + + const items: IActionListItem[] = permissionModes.map(mode => ({ + kind: ActionListItemKind.Action, + group: { kind: ActionListItemKind.Header, title: '', icon: mode.icon }, + item: mode, + label: mode.label, + description: mode.description, + disabled: false, + })); + + const triggerElement = this._triggerElement; + const delegate: IActionListDelegate = { + onSelect: (item) => { + this.actionWidgetService.hide(); + this._selectMode(item); + }, + onHide: () => { triggerElement.focus(); }, + }; + + const listOptions: IActionListOptions = { descriptionBelow: true, minWidth: 255 }; + this.actionWidgetService.show( + 'claudePermissionModePicker', + false, + items, + delegate, + this._triggerElement, + undefined, + [], + { + getWidgetAriaLabel: () => localize('claudePermissionModePicker.ariaLabel', "Permission Mode"), + }, + listOptions, + ); + } + + private _selectMode(mode: IClaudePermissionModeItem): void { + this._currentModeId = mode.id; + this._updateTriggerLabel(this._triggerElement); + + const session = this.sessionsManagementService.activeSession.get(); + if (!session) { + return; + } + const provider = this.sessionsProvidersService.getProvider(session.providerId); + if (provider instanceof CopilotChatSessionsProvider) { + provider.getSession(session.sessionId)?.setOption?.(PERMISSION_MODE_OPTION_ID, { id: mode.id, name: mode.label }); + } + } + + private _updateTriggerLabel(trigger: HTMLElement | undefined): void { + if (!trigger) { + return; + } + + dom.clearNode(trigger); + const currentMode = permissionModes.find(m => m.id === this._currentModeId) ?? permissionModes[1]; + + dom.append(trigger, renderIcon(currentMode.icon)); + const labelSpan = dom.append(trigger, dom.$('span.sessions-chat-dropdown-label')); + labelSpan.textContent = currentMode.label; + dom.append(trigger, renderIcon(Codicon.chevronDown)); + + trigger.ariaLabel = localize('claudePermissionModePicker.triggerAriaLabel', "Pick Permission Mode, {0}", currentMode.label); + } +} diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts index b5ce2480e97..e93478001fe 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessions.contribution.ts @@ -6,7 +6,7 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { CopilotChatSessionsProvider, COPILOT_MULTI_CHAT_SETTING } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js'; +import { CopilotChatSessionsProvider, COPILOT_MULTI_CHAT_SETTING, CLAUDE_CODE_ENABLED_SETTING } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js'; import '../../copilotChatSessions/browser/copilotChatSessionsActions.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; @@ -24,6 +24,12 @@ Registry.as(ConfigurationExtensions.Configuration).regis tags: ['preview'], description: localize('sessions.github.copilot.multiChatSessions', "Whether to enable multiple chats within a single session in the Copilot Chat sessions provider."), }, + [CLAUDE_CODE_ENABLED_SETTING]: { + type: 'boolean', + default: false, + tags: ['experimental', 'onExp'], + description: localize('sessions.chatSessions.claude.enabled', "NOTE: This is HIGHLY experimental and under active development! Whether to enable Claude agent sessions in the sessions provider."), + }, }, }); diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts index 440a03d8e48..8f907b8c64e 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts @@ -23,7 +23,7 @@ import { Menus } from '../../../browser/menus.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { SessionItemContextMenuId } from '../../sessions/browser/views/sessionsList.js'; -import { COPILOT_CLI_SESSION_TYPE, COPILOT_CLOUD_SESSION_TYPE, ISession } from '../../../services/sessions/common/session.js'; +import { CLAUDE_CODE_SESSION_TYPE, COPILOT_CLI_SESSION_TYPE, COPILOT_CLOUD_SESSION_TYPE, ISession } from '../../../services/sessions/common/session.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { COPILOT_PROVIDER_ID, CopilotChatSessionsProvider } from './copilotChatSessionsProvider.js'; import { ActiveSessionHasGitRepositoryContext, ActiveSessionProviderIdContext, ActiveSessionTypeContext, ChatSessionProviderIdContext, IsNewChatSessionContext } from '../../../common/contextkeys.js'; @@ -32,12 +32,15 @@ import { BranchPicker } from './branchPicker.js'; import { ModePicker } from './modePicker.js'; import { CloudModelPicker } from './modelPicker.js'; import { CopilotPermissionPickerDelegate, PermissionPicker } from './permissionPicker.js'; +import { ClaudePermissionModePicker } from './claudePermissionModePicker.js'; const IsActiveSessionCopilotCLI = ContextKeyExpr.equals(ActiveSessionTypeContext.key, COPILOT_CLI_SESSION_TYPE); const IsActiveSessionCopilotCloud = ContextKeyExpr.equals(ActiveSessionTypeContext.key, COPILOT_CLOUD_SESSION_TYPE); const IsActiveCopilotChatSessionProvider = ContextKeyExpr.equals(ActiveSessionProviderIdContext.key, COPILOT_PROVIDER_ID); const IsActiveSessionCopilotChatCLI = ContextKeyExpr.and(IsActiveSessionCopilotCLI, IsActiveCopilotChatSessionProvider); const IsActiveSessionCopilotChatCloud = ContextKeyExpr.and(IsActiveSessionCopilotCloud, IsActiveCopilotChatSessionProvider); +const IsActiveSessionClaudeCode = ContextKeyExpr.equals(ActiveSessionTypeContext.key, CLAUDE_CODE_SESSION_TYPE); +const IsActiveSessionCopilotChatClaudeCode = ContextKeyExpr.and(IsActiveSessionClaudeCode, IsActiveCopilotChatSessionProvider); // -- Actions -- @@ -148,6 +151,23 @@ registerAction2(class extends Action2 { override async run(): Promise { /* handled by action view item */ } }); +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'sessions.defaultCopilot.claudePermissionModePicker', + title: localize2('claudePermissionModePicker', "Permission Mode"), + f1: false, + menu: [{ + id: Menus.NewSessionControl, + group: 'navigation', + order: 1, + when: IsActiveSessionCopilotChatClaudeCode, + }], + }); + } + override async run(): Promise { /* handled by action view item */ } +}); + // -- Helper -- /** @@ -279,6 +299,13 @@ class CopilotPickerActionViewItemContribution extends Disposable implements IWor return new PickerActionViewItem(picker, delegate); }, )); + this._register(actionViewItemService.register( + Menus.NewSessionControl, 'sessions.defaultCopilot.claudePermissionModePicker', + () => { + const picker = instantiationService.createInstance(ClaudePermissionModePicker); + return new PickerActionViewItem(picker); + }, + )); } } diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 3db5f674e74..c93edc1ff49 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -22,7 +22,7 @@ import { AgentSessionProviders, AgentSessionTarget } from '../../../../workbench import { IChatService, IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatResponseModel } from '../../../../workbench/contrib/chat/common/model/chatModel.js'; import { ChatSessionStatus, IChatSessionFileChange, IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { ISession, IChat, ISessionRepository, ISessionWorkspace, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, CopilotCLISessionType, CopilotCloudSessionType, ISessionType, ISessionWorkspaceBrowseAction } from '../../../services/sessions/common/session.js'; +import { ISession, IChat, ISessionRepository, ISessionWorkspace, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, CopilotCLISessionType, CopilotCloudSessionType, ClaudeCodeSessionType, ISessionType, ISessionWorkspaceBrowseAction } from '../../../services/sessions/common/session.js'; import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; import { basename, isEqual } from '../../../../base/common/resources.js'; import { ISendRequestOptions, ISessionChangeEvent, ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js'; @@ -99,6 +99,7 @@ export interface ICopilotChatSession { setModelId(modelId: string): void; setMode(chatMode: IChatMode | undefined): void; + setOption?(optionId: string, value: IChatSessionProviderOptionItem | string): void; readonly gitRepository?: IGitRepository; readonly branches: IObservable; @@ -112,6 +113,9 @@ export const COPILOT_PROVIDER_ID = 'default-copilot'; /** Setting key controlling whether the Copilot provider supports multiple chats per session. */ export const COPILOT_MULTI_CHAT_SETTING = 'sessions.github.copilot.multiChatSessions'; +/** Setting key controlling whether Claude agent sessions are available. */ +export const CLAUDE_CODE_ENABLED_SETTING = 'sessions.chatSessions.claude.enabled'; + const REPOSITORY_OPTION_ID = 'repository'; const PARENT_SESSION_OPTION_ID = 'parentSessionId'; @@ -119,7 +123,11 @@ const BRANCH_OPTION_ID = 'branch'; const ISOLATION_OPTION_ID = 'isolation'; const AGENT_OPTION_ID = 'agent'; -type NewSession = CopilotCLISession | RemoteNewSession; +type NewSession = CopilotCLISession | RemoteNewSession | ClaudeCodeNewSession; + +function isNewSession(session: ICopilotChatSession): session is NewSession { + return session instanceof CopilotCLISession || session instanceof RemoteNewSession || session instanceof ClaudeCodeNewSession; +} /** * Local new session for Background agent sessions. @@ -664,6 +672,137 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession update(_session: IAgentSession): void { } } +/** + * New session for Claude agent sessions. + * Implements {@link ICopilotChatSession} (session facade) and provides + * pre-send configuration methods for the new-session flow. + * Simpler than {@link CopilotCLISession} because the Claude agent manages + * its own worktrees and branches at runtime. + */ +class ClaudeCodeNewSession extends Disposable implements ICopilotChatSession { + + // -- ISessionData fields -- + + readonly id: string; + readonly providerId: string; + readonly sessionType: string; + readonly icon: ThemeIcon; + readonly createdAt: Date; + + private readonly _title = observableValue(this, ''); + readonly title: IObservable = this._title; + + private readonly _updatedAt = observableValue(this, new Date()); + readonly updatedAt: IObservable = this._updatedAt; + + private readonly _status = observableValue(this, SessionStatus.Untitled); + readonly status: IObservable = this._status; + + private readonly _permissionLevel = observableValue(this, ChatPermissionLevel.Default); + readonly permissionLevel: IObservable = this._permissionLevel; + + private readonly _workspaceData = observableValue(this, undefined); + readonly workspace: IObservable = this._workspaceData; + + readonly changes: IObservable = observableValue(this, []); + + private readonly _modelIdObservable = observableValue(this, undefined); + readonly modelId: IObservable = this._modelIdObservable; + + private readonly _modeObservable = observableValue<{ readonly id: string; readonly kind: string } | undefined>(this, undefined); + readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined> = this._modeObservable; + + readonly loading: IObservable = observableValue(this, false); + + private readonly _isArchived = observableValue(this, false); + readonly isArchived: IObservable = this._isArchived; + readonly isRead: IObservable = observableValue(this, true); + readonly description: IObservable = constObservable(undefined); + readonly lastTurnEnd: IObservable = constObservable(undefined); + readonly gitHubInfo: IObservable = constObservable(undefined); + readonly branch: IObservable = constObservable(undefined); + readonly isolationMode: IObservable = constObservable(undefined); + readonly branches: IObservable = constObservable([]); + readonly gitRepository?: IGitRepository | undefined; + + // -- New session configuration fields -- + + private _modelId: string | undefined; + private _mode: IChatMode | undefined; + + readonly target = AgentSessionProviders.Claude; + readonly selectedOptions = new Map(); + + get selectedModelId(): string | undefined { return this._modelId; } + get chatMode(): IChatMode | undefined { return this._mode; } + get query(): string | undefined { return undefined; } + get attachedContext(): IChatRequestVariableEntry[] | undefined { return undefined; } + get disabled(): boolean { return false; } + + constructor( + readonly resource: URI, + readonly sessionWorkspace: ISessionWorkspace, + providerId: string, + ) { + super(); + this.id = `${providerId}:${resource.toString()}`; + this.providerId = providerId; + this.sessionType = AgentSessionProviders.Claude; + this.icon = ClaudeCodeSessionType.icon; + this.createdAt = new Date(); + + this._workspaceData.set(sessionWorkspace, undefined); + } + + setOption(optionId: string, value: IChatSessionProviderOptionItem | string): void { + if (typeof value === 'string') { + this.selectedOptions.set(optionId, { id: value, name: value }); + } else { + this.selectedOptions.set(optionId, value); + } + } + + setPermissionLevel(level: ChatPermissionLevel): void { + this._permissionLevel.set(level, undefined); + } + + setIsolationMode(_mode: IsolationMode): void { + // No-op — Claude agent manages its own worktrees + } + + setBranch(_branch: string | undefined): void { + // No-op — Claude agent manages branches at runtime + } + + setModelId(modelId: string | undefined): void { + this._modelId = modelId; + this._modelIdObservable.set(modelId, undefined); + } + + setTitle(title: string): void { + this._title.set(title, undefined); + } + + setStatus(status: SessionStatus): void { + this._status.set(status, undefined); + } + + setArchived(archived: boolean): void { + this._isArchived.set(archived, undefined); + } + + setMode(mode: IChatMode | undefined): void { + this._mode = mode; + if (mode) { + this._modeObservable.set({ id: mode.id, kind: mode.kind }, undefined); + } else { + this._modeObservable.set(undefined, undefined); + } + } + + update(_session: IAgentSession): void { } +} + /** * Maps the existing {@link ChatSessionStatus} to the new {@link SessionStatus}. */ @@ -828,6 +967,8 @@ class AgentSessionAdapter implements ICopilotChatSession { return CopilotCLISessionType.icon; case AgentSessionProviders.Cloud: return CopilotCloudSessionType.icon; + case AgentSessionProviders.Claude: + return ClaudeCodeSessionType.icon; default: return session.icon; } @@ -1046,8 +1187,16 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions readonly id = COPILOT_PROVIDER_ID; readonly label = localize('copilotChatSessionsProvider', "Copilot Chat"); readonly icon = Codicon.copilot; - readonly sessionTypes: readonly ISessionType[] = [CopilotCLISessionType, CopilotCloudSessionType]; - readonly onDidChangeSessionTypes = Event.None; + get sessionTypes(): readonly ISessionType[] { + const types: ISessionType[] = [CopilotCLISessionType, CopilotCloudSessionType]; + if (this._claudeEnabled) { + types.push(ClaudeCodeSessionType); + } + return types; + } + + private readonly _onDidChangeSessionTypes = this._register(new Emitter()); + readonly onDidChangeSessionTypes: Event = this._onDidChangeSessionTypes.event; private readonly _onDidChangeSessions = this._register(new Emitter()); readonly onDidChangeSessions: Event = this._onDidChangeSessions.event; @@ -1056,7 +1205,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions readonly onDidReplaceSession: Event<{ readonly from: ISession; readonly to: ISession }> = this._onDidReplaceSession.event; /** Cache of adapted sessions, keyed by resource URI string. */ - private readonly _sessionCache = new Map(); + private readonly _sessionCache = new Map(); /** Cache of ISession wrappers, keyed by session group ID. */ private readonly _sessionGroupCache = new Map(); @@ -1065,6 +1214,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions private readonly _groupModel: SessionsGroupModel; private readonly _multiChatEnabled: boolean; + private _claudeEnabled: boolean; readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; @@ -1080,14 +1230,26 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService, @IStorageService storageService: IStorageService, - @IConfigurationService configurationService: IConfigurationService, + @IConfigurationService private readonly configurationService: IConfigurationService, @ILogService private readonly logService: ILogService, @IGitHubService private readonly gitHubService: IGitHubService, ) { super(); this._groupModel = this._register(new SessionsGroupModel(storageService)); - this._multiChatEnabled = configurationService.getValue(COPILOT_MULTI_CHAT_SETTING) ?? true; + this._multiChatEnabled = this.configurationService.getValue(COPILOT_MULTI_CHAT_SETTING) ?? true; + this._claudeEnabled = this.configurationService.getValue(CLAUDE_CODE_ENABLED_SETTING) ?? false; + + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(CLAUDE_CODE_ENABLED_SETTING)) { + const claudeEnabled = this.configurationService.getValue(CLAUDE_CODE_ENABLED_SETTING) ?? false; + if (this._claudeEnabled !== claudeEnabled) { + this._claudeEnabled = claudeEnabled; + this._onDidChangeSessionTypes.fire(); + this._refreshSessionCache(); + } + } + })); this.browseActions = [ { @@ -1116,7 +1278,11 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions if (workspaceUri.scheme === GITHUB_REMOTE_FILE_SCHEME) { return [CopilotCloudSessionType]; } - return [CopilotCLISessionType]; + const types: ISessionType[] = [CopilotCLISessionType]; + if (this._claudeEnabled) { + types.push(ClaudeCodeSessionType); + } + return types; } getSessions(): ISession[] { @@ -1171,8 +1337,15 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return this._chatToSession(session); } + if (sessionTypeId === ClaudeCodeSessionType.id) { + const resource = URI.from({ scheme: AgentSessionProviders.Claude, path: `/untitled-${generateUuid()}` }); + const session = this.instantiationService.createInstance(ClaudeCodeNewSession, resource, workspace, this.id); + this._currentNewSession = session; + return this._chatToSession(session); + } + if (sessionTypeId !== CopilotCLISessionType.id) { - throw new Error('Only Copilot CLI sessions can be created for non-GitHub repositories'); + throw new Error(`Unsupported session type '${sessionTypeId}' for local workspaces`); } const resource = URI.from({ scheme: AgentSessionProviders.Background, path: `/untitled-${generateUuid()}` }); const session = this.instantiationService.createInstance(CopilotCLISession, resource, workspace, this.id); @@ -1198,7 +1371,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions // Temp session that hasn't been committed — archive it in-place // so the user can still review whatever content was produced. const chatSession = this._findChatSession(sessionId); - if (chatSession && (chatSession instanceof CopilotCLISession || chatSession instanceof RemoteNewSession)) { + if (chatSession && isNewSession(chatSession)) { chatSession.setArchived(true); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(chatSession)] }); return; @@ -1214,7 +1387,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions // Temp session that hasn't been committed — unarchive it in-place const chatSession = this._findChatSession(sessionId); - if (chatSession && (chatSession instanceof CopilotCLISession || chatSession instanceof RemoteNewSession)) { + if (chatSession && isNewSession(chatSession)) { chatSession.setArchived(false); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(chatSession)] }); } @@ -1264,7 +1437,11 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions await this.commandService.executeCommand('github.copilot.cli.sessions.setTitle', { resource: chatUri }, title); return; } - throw new Error('Renaming is only supported for Copilot CLI sessions'); + if (agentSession?.providerType === AgentSessionProviders.Claude) { + await this.commandService.executeCommand('github.copilot.claude.sessions.rename', { resource: chatUri }, title); + return; + } + throw new Error('Renaming is not supported for this session type'); } async deleteChat(sessionId: string, chatUri: URI): Promise { @@ -1398,7 +1575,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions * Sends the first chat for a newly created session. * Adds the temp session to the cache, waits for commit, then replaces it. */ - private async _sendFirstChat(session: CopilotCLISession | RemoteNewSession, options: ISendRequestOptions): Promise { + private async _sendFirstChat(session: CopilotCLISession | RemoteNewSession | ClaudeCodeNewSession, options: ISendRequestOptions): Promise { const { query, attachedContext } = options; @@ -1701,6 +1878,10 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions throw new Error('Multiple chats per session is not supported for cloud sessions'); } + if (chat.sessionType === AgentSessionProviders.Claude) { + throw new Error('Multiple chats per session is not supported for Claude sessions'); + } + const workspace = chat.workspace.get(); if (!workspace) { throw new Error('Chat session has no associated workspace'); @@ -1918,7 +2099,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions const removedSession = this._chatToSession(chatSession); this._sessionGroupCache.delete(chatSession.id); this._onDidChangeSessions.fire({ added: [], removed: [removedSession], changed: [] }); - if (chatSession instanceof CopilotCLISession || chatSession instanceof RemoteNewSession) { + if (isNewSession(chatSession)) { chatSession.dispose(); } } @@ -1930,7 +2111,12 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions for (const session of this.agentSessionsService.model.sessions) { if (session.providerType !== AgentSessionProviders.Background - && session.providerType !== AgentSessionProviders.Cloud) { + && session.providerType !== AgentSessionProviders.Cloud + && session.providerType !== AgentSessionProviders.Claude) { + continue; + } + + if (session.providerType === AgentSessionProviders.Claude && !this._claudeEnabled) { continue; } diff --git a/src/vs/sessions/contrib/copilotChatSessions/test/browser/claudePermissionModePicker.test.ts b/src/vs/sessions/contrib/copilotChatSessions/test/browser/claudePermissionModePicker.test.ts new file mode 100644 index 00000000000..00faf84b696 --- /dev/null +++ b/src/vs/sessions/contrib/copilotChatSessions/test/browser/claudePermissionModePicker.test.ts @@ -0,0 +1,158 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { IActionListItem } from '../../../../../platform/actionWidget/browser/actionList.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; +import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { CopilotChatSessionsProvider, ICopilotChatSession } from '../../browser/copilotChatSessionsProvider.js'; +import { ClaudePermissionModePicker } from '../../browser/claudePermissionModePicker.js'; + +interface IPermissionModeItem { + readonly id: string; + readonly label: string; +} + +function showPicker(container: HTMLElement): void { + const trigger = container.querySelector('a.action-label'); + assert.ok(trigger); + trigger.click(); +} + +function createPicker( + disposables: DisposableStore, + opts?: { + setOptionSpy?: (optionId: string, value: { id: string; name: string }) => void; + hasActiveSession?: boolean; + }, +): { picker: ClaudePermissionModePicker; actionWidgetItems: IActionListItem[]; onSelect: (item: IPermissionModeItem) => void } { + const instantiationService = disposables.add(new TestInstantiationService()); + const actionWidgetItems: IActionListItem[] = []; + let capturedOnSelect: ((item: IPermissionModeItem) => void) | undefined; + + const setOptionSpy = opts?.setOptionSpy ?? (() => { }); + const hasActiveSession = opts?.hasActiveSession ?? true; + + const activeSession = hasActiveSession ? { + providerId: 'default-copilot', + sessionId: 'session-id', + loading: observableValue('loading', false), + } as unknown as IActiveSession : undefined; + + const mockSession: Partial = { + setOption: setOptionSpy as ICopilotChatSession['setOption'], + }; + + const provider = Object.assign(Object.create(CopilotChatSessionsProvider.prototype), { + getSession: () => mockSession, + }); + + instantiationService.stub(IActionWidgetService, { + isVisible: false, + hide: () => { }, + show: (_id: string, _supportsPreview: boolean, items: IActionListItem[], delegate: { onSelect: (item: T) => void }) => { + actionWidgetItems.splice(0, actionWidgetItems.length, ...(items as IActionListItem[])); + capturedOnSelect = delegate.onSelect as (item: IPermissionModeItem) => void; + }, + }); + instantiationService.stub(ISessionsManagementService, { + activeSession: observableValue('activeSession', activeSession), + } as unknown as ISessionsManagementService); + instantiationService.stub(ISessionsProvidersService, { + onDidChangeProviders: Event.None, + getProviders: () => [], + getProvider: () => provider, + } as unknown as ISessionsProvidersService); + + const picker = disposables.add(instantiationService.createInstance(ClaudePermissionModePicker)); + + return { + picker, + actionWidgetItems, + get onSelect() { return capturedOnSelect!; }, + }; +} + +suite('ClaudePermissionModePicker', () => { + const disposables = new DisposableStore(); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('shows all three permission modes', () => { + const { picker, actionWidgetItems } = createPicker(disposables); + const container = document.createElement('div'); + picker.render(container); + showPicker(container); + + assert.deepStrictEqual( + actionWidgetItems.map(item => ({ id: item.item?.id, label: item.label })), + [ + { id: 'default', label: 'Ask Before Edits' }, + { id: 'acceptEdits', label: 'Edit Automatically' }, + { id: 'plan', label: 'Plan Mode' }, + ], + ); + }); + + test('selecting a mode updates the trigger label', () => { + const result = createPicker(disposables); + const container = document.createElement('div'); + result.picker.render(container); + showPicker(container); + + result.onSelect({ id: 'plan', label: 'Plan Mode' } as IPermissionModeItem); + + const labelSpan = container.querySelector('span.sessions-chat-dropdown-label'); + assert.ok(labelSpan); + assert.strictEqual(labelSpan.textContent, 'Plan Mode'); + }); + + test('selecting a mode calls setOption on the session', () => { + const calls: { optionId: string; value: { id: string; name: string } }[] = []; + const result = createPicker(disposables, { + setOptionSpy: (optionId, value) => calls.push({ optionId, value }), + }); + const container = document.createElement('div'); + result.picker.render(container); + showPicker(container); + + result.onSelect({ id: 'default', label: 'Ask Before Edits' } as IPermissionModeItem); + + assert.deepStrictEqual(calls, [{ + optionId: 'permissionMode', + value: { id: 'default', name: 'Ask Before Edits' }, + }]); + }); + + test('selecting a mode does not throw when no active session', () => { + const result = createPicker(disposables, { hasActiveSession: false }); + const container = document.createElement('div'); + result.picker.render(container); + showPicker(container); + + assert.doesNotThrow(() => result.onSelect({ id: 'plan', label: 'Plan Mode' } as IPermissionModeItem)); + }); + + test('trigger has correct aria label', () => { + const { picker } = createPicker(disposables); + const container = document.createElement('div'); + picker.render(container); + + const trigger = container.querySelector('a.action-label'); + assert.ok(trigger); + // Default mode is 'acceptEdits' → "Edit Automatically" + assert.ok(trigger.ariaLabel?.includes('Edit Automatically')); + }); +}); diff --git a/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index 5dd5aaadd5a..db3ee3ffebb 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -10,7 +10,7 @@ import { DisposableStore, toDisposable } from '../../../../../base/common/lifecy import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IDialogService, IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; @@ -30,8 +30,8 @@ import { IChatResponseModel } from '../../../../../workbench/contrib/chat/common import { IChatAgentData } from '../../../../../workbench/contrib/chat/common/participants/chatAgents.js'; import { IGitService } from '../../../../../workbench/contrib/git/common/gitService.js'; import { ISessionChangeEvent } from '../../../../services/sessions/common/sessionsProvider.js'; -import { CopilotCLISessionType, SessionStatus } from '../../../../services/sessions/common/session.js'; -import { CopilotChatSessionsProvider, COPILOT_PROVIDER_ID } from '../../browser/copilotChatSessionsProvider.js'; +import { ClaudeCodeSessionType, CopilotCLISessionType, GITHUB_REMOTE_FILE_SCHEME, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { CLAUDE_CODE_ENABLED_SETTING, CopilotChatSessionsProvider, COPILOT_PROVIDER_ID } from '../../browser/copilotChatSessionsProvider.js'; import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; // ---- Helpers ---------------------------------------------------------------- @@ -107,12 +107,21 @@ class MockAgentSessionsModel { function createProvider( disposables: DisposableStore, model: MockAgentSessionsModel, - opts?: { multiChatEnabled?: boolean }, + opts?: { multiChatEnabled?: boolean; claudeEnabled?: boolean }, ): CopilotChatSessionsProvider { + return createProviderWithConfig(disposables, model, opts).provider; +} + +function createProviderWithConfig( + disposables: DisposableStore, + model: MockAgentSessionsModel, + opts?: { multiChatEnabled?: boolean; claudeEnabled?: boolean }, +): { provider: CopilotChatSessionsProvider; configService: TestConfigurationService } { const instantiationService = disposables.add(new TestInstantiationService()); const configService = new TestConfigurationService(); configService.setUserConfiguration('sessions.github.copilot.multiChatSessions', opts?.multiChatEnabled ?? true); + configService.setUserConfiguration(CLAUDE_CODE_ENABLED_SETTING, opts?.claudeEnabled ?? false); instantiationService.stub(IConfigurationService, configService); instantiationService.stub(IStorageService, disposables.add(new TestStorageService())); @@ -171,7 +180,7 @@ function createProvider( instantiationService.stub(IInstantiationService, instantiationService); const provider = disposables.add(instantiationService.createInstance(CopilotChatSessionsProvider)); - return provider; + return { provider, configService }; } // ---- Provider factory for send/cancel tests --------------------------------- @@ -188,12 +197,13 @@ function createProviderForSendTests( disposables: DisposableStore, model: MockAgentSessionsModel, sendRequest: () => Promise, - opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }> }, + opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }>; claudeEnabled?: boolean }, ): CopilotChatSessionsProvider { const instantiationService = disposables.add(new TestInstantiationService()); const configService = new TestConfigurationService(); configService.setUserConfiguration('sessions.github.copilot.multiChatSessions', true); + configService.setUserConfiguration(CLAUDE_CODE_ENABLED_SETTING, opts?.claudeEnabled ?? false); instantiationService.stub(ILogService, NullLogService); instantiationService.stub(IConfigurationService, configService); @@ -263,6 +273,83 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(provider.sessionTypes.length, 2); }); + test('sessionTypes includes Claude when setting is enabled', () => { + const provider = createProvider(disposables, model, { claudeEnabled: true }); + assert.strictEqual(provider.sessionTypes.length, 3); + assert.ok(provider.sessionTypes.some(t => t.id === ClaudeCodeSessionType.id)); + }); + + test('onDidChangeSessionTypes fires when claude setting changes', () => { + const { provider, configService } = createProviderWithConfig(disposables, model); + assert.strictEqual(provider.sessionTypes.length, 2); + + let fired = false; + disposables.add(provider.onDidChangeSessionTypes(() => { fired = true; })); + + // Enable claude via config change + configService.setUserConfiguration(CLAUDE_CODE_ENABLED_SETTING, true); + configService.onDidChangeConfigurationEmitter.fire({ + source: ConfigurationTarget.USER, + affectedKeys: new Set([CLAUDE_CODE_ENABLED_SETTING]), + change: { keys: [CLAUDE_CODE_ENABLED_SETTING], overrides: [] }, + affectsConfiguration: (key: string) => key === CLAUDE_CODE_ENABLED_SETTING, + }); + + assert.ok(fired, 'onDidChangeSessionTypes should have fired'); + assert.strictEqual(provider.sessionTypes.length, 3); + }); + + test('toggling claude setting refreshes sessions list', () => { + const claudeResource = URI.from({ scheme: AgentSessionProviders.Claude, path: '/claude-session' }); + model.addSession(createMockAgentSession(claudeResource, { providerType: AgentSessionProviders.Claude })); + + const { provider, configService } = createProviderWithConfig(disposables, model); + assert.strictEqual(provider.getSessions().length, 0, 'Claude sessions should be hidden when disabled'); + + // Enable Claude + configService.setUserConfiguration(CLAUDE_CODE_ENABLED_SETTING, true); + configService.onDidChangeConfigurationEmitter.fire({ + source: ConfigurationTarget.USER, + affectedKeys: new Set([CLAUDE_CODE_ENABLED_SETTING]), + change: { keys: [CLAUDE_CODE_ENABLED_SETTING], overrides: [] }, + affectsConfiguration: (key: string) => key === CLAUDE_CODE_ENABLED_SETTING, + }); + + assert.strictEqual(provider.getSessions().length, 1, 'Claude sessions should appear after enabling'); + + // Disable Claude + configService.setUserConfiguration(CLAUDE_CODE_ENABLED_SETTING, false); + configService.onDidChangeConfigurationEmitter.fire({ + source: ConfigurationTarget.USER, + affectedKeys: new Set([CLAUDE_CODE_ENABLED_SETTING]), + change: { keys: [CLAUDE_CODE_ENABLED_SETTING], overrides: [] }, + affectsConfiguration: (key: string) => key === CLAUDE_CODE_ENABLED_SETTING, + }); + + assert.strictEqual(provider.getSessions().length, 0, 'Claude sessions should disappear after disabling'); + }); + + // ---- getSessionTypes ------- + + test('getSessionTypes returns Claude for local workspace when enabled', () => { + const provider = createProvider(disposables, model, { claudeEnabled: true }); + const types = provider.getSessionTypes(URI.file('/test/project')); + assert.ok(types.some(t => t.id === ClaudeCodeSessionType.id)); + }); + + test('getSessionTypes does not return Claude for local workspace when disabled', () => { + const provider = createProvider(disposables, model); + const types = provider.getSessionTypes(URI.file('/test/project')); + assert.ok(!types.some(t => t.id === ClaudeCodeSessionType.id)); + }); + + test('getSessionTypes returns only Cloud for remote workspace regardless of claude setting', () => { + const provider = createProvider(disposables, model, { claudeEnabled: true }); + const types = provider.getSessionTypes(URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, path: '/owner/repo' })); + assert.strictEqual(types.length, 1); + assert.ok(!types.some(t => t.id === ClaudeCodeSessionType.id)); + }); + // ---- Session listing ------- test('getSessions returns empty array initially', () => { @@ -282,7 +369,7 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(sessions.length, 2); }); - test('getSessions ignores non-Background/Cloud sessions', () => { + test('getSessions ignores non-Background/Cloud/Claude sessions', () => { const bgResource = URI.from({ scheme: AgentSessionProviders.Background, path: '/bg-session' }); const localResource = URI.from({ scheme: AgentSessionProviders.Local, path: '/local-session' }); model.addSession(createMockAgentSession(bgResource)); @@ -294,6 +381,26 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(sessions.length, 1); }); + test('getSessions includes Claude agent sessions when enabled', () => { + const claudeResource = URI.from({ scheme: AgentSessionProviders.Claude, path: '/claude-session' }); + model.addSession(createMockAgentSession(claudeResource, { providerType: AgentSessionProviders.Claude })); + + const provider = createProvider(disposables, model, { claudeEnabled: true }); + const sessions = provider.getSessions(); + + assert.strictEqual(sessions.length, 1); + }); + + test('getSessions excludes Claude agent sessions when disabled', () => { + const claudeResource = URI.from({ scheme: AgentSessionProviders.Claude, path: '/claude-session' }); + model.addSession(createMockAgentSession(claudeResource, { providerType: AgentSessionProviders.Claude })); + + const provider = createProvider(disposables, model); + const sessions = provider.getSessions(); + + assert.strictEqual(sessions.length, 0); + }); + test('onDidChangeSessions fires when agent model changes', () => { const provider = createProvider(disposables, model); provider.getSessions(); // Initialize cache @@ -378,6 +485,17 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(sessions[0].capabilities.supportsMultipleChats, false); }); + test('claude sessions do not have supportsMultipleChats capability', () => { + const resource = URI.from({ scheme: AgentSessionProviders.Claude, path: '/session-1' }); + model.addSession(createMockAgentSession(resource, { providerType: AgentSessionProviders.Claude })); + + const provider = createProvider(disposables, model, { claudeEnabled: true }); + const sessions = provider.getSessions(); + + assert.strictEqual(sessions.length, 1); + assert.strictEqual(sessions[0].capabilities.supportsMultipleChats, false); + }); + // ---- Session listing & grouping ------- test('each session has exactly one chat initially', () => { @@ -654,6 +772,130 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(provider.browseActions[1].providerId, COPILOT_PROVIDER_ID); }); + // ---- Claude session creation ------- + + function makeClaudeInFlightProvider(): { provider: CopilotChatSessionsProvider; cancelRequest: () => void } { + let resolveComplete!: () => void; + let resolveCreated!: (r: IChatResponseModel) => void; + const responseCompletePromise = new Promise(r => { resolveComplete = r; }); + const responseCreatedPromise = new Promise(r => { resolveCreated = r; }); + + const provider = createProviderForSendTests(disposables, model, async () => ({ + kind: 'sent' as const, + data: { + responseCompletePromise, + responseCreatedPromise, + agent: new class extends mock() { }(), + } as IChatSendRequestData, + }), { claudeEnabled: true }); + + return { + provider, + cancelRequest: () => { + resolveCreated({ isCanceled: true } as unknown as IChatResponseModel); + resolveComplete(); + }, + }; + } + + function waitForSessionAdded(provider: CopilotChatSessionsProvider): Promise { + return new Promise(resolve => { + const d = provider.onDidChangeSessions(e => { + if (e.added.length > 0) { + d.dispose(); + resolve(); + } + }); + }); + } + + test('createNewSession with Claude type creates a session', async () => { + const { provider, cancelRequest } = makeClaudeInFlightProvider(); + const workspace = URI.file('/test/project'); + + const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id); + + assert.ok(session); + assert.strictEqual(session.sessionType, ClaudeCodeSessionType.id); + assert.strictEqual(session.status.get(), SessionStatus.Untitled); + + // Send and clean up so the session enters the cache and can be disposed + const added = waitForSessionAdded(provider); + const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'test' }); + await added; + cancelRequest(); + await assert.doesNotReject(sendPromise); + await provider.deleteSession(session.sessionId); + }); + + test('archiveSession archives a Claude temp session', async () => { + const { provider, cancelRequest } = makeClaudeInFlightProvider(); + const workspace = URI.file('/test/project'); + const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id); + + const added = waitForSessionAdded(provider); + const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'test' }); + await added; + + await provider.archiveSession(session.sessionId); + assert.strictEqual(provider.getSessions()[0].isArchived.get(), true); + + cancelRequest(); + await assert.doesNotReject(sendPromise); + + // Clean up + await provider.deleteSession(session.sessionId); + }); + + test('unarchiveSession unarchives a Claude temp session', async () => { + const { provider, cancelRequest } = makeClaudeInFlightProvider(); + const workspace = URI.file('/test/project'); + const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id); + + const added = waitForSessionAdded(provider); + const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'test' }); + await added; + + await provider.archiveSession(session.sessionId); + assert.strictEqual(provider.getSessions()[0].isArchived.get(), true); + + await provider.unarchiveSession(session.sessionId); + assert.strictEqual(provider.getSessions()[0].isArchived.get(), false); + + cancelRequest(); + await assert.doesNotReject(sendPromise); + + // Clean up + await provider.deleteSession(session.sessionId); + }); + + // ---- Rename ------- + + test('renameChat delegates to claude rename command', async () => { + const claudeResource = URI.from({ scheme: AgentSessionProviders.Claude, path: '/claude-session' }); + model.addSession(createMockAgentSession(claudeResource, { providerType: AgentSessionProviders.Claude })); + + const provider = createProvider(disposables, model, { claudeEnabled: true }); + const sessions = provider.getSessions(); + assert.strictEqual(sessions.length, 1); + + // Should not throw — delegates to ICommandService.executeCommand + await provider.renameChat(sessions[0].sessionId, claudeResource, 'New Title'); + }); + + test('renameChat throws for unsupported session type', async () => { + const resource = URI.from({ scheme: AgentSessionProviders.Cloud, path: '/cloud-session' }); + model.addSession(createMockAgentSession(resource, { providerType: AgentSessionProviders.Cloud })); + + const provider = createProvider(disposables, model); + const sessions = provider.getSessions(); + + await assert.rejects( + () => provider.renameChat(sessions[0].sessionId, resource, 'New Title'), + /not supported/, + ); + }); + // ---- Uncommitted temp session cleanup ------------------------------------ suite('uncommitted temp session cleanup', () => { diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 2d26f435abf..8d97f27a750 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -40,6 +40,16 @@ export const CopilotCloudSessionType: ISessionType = { icon: Codicon.cloud, }; +/** Session type ID for Claude Code sessions. */ +export const CLAUDE_CODE_SESSION_TYPE = 'claude-code'; + +/** Claude Code session type — local agent powered by Claude. */ +export const ClaudeCodeSessionType: ISessionType = { + id: CLAUDE_CODE_SESSION_TYPE, + label: localize('claudeCode', "Claude"), + icon: Codicon.claude, +}; + export const GITHUB_REMOTE_FILE_SCHEME = 'github-remote-file'; /**