Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1610,21 +1610,16 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions

// -- Session Lifecycle --

private _currentNewSession: NewSession | undefined;
private readonly _currentNewSession = this._register(new MutableDisposable<NewSession>());

getSession(sessionId: string): ICopilotChatSession | undefined {
if (this._currentNewSession?.id === sessionId) {
return this._currentNewSession;
if (this._currentNewSession.value?.id === sessionId) {
return this._currentNewSession.value;
}
return this._findChatSession(sessionId);
}

createNewSession(workspaceUri: URI, sessionTypeId: string): ISession {
if (this._currentNewSession) {
this._currentNewSession.dispose();
this._currentNewSession = undefined;
}

const workspace = this.resolveWorkspace(workspaceUri);
if (!workspace) {
throw new Error(`Cannot resolve workspace for URI: ${workspaceUri.toString()}`);
Expand All @@ -1636,20 +1631,21 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions
}
const resource = URI.from({ scheme: AgentSessionProviders.Cloud, path: `/untitled-${generateUuid()}` });
const session = this.instantiationService.createInstance(RemoteNewSession, resource, workspace, AgentSessionProviders.Cloud, this.id);
this._currentNewSession = session;
this._currentNewSession.value = session;
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;
this._currentNewSession.value = session;
return this._chatToSession(session);
}

if (sessionTypeId === LocalSessionType.id) {
const session = this.instantiationService.createInstance(LocalNewSession, workspace, this.id);
this._currentNewSession = session;
session.setPermissionLevel(this._defaultPermissionLevel());
this._currentNewSession.value = session;
return this._chatToSession(session);
}

Expand All @@ -1658,13 +1654,28 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions
}
const resource = URI.from({ scheme: AgentSessionProviders.Background, path: `/untitled-${generateUuid()}` });
const session = this.instantiationService.createInstance(CopilotCLISession, resource, workspace, this.id);
this._currentNewSession = session;
session.setPermissionLevel(this._defaultPermissionLevel());
this._currentNewSession.value = session;
return this._chatToSession(session);
}

/**
* Resolves the initial permission level for a brand-new session from
* `chat.permissions.default`, clamped to `Default` when enterprise policy
* disables global auto-approval.
*/
private _defaultPermissionLevel(): ChatPermissionLevel {
const policyRestricted = this.configurationService.inspect<boolean>(ChatConfiguration.GlobalAutoApprove).policyValue === false;
if (policyRestricted) {
return ChatPermissionLevel.Default;
}
const level = this.configurationService.getValue<string>(ChatConfiguration.DefaultPermissionLevel);
return isChatPermissionLevel(level) ? level : ChatPermissionLevel.Default;
}
Comment thread
justschen marked this conversation as resolved.
Comment thread
justschen marked this conversation as resolved.

setModel(sessionId: string, modelId: string): void {
if (this._currentNewSession?.id === sessionId) {
this._currentNewSession.setModelId(modelId);
if (this._currentNewSession.value?.id === sessionId) {
this._currentNewSession.value.setModelId(modelId);
}
}

Expand Down Expand Up @@ -1802,9 +1813,8 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions
const key = chat.resource.toString();
this._sessionCache.delete(key);
this._invalidateGroupingCaches();
if (this._currentNewSession?.id === chatId) {
this._currentNewSession.dispose();
this._currentNewSession = undefined;
if (this._currentNewSession.value?.id === chatId) {
this._currentNewSession.clear();
}
}
this._sessionGroupCache.delete(sessionId);
Expand Down Expand Up @@ -1836,7 +1846,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions

async sendAndCreateChat(sessionId: string, options: ISendRequestOptions): Promise<ISession> {
// Determine if this is the first chat or a subsequent chat
const session = this._currentNewSession;
const session = this._currentNewSession.value;
if (session && session.id === sessionId) {
// First chat — use the existing new-session flow
return this._sendFirstChat(session, options);
Expand Down Expand Up @@ -1984,8 +1994,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions

// Remove the temp from the cache (the adapter now owns the committed key)
this._sessionCache.delete(key);
this._currentNewSession = undefined;
session.dispose();
this._currentNewSession.clear();

const committedSession = this._chatToSession(committedChat);

Expand All @@ -1995,7 +2004,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions

return committedSession;
} catch (error) {
this._currentNewSession = undefined;
this._currentNewSession.clearAndLeak();

if (error instanceof CancellationError) {
// Session was stopped before the agent created a worktree.
Expand Down Expand Up @@ -2065,7 +2074,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions
const key = session.resource.toString();
this._sessionCache.set(key, session);
this._invalidateGroupingCaches();
this._currentNewSession = undefined;
this._currentNewSession.clearAndLeak();
const newSession = this._chatToSession(session);
this._onDidChangeSessions.fire({ added: [newSession], removed: [], changed: [] });

Expand Down Expand Up @@ -2155,16 +2164,15 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions

// Clean up temp session and replace with the real adapter
this._sessionCache.delete(tempKey);
this._currentNewSession = undefined;
session.dispose();
this._currentNewSession.clear();

const committedSession = this._chatToSession(committedChat);
this._sessionGroupCache.delete(session.id);
this._onDidReplaceSession.fire({ from: tempSession, to: committedSession });

return committedSession;
} catch (error) {
this._currentNewSession = undefined;
this._currentNewSession.clearAndLeak();

if (error instanceof CancellationError) {
// Keep the temp session visible so the user can review
Expand Down Expand Up @@ -2225,8 +2233,9 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions
private async _sendSubsequentChat(sessionId: string, options: ISendRequestOptions): Promise<ISession> {
// Reuse a chat that was pre-created by addChat(), otherwise create one
let newChatSession: CopilotCLISession;
if (this._currentNewSession && this._getGroupIdForChat(this._currentNewSession) === sessionId) {
newChatSession = this._currentNewSession as CopilotCLISession;
const current = this._currentNewSession.value;
if (current && this._getGroupIdForChat(current) === sessionId) {
newChatSession = current as CopilotCLISession;
} else {
newChatSession = this._createNewSessionFrom(sessionId);
newChatSession.setTitle(localize('new chat', "New Chat"));
Expand Down Expand Up @@ -2310,8 +2319,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions
// Clean up temp
this._sessionCache.delete(key);
this._invalidateGroupingCaches();
this._currentNewSession = undefined;
newChatSession.dispose();
this._currentNewSession.clear();

// Invalidate the session group cache so it rebuilds with the committed chat
this._sessionGroupCache.delete(sessionId);
Expand All @@ -2321,7 +2329,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions

return updatedSession;
} catch (error) {
this._currentNewSession = undefined;
this._currentNewSession.clearAndLeak();

if (error instanceof CancellationError) {
// Cancelled before commit — keep the chat in the group so the
Expand Down Expand Up @@ -2380,11 +2388,6 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions
throw new Error('Workspace has no repository');
}

if (this._currentNewSession) {
this._currentNewSession.dispose();
this._currentNewSession = undefined;
}

const newWorkspace = this.resolveWorkspace(repository.workingDirectory || repository.uri);
if (!newWorkspace) {
throw new Error(`Cannot resolve workspace for URI: ${(repository.workingDirectory || repository.uri).toString()}`);
Expand All @@ -2393,10 +2396,8 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions
const session = this.instantiationService.createInstance(CopilotCLISession, resource, newWorkspace, this.id);
session.setIsolationMode('workspace');
session.setOption(PARENT_SESSION_OPTION_ID, chat.resource.path.slice(1));
const level = this.configurationService.getValue<string>(ChatConfiguration.DefaultPermissionLevel);
const permissionLevel = isChatPermissionLevel(level) ? level : ChatPermissionLevel.Default;
session.setPermissionLevel(permissionLevel);
this._currentNewSession = session;
session.setPermissionLevel(this._defaultPermissionLevel());
this._currentNewSession.value = session;
return session;
}

Expand Down Expand Up @@ -2680,8 +2681,8 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions
this._sessionCache.delete(key);
this._invalidateGroupingCaches();
this._sessionGroupCache.delete(chatSession.id);
if (this._currentNewSession?.id === chatSession.id) {
this._currentNewSession = undefined;
if (this._currentNewSession.value?.id === chatSession.id) {
this._currentNewSession.clearAndLeak();
}
const removedSession = this._chatToSession(chatSession);
this._sessionGroupCache.delete(chatSession.id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Gesture, EventType as TouchEventType } from '../../../../base/browser/t
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 { autorun, IObservable } from '../../../../base/common/observable.js';
import { autorun, derived, IObservable } from '../../../../base/common/observable.js';
import { ThemeIcon } from '../../../../base/common/themables.js';
import { URI } from '../../../../base/common/uri.js';
import { localize } from '../../../../nls.js';
Expand Down Expand Up @@ -42,7 +42,7 @@ export interface IPermissionPickerDelegate {
* omitted, the picker manages its own internal state and starts at
* {@link ChatPermissionLevel.Default}.
*/
readonly currentPermissionLevel?: IObservable<ChatPermissionLevel>;
readonly currentPermissionLevel?: IObservable<ChatPermissionLevel | undefined>;

/**
* If provided, the picker hides itself when this is `false`. Used by
Expand Down Expand Up @@ -121,7 +121,11 @@ export class PermissionPicker extends Disposable {
const currentPermissionLevel = this._delegate.currentPermissionLevel;
if (currentPermissionLevel) {
this._renderDisposables.add(autorun(reader => {
this._currentLevel = currentPermissionLevel.read(reader);
const level = currentPermissionLevel.read(reader);
if (level === undefined) {
return;
}
this._currentLevel = level;
this._updateTriggerLabel(trigger);
}));
}
Expand Down Expand Up @@ -283,19 +287,33 @@ export class PermissionPicker extends Disposable {

/**
* Default-Copilot {@link IPermissionPickerDelegate}: writes the user's chosen
* level back to the active {@link CopilotChatSessionsProvider} session.
*
* Does not provide `currentPermissionLevel` or `isApplicable`, so the picker
* manages its own state and is always visible (visibility is gated at the menu
* contribution level via `when` clauses).
* level back to the active {@link CopilotChatSessionsProvider} session, and
* exposes that session's `permissionLevel` observable so the picker's
* trigger label tracks the session's current level rather than resetting to
* the configured default on every re-render.
*/
export class CopilotPermissionPickerDelegate extends Disposable implements IPermissionPickerDelegate {

readonly currentPermissionLevel: IObservable<ChatPermissionLevel | undefined>;

constructor(
@ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService,
@ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService,
@IChatSessionsService private readonly _chatSessionsService: IChatSessionsService,
) {
super();

this.currentPermissionLevel = derived(this, reader => {
const session = this._sessionsManagementService.activeSession.read(reader);
if (!session) {
return undefined;
}
const provider = this._sessionsProvidersService.getProvider(session.providerId);
if (!(provider instanceof CopilotChatSessionsProvider)) {
return undefined;
}
return provider.getSession(session.sessionId)?.permissionLevel.read(reader);
});
}

Comment thread
justschen marked this conversation as resolved.
setPermissionLevel(level: ChatPermissionLevel): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
import { ConfigurationTarget, IConfigurationService, IConfigurationValue } 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';
Expand All @@ -31,6 +31,7 @@ import { IChatAgentData } from '../../../../../workbench/contrib/chat/common/par
import { IGitService } from '../../../../../workbench/contrib/git/common/gitService.js';
import { ISessionChangeEvent } from '../../../../services/sessions/common/sessionsProvider.js';
import { ClaudeCodeSessionType, CopilotCLISessionType, GITHUB_REMOTE_FILE_SCHEME, SessionStatus } from '../../../../services/sessions/common/session.js';
import { ChatConfiguration, ChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js';
import { CLAUDE_CODE_ENABLED_SETTING, CopilotChatSessionsProvider, COPILOT_PROVIDER_ID } from '../../browser/copilotChatSessionsProvider.js';
import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js';
import { ILabelService } from '../../../../../platform/label/common/label.js';
Expand Down Expand Up @@ -202,11 +203,11 @@ function createProviderForSendTests(
disposables: DisposableStore,
model: MockAgentSessionsModel,
sendRequest: () => Promise<ChatSendResult>,
opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }>; claudeEnabled?: boolean; createNewChatSessionItem?: IChatSessionsService['createNewChatSessionItem'] },
opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }>; claudeEnabled?: boolean; createNewChatSessionItem?: IChatSessionsService['createNewChatSessionItem']; configurationService?: TestConfigurationService },
): CopilotChatSessionsProvider {
const instantiationService = disposables.add(new TestInstantiationService());

const configService = new TestConfigurationService();
const configService = opts?.configurationService ?? new TestConfigurationService();
configService.setUserConfiguration('sessions.github.copilot.multiChatSessions', true);
configService.setUserConfiguration(CLAUDE_CODE_ENABLED_SETTING, opts?.claudeEnabled ?? true);

Expand Down Expand Up @@ -1308,4 +1309,56 @@ suite('CopilotChatSessionsProvider', () => {
await provider.deleteSession(sessionId);
});
});

// ---- New session default permission level seeding -----------------------

suite('new session default permission level', () => {
const workspace = URI.file('/test/repo');

function makeConfig(opts: { defaultLevel?: ChatPermissionLevel; policyRestricted?: boolean }): TestConfigurationService {
const config = new class extends TestConfigurationService {
override inspect<T>(key: string): IConfigurationValue<T> {
const base = super.inspect<T>(key);
if (opts.policyRestricted && key === ChatConfiguration.GlobalAutoApprove) {
return { ...base, policyValue: false as unknown as T };
}
return base;
}
}();
if (opts.defaultLevel) {
config.setUserConfiguration(ChatConfiguration.DefaultPermissionLevel, opts.defaultLevel);
}
return config;
}

test('CLI session seeds permission level from chat.permissions.default', () => {
const configurationService = makeConfig({ defaultLevel: ChatPermissionLevel.Autopilot });
const provider = createProviderForSendTests(disposables, model, () => new Promise(() => { }), { configurationService });

const sessionInfo = provider.createNewSession(workspace, CopilotCLISessionType.id);
const session = provider.getSession(sessionInfo.sessionId);

assert.strictEqual(session?.permissionLevel.get(), ChatPermissionLevel.Autopilot);
});
Comment thread
justschen marked this conversation as resolved.

test('clamps to Default when chat.tools.global.autoApprove policy is false', () => {
const configurationService = makeConfig({ defaultLevel: ChatPermissionLevel.Autopilot, policyRestricted: true });
const provider = createProviderForSendTests(disposables, model, () => new Promise(() => { }), { configurationService });

const sessionInfo = provider.createNewSession(workspace, CopilotCLISessionType.id);
const session = provider.getSession(sessionInfo.sessionId);

assert.strictEqual(session?.permissionLevel.get(), ChatPermissionLevel.Default);
});
Comment thread
justschen marked this conversation as resolved.

test('falls back to Default when chat.permissions.default is unset', () => {
const configurationService = makeConfig({});
const provider = createProviderForSendTests(disposables, model, () => new Promise(() => { }), { configurationService });

const sessionInfo = provider.createNewSession(workspace, CopilotCLISessionType.id);
const session = provider.getSession(sessionInfo.sessionId);

assert.strictEqual(session?.permissionLevel.get(), ChatPermissionLevel.Default);
});
Comment thread
justschen marked this conversation as resolved.
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -1035,7 +1035,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
}

private _setEmptyModelState() {
if (this._inputModel?.state?.get()?.permissionLevel !== this.getDefaultPermissionLevel()) {
const currentLevel = this._inputModel?.state?.get()?.permissionLevel;
if (currentLevel === undefined || !isChatPermissionLevel(currentLevel)) {
this.setPermissionLevel(this.getDefaultPermissionLevel());
}

Expand Down
Loading