mirror of
https://github.com/microsoft/vscode.git
synced 2026-07-10 16:58:55 -05:00
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:
committed by
GitHub
parent
cf0709667e
commit
8f796b4d8f
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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."),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user