Skip to content
Merged
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
72 changes: 52 additions & 20 deletions src/vs/sessions/contrib/chat/browser/newChatPermissionPicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export class NewChatPermissionPicker extends Disposable {
readonly onDidChangeLevel: Event<ChatPermissionLevel> = this._onDidChangeLevel.event;

private _currentLevel: ChatPermissionLevel = ChatPermissionLevel.Default;
private _worktreeIsolated = false;
private _triggerElement: HTMLElement | undefined;
private _container: HTMLElement | undefined;
private readonly _renderDisposables = this._register(new DisposableStore());
Expand Down Expand Up @@ -89,13 +90,30 @@ export class NewChatPermissionPicker extends Disposable {
}
}

setWorktreeIsolated(isolated: boolean): void {
if (this._worktreeIsolated === isolated) {
return;
}
this._worktreeIsolated = isolated;
if (isolated) {
this._currentLevel = ChatPermissionLevel.AutoApprove;
this._onDidChangeLevel.fire(this._currentLevel);
} else {
this._currentLevel = ChatPermissionLevel.Default;
this._onDidChangeLevel.fire(this._currentLevel);
}
this._updateTriggerLabel(this._triggerElement);
}

showPicker(): void {
if (!this._triggerElement || this.actionWidgetService.isVisible) {
return;
}

const policyRestricted = this.configurationService.inspect<boolean>(ChatConfiguration.GlobalAutoApprove).policyValue === false;
const isAutopilotEnabled = this.configurationService.getValue<boolean>(ChatConfiguration.AutopilotEnabled) !== false;
const worktreeIsolated = this._worktreeIsolated;
const worktreeTooltip = localize('permissions.worktreeIsolated', "Worktrees are isolated and don't require approvals");

const items: IActionListItem<IPermissionItem>[] = [
{
Expand All @@ -105,11 +123,12 @@ export class NewChatPermissionPicker extends Disposable {
level: ChatPermissionLevel.Default,
label: localize('permissions.default', "Default Approvals"),
icon: Codicon.shield,
checked: this._currentLevel === ChatPermissionLevel.Default,
checked: !worktreeIsolated && this._currentLevel === ChatPermissionLevel.Default,
},
label: localize('permissions.default', "Default Approvals"),
description: localize('permissions.default.subtext', "Copilot uses your configured settings"),
disabled: false,
disabled: worktreeIsolated,
tooltip: worktreeIsolated ? worktreeTooltip : undefined,
},
{
kind: ActionListItemKind.Action,
Expand All @@ -118,10 +137,12 @@ export class NewChatPermissionPicker extends Disposable {
level: ChatPermissionLevel.AutoApprove,
label: localize('permissions.autoApprove', "Bypass Approvals"),
icon: Codicon.warning,
checked: this._currentLevel === ChatPermissionLevel.AutoApprove,
checked: worktreeIsolated || this._currentLevel === ChatPermissionLevel.AutoApprove,
},
label: localize('permissions.autoApprove', "Bypass Approvals"),
description: localize('permissions.autoApprove.subtext', "All tool calls are auto-approved"),
description: worktreeIsolated
? localize('permissions.autoApprove.worktreeSubtext', "Worktrees run in an isolated Git branch")
: localize('permissions.autoApprove.subtext', "All tool calls are auto-approved"),
disabled: policyRestricted,
},
];
Expand All @@ -134,11 +155,12 @@ export class NewChatPermissionPicker extends Disposable {
level: ChatPermissionLevel.Autopilot,
label: localize('permissions.autopilot', "Autopilot (Preview)"),
icon: Codicon.rocket,
checked: this._currentLevel === ChatPermissionLevel.Autopilot,
checked: !worktreeIsolated && this._currentLevel === ChatPermissionLevel.Autopilot,
},
label: localize('permissions.autopilot', "Autopilot (Preview)"),
description: localize('permissions.autopilot.subtext', "Autonomously iterates from start to finish"),
disabled: policyRestricted,
disabled: policyRestricted || worktreeIsolated,
tooltip: worktreeIsolated ? worktreeTooltip : undefined,
});
}

Expand Down Expand Up @@ -167,7 +189,12 @@ export class NewChatPermissionPicker extends Disposable {
}

private async _selectLevel(level: ChatPermissionLevel): Promise<void> {
if (level === ChatPermissionLevel.AutoApprove && !shownWarnings.has(ChatPermissionLevel.AutoApprove)) {
// When worktree isolated, only bypass approvals is allowed
if (this._worktreeIsolated && level !== ChatPermissionLevel.AutoApprove) {
return;
}

if (level === ChatPermissionLevel.AutoApprove && !this._worktreeIsolated && !shownWarnings.has(ChatPermissionLevel.AutoApprove)) {
const result = await this.dialogService.prompt({
type: Severity.Warning,
message: localize('permissions.autoApprove.warning.title', "Enable Bypass Approvals?"),
Expand Down Expand Up @@ -234,19 +261,24 @@ export class NewChatPermissionPicker extends Disposable {
dom.clearNode(trigger);
let icon: ThemeIcon;
let label: string;
switch (this._currentLevel) {
case ChatPermissionLevel.Autopilot:
icon = Codicon.rocket;
label = localize('permissions.autopilot.label', "Autopilot (Preview)");
break;
case ChatPermissionLevel.AutoApprove:
icon = Codicon.warning;
label = localize('permissions.autoApprove.label', "Bypass Approvals");
break;
default:
icon = Codicon.shield;
label = localize('permissions.default.label', "Default Approvals");
break;
if (this._worktreeIsolated) {
icon = Codicon.warning;
label = localize('permissions.autoApprove.label', "Bypass Approvals");
} else {
switch (this._currentLevel) {
case ChatPermissionLevel.Autopilot:
icon = Codicon.rocket;
label = localize('permissions.autopilot.label', "Autopilot (Preview)");
break;
case ChatPermissionLevel.AutoApprove:
icon = Codicon.warning;
label = localize('permissions.autoApprove.label', "Bypass Approvals");
break;
default:
icon = Codicon.shield;
label = localize('permissions.default.label', "Default Approvals");
break;
}
}

dom.append(trigger, renderIcon(icon));
Expand Down
5 changes: 5 additions & 0 deletions src/vs/sessions/contrib/chat/browser/newChatViewPane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
const isLocal = target === AgentSessionProviders.Background;
this._updateIsolationPickerVisibility();
this._permissionPicker.setVisible(isLocal);
this._permissionPicker.setWorktreeIsolated(isLocal && this._isolationModePicker.isolationMode === 'worktree');
this._branchPicker.setVisible(isLocal);
this._syncIndicator.setVisible(isLocal);
this._updateDraftState();
Expand Down Expand Up @@ -244,6 +245,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
this._newSession.value?.setIsolationMode(mode);
this._branchPicker.setVisible(mode === 'worktree');
this._syncIndicator.setVisible(mode === 'worktree');
this._permissionPicker.setWorktreeIsolated(mode === 'worktree');
this._updateDraftState();
this._focusEditor();
}));
Expand Down Expand Up @@ -350,6 +352,9 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget {
const isWorktree = this._isolationModePicker.isolationMode === 'worktree';
this._branchPicker.setVisible(isLocal && isWorktree);
this._syncIndicator.setVisible(isLocal && isWorktree);
if (isLocal) {
this._permissionPicker.setWorktreeIsolated(isWorktree);
}

// Render target buttons & extension pickers
this._renderOptionGroupPickers();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge

private readonly _currentModeObservable: ISettableObservable<IChatMode>;
private readonly _currentPermissionLevel: ISettableObservable<ChatPermissionLevel>;
private readonly _isWorktreeIsolated: ISettableObservable<boolean>;
private _lastIsolationOptionId: string | undefined;
private permissionLevelKey: IContextKey<ChatPermissionLevel>;

public get currentModeKind(): ChatModeKind {
Expand Down Expand Up @@ -551,6 +553,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
this._contextResourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this._onDidChangeVisibility.event }));
this._currentModeObservable = observableValue<IChatMode>('currentMode', this.options.defaultMode ?? ChatMode.Agent);
this._currentPermissionLevel = observableValue<ChatPermissionLevel>('permissionLevel', ChatPermissionLevel.Default);
this._isWorktreeIsolated = observableValue<boolean>('isWorktreeIsolated', false);
this._register(this.editorService.onDidActiveEditorChange(() => {
this._indexOfLastOpenedContext = -1;
this.refreshChatSessionPickers();
Expand All @@ -562,6 +565,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
if (sessionResource && isEqual(sessionResource, e)) {
// Options changed for our current session - refresh pickers
this.refreshChatSessionPickers();
this.updateWorktreeIsolationState(sessionResource);
}
}));

Expand All @@ -572,6 +576,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
const delegateSessionType = this.options.sessionTypePickerDelegate?.getActiveSessionProvider?.();
if (ctx && (getChatSessionType(ctx.chatSessionResource) === chatSessionType) || delegateSessionType === chatSessionType) {
this.refreshChatSessionPickers();
this.updateWorktreeIsolationState(sessionResource);
}
}
}));
Expand All @@ -583,6 +588,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
this.agentSessionTypeKey.set(newSessionType);
this.updateWidgetLockStateFromSessionType(newSessionType);
this.refreshChatSessionPickers();
// Re-evaluate worktree isolation — the new session type may have
// a different isolation option than the previous one.
this.updateWorktreeIsolationState(this._widget?.viewModel?.model.sessionResource);
}));
}

Expand Down Expand Up @@ -823,6 +831,44 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
this.permissionWidget?.refresh();
}

private updateWorktreeIsolationState(sessionResource?: URI): void {
let isolationOptionId: string | undefined;

// Try session-based lookup first (existing session)
if (sessionResource) {
const ctx = this.chatService.getChatSessionFromInternalUri(sessionResource);
if (ctx) {
const isolationOption = this.chatSessionsService.getSessionOption(ctx.chatSessionResource, 'isolation');
isolationOptionId = typeof isolationOption === 'string'
? isolationOption
: isolationOption?.id;
}
}

// Fall back to the last known isolation option value (welcome view / no session yet)
if (isolationOptionId === undefined) {
isolationOptionId = this._lastIsolationOptionId;
}

this.applyWorktreeIsolation(isolationOptionId === 'worktree');
}

private applyWorktreeIsolation(isWorktree: boolean): void {
const wasWorktree = this._isWorktreeIsolated.get();
this._isWorktreeIsolated.set(isWorktree, undefined);
if (isWorktree && !wasWorktree) {
// Switching to worktree: force bypass approvals
this._currentPermissionLevel.set(ChatPermissionLevel.AutoApprove, undefined);
this.permissionLevelKey.set(ChatPermissionLevel.AutoApprove);
this.permissionWidget?.refresh();
} else if (!isWorktree && wasWorktree) {
// Switching away from worktree: reset to default
this._currentPermissionLevel.set(ChatPermissionLevel.Default, undefined);
this.permissionLevelKey.set(ChatPermissionLevel.Default);
this.permissionWidget?.refresh();
}
}

public openSessionTargetPicker(): void {
this.sessionTargetWidget?.show();
}
Expand Down Expand Up @@ -853,6 +899,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
// Clear existing widgets
this.disposeSessionPickerWidgets();

// Reset isolation tracking if the isolation option is no longer visible
if (!visibleGroupIds.has('isolation')) {
this._lastIsolationOptionId = undefined;
}

const widgets: (ChatSessionPickerActionItem | SearchableOptionPickerActionItem)[] = [];
for (const optionGroup of optionGroups) {
if (!visibleGroupIds.has(optionGroup.id)) {
Expand All @@ -862,6 +913,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
const initialItem = this.getCurrentOptionForGroup(optionGroup.id);
const initialState = { group: optionGroup, item: initialItem };

// Track the initial isolation option value for welcome view fallback
if (optionGroup.id === 'isolation' && initialItem) {
this._lastIsolationOptionId = initialItem.id;
}

// Create delegate for this option group
const itemDelegate: IChatSessionPickerDelegate = {
getCurrentOption: () => this.getCurrentOptionForGroup(optionGroup.id),
Expand All @@ -871,6 +927,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
this.updateOptionContextKey(optionGroup.id, option.id);
this.getOrCreateOptionEmitter(optionGroup.id).fire(option);

// Track isolation option changes for worktree isolation state
if (optionGroup.id === 'isolation') {
this._lastIsolationOptionId = option.id;
this.applyWorktreeIsolation(option.id === 'worktree');
}

// Notify session if we have one (not in welcome view before session creation)
const sessionResource = this._widget?.viewModel?.model.sessionResource;
const currentCtx = sessionResource ? this.chatService.getChatSessionFromInternalUri(sessionResource) : undefined;
Expand Down Expand Up @@ -918,6 +980,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
this.permissionWidget?.refresh();
}

// Update worktree isolation state for the new session
const sessionResource = this._widget?.viewModel?.model.sessionResource;
if (sessionResource) {
this.updateWorktreeIsolationState(sessionResource);
} else {
this._isWorktreeIsolated.set(false, undefined);
}

// TODO@roblourens This is for an experiment which will be obsolete in a month or two and can then be removed.
if (chatSessionIsEmpty) {
this._setEmptyModelState();
Expand Down Expand Up @@ -2383,6 +2453,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
this._currentPermissionLevel.set(level, undefined);
this.permissionLevelKey.set(level);
},
isWorktreeIsolated: this._isWorktreeIsolated,
};
return this.permissionWidget = this.instantiationService.createInstance(PermissionPickerActionItem, action, delegate, pickerOptions);
}
Expand Down
Loading
Loading