Initial framework of Claude sessions in the Agents app (#311532)

* Initial framework of Claude sessions in the Agents app

This is barely functional, but it _is_ possible to chat with Claude through it.

Bugs:

First sent message from the welcome chat behavior is VERY strange. The chat is shown completely blank. Then I see the chat under "Unknown" section in the sessions list. I click it, it disappears after a second and reappears in the "Unknown" section. I click it again, it re-appears in the correct place... then I can start chatting with it.

Secondly, no dropdowns show in the existing chat so you can't change the permission mode once you start.

I put all of this behind a setting since it's not really ready yet.. but I wanted to get more eyes on these bugs.

Co-authored-by: Copilot <copilot@github.com>

* feedback

Co-authored-by: Copilot <copilot@github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Tyler James Leonhardt
2026-04-20 18:10:10 -07:00
committed by GitHub
parent cf0709667e
commit 8f796b4d8f
11 changed files with 917 additions and 27 deletions

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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

View File

@@ -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<string, AgentSessionAdapter>` 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<string, AgentSessionAdapter>` 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<void> { /* 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.

View File

@@ -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<IClaudePermissionModeItem>[] = 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<IClaudePermissionModeItem> = {
onSelect: (item) => {
this.actionWidgetService.hide();
this._selectMode(item);
},
onHide: () => { triggerElement.focus(); },
};
const listOptions: IActionListOptions = { descriptionBelow: true, minWidth: 255 };
this.actionWidgetService.show<IClaudePermissionModeItem>(
'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);
}
}

View File

@@ -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<IConfigurationRegistry>(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."),
},
},
});

View File

@@ -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<void> { /* 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<void> { /* 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);
},
));
}
}

View File

@@ -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<readonly string[]>;
@@ -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<string> = this._title;
private readonly _updatedAt = observableValue(this, new Date());
readonly updatedAt: IObservable<Date> = this._updatedAt;
private readonly _status = observableValue(this, SessionStatus.Untitled);
readonly status: IObservable<SessionStatus> = this._status;
private readonly _permissionLevel = observableValue(this, ChatPermissionLevel.Default);
readonly permissionLevel: IObservable<ChatPermissionLevel> = this._permissionLevel;
private readonly _workspaceData = observableValue<ISessionWorkspace | undefined>(this, undefined);
readonly workspace: IObservable<ISessionWorkspace | undefined> = this._workspaceData;
readonly changes: IObservable<readonly IChatSessionFileChange[]> = observableValue<readonly IChatSessionFileChange[]>(this, []);
private readonly _modelIdObservable = observableValue<string | undefined>(this, undefined);
readonly modelId: IObservable<string | undefined> = 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<boolean> = observableValue(this, false);
private readonly _isArchived = observableValue(this, false);
readonly isArchived: IObservable<boolean> = this._isArchived;
readonly isRead: IObservable<boolean> = observableValue(this, true);
readonly description: IObservable<IMarkdownString | undefined> = constObservable(undefined);
readonly lastTurnEnd: IObservable<Date | undefined> = constObservable(undefined);
readonly gitHubInfo: IObservable<IGitHubInfo | undefined> = constObservable(undefined);
readonly branch: IObservable<string | undefined> = constObservable(undefined);
readonly isolationMode: IObservable<IsolationMode | undefined> = constObservable(undefined);
readonly branches: IObservable<readonly string[]> = 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<string, IChatSessionProviderOptionItem>();
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<void>());
readonly onDidChangeSessionTypes: Event<void> = this._onDidChangeSessionTypes.event;
private readonly _onDidChangeSessions = this._register(new Emitter<ISessionChangeEvent>());
readonly onDidChangeSessions: Event<ISessionChangeEvent> = 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<string, AgentSessionAdapter | CopilotCLISession | RemoteNewSession>();
private readonly _sessionCache = new Map<string, AgentSessionAdapter | CopilotCLISession | RemoteNewSession | ClaudeCodeNewSession>();
/** Cache of ISession wrappers, keyed by session group ID. */
private readonly _sessionGroupCache = new Map<string, ISession>();
@@ -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<boolean>(COPILOT_MULTI_CHAT_SETTING) ?? true;
this._multiChatEnabled = this.configurationService.getValue<boolean>(COPILOT_MULTI_CHAT_SETTING) ?? true;
this._claudeEnabled = this.configurationService.getValue<boolean>(CLAUDE_CODE_ENABLED_SETTING) ?? false;
this._register(this.configurationService.onDidChangeConfiguration(e => {
if (e.affectsConfiguration(CLAUDE_CODE_ENABLED_SETTING)) {
const claudeEnabled = this.configurationService.getValue<boolean>(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<void> {
@@ -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<ISession> {
private async _sendFirstChat(session: CopilotCLISession | RemoteNewSession | ClaudeCodeNewSession, options: ISendRequestOptions): Promise<ISession> {
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;
}

View File

@@ -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<HTMLElement>('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<IPermissionModeItem>[]; onSelect: (item: IPermissionModeItem) => void } {
const instantiationService = disposables.add(new TestInstantiationService());
const actionWidgetItems: IActionListItem<IPermissionModeItem>[] = [];
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<ICopilotChatSession> = {
setOption: setOptionSpy as ICopilotChatSession['setOption'],
};
const provider = Object.assign(Object.create(CopilotChatSessionsProvider.prototype), {
getSession: () => mockSession,
});
instantiationService.stub(IActionWidgetService, {
isVisible: false,
hide: () => { },
show: <T>(_id: string, _supportsPreview: boolean, items: IActionListItem<T>[], delegate: { onSelect: (item: T) => void }) => {
actionWidgetItems.splice(0, actionWidgetItems.length, ...(items as IActionListItem<IPermissionModeItem>[]));
capturedOnSelect = delegate.onSelect as (item: IPermissionModeItem) => void;
},
});
instantiationService.stub(ISessionsManagementService, {
activeSession: observableValue<IActiveSession | undefined>('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<HTMLElement>('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<HTMLElement>('a.action-label');
assert.ok(trigger);
// Default mode is 'acceptEdits' → "Edit Automatically"
assert.ok(trigger.ariaLabel?.includes('Edit Automatically'));
});
});

View File

@@ -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<ChatSendResult>,
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<void>(r => { resolveComplete = r; });
const responseCreatedPromise = new Promise<IChatResponseModel>(r => { resolveCreated = r; });
const provider = createProviderForSendTests(disposables, model, async () => ({
kind: 'sent' as const,
data: {
responseCompletePromise,
responseCreatedPromise,
agent: new class extends mock<IChatAgentData>() { }(),
} as IChatSendRequestData,
}), { claudeEnabled: true });
return {
provider,
cancelRequest: () => {
resolveCreated({ isCanceled: true } as unknown as IChatResponseModel);
resolveComplete();
},
};
}
function waitForSessionAdded(provider: CopilotChatSessionsProvider): Promise<void> {
return new Promise<void>(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', () => {

View File

@@ -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';
/**