Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -143,6 +143,7 @@ export class AgenticSessionsViewPane extends ViewPane {
filter: sessionsFilter,
overrideStyles: this.getLocationBasedColors().listOverrideStyles,
disableHover: true,
enableApprovalRow: true,
getHoverPosition: () => this.getSessionHoverPosition(),
trackActiveEditorSession: () => true,
collapseOlderSections: () => true,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { renderAsPlaintext } from '../../../../../base/browser/markdownRenderer.js';
import { Disposable, DisposableResourceMap, IDisposable } from '../../../../../base/common/lifecycle.js';
import { autorun, autorunIterableDelta, IObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js';
import { URI } from '../../../../../base/common/uri.js';
import { migrateLegacyTerminalToolSpecificData } from '../../common/chat.js';
import { IChatModel } from '../../common/model/chatModel.js';
import { IChatService, IChatToolInvocation, ToolConfirmKind } from '../../common/chatService/chatService.js';
import { ILanguageService } from '../../../../../editor/common/languages/language.js';

export interface IAgentSessionApprovalInfo {
readonly label: string;
readonly languageId: string | undefined;
confirm(): void;
}

/**
* Tracks approval state for all live chat sessions. For each session,
* exposes an observable that emits {@link IAgentSessionApprovalInfo}
* when a tool invocation is waiting for user confirmation, or `undefined`
* when no approval is needed.
*/
export class AgentSessionApprovalModel extends Disposable {

private readonly _approvals = new Map<string, ISettableObservable<IAgentSessionApprovalInfo | undefined>>();
private readonly _modelTrackers = this._register(new DisposableResourceMap());

constructor(
@IChatService private readonly _chatService: IChatService,
@ILanguageService private readonly _languageService: ILanguageService,
) {
super();

this._register(autorunIterableDelta(
reader => this._chatService.chatModels.read(reader),
({ addedValues, removedValues }) => {
for (const model of addedValues) {
this._modelTrackers.set(model.sessionResource, this._trackModel(model));
}
for (const model of removedValues) {
this._modelTrackers.deleteAndDispose(model.sessionResource);
this._approvals.get(model.sessionResource.toString())?.set(undefined, undefined);
}
}
));
}

getApproval(sessionResource: URI): IObservable<IAgentSessionApprovalInfo | undefined> {
return this._getOrCreateApproval(sessionResource.toString());
}

private _getOrCreateApproval(key: string): ISettableObservable<IAgentSessionApprovalInfo | undefined> {
let obs = this._approvals.get(key);
if (!obs) {
obs = observableValue<IAgentSessionApprovalInfo | undefined>(`sessionApproval.${key}`, undefined);
this._approvals.set(key, obs);
}
return obs;
}

private _trackModel(model: IChatModel): IDisposable {
const settable = this._getOrCreateApproval(model.sessionResource.toString());

const setIfChanged = (value: IAgentSessionApprovalInfo | undefined) => {
const current = settable.get();
if (current === value) {
return;
}
if (current !== undefined && value !== undefined && current.label === value.label && current.languageId === value.languageId) {
Comment thread
benibenj marked this conversation as resolved.
Resolved
return;
}
settable.set(value, undefined);
};

return autorun(reader => {
const needsInput = model.requestNeedsInput.read(reader);
if (!needsInput) {
setIfChanged(undefined);
return;
}

const lastResponse = model.lastRequest?.response;
if (!lastResponse?.response?.value) {
setIfChanged(undefined);
return;
}

for (const part of lastResponse.response.value) {
if (part.kind !== 'toolInvocation') {
continue;
}
const state = part.state.read(reader);
if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation || state.type === IChatToolInvocation.StateKind.WaitingForPostApproval) {
let label: string;
let languageId: string | undefined;
if (part.toolSpecificData?.kind === 'terminal') {
const terminalData = migrateLegacyTerminalToolSpecificData(part.toolSpecificData);
label = terminalData.presentationOverrides?.commandLine ?? terminalData.commandLine.forDisplay ?? terminalData.commandLine.userEdited ?? terminalData.commandLine.toolEdited ?? terminalData.commandLine.original;
languageId = this._languageService.getLanguageIdByLanguageName(terminalData.presentationOverrides?.language ?? terminalData.language) ?? undefined;
} else if (needsInput.detail) {
label = needsInput.detail;
} else {
const msg = part.invocationMessage;
label = typeof msg === 'string' ? msg : renderAsPlaintext(msg);
}

const confirmState = state;
setIfChanged({
label,
languageId,
confirm: () => confirmState.confirm({ type: ToolConfirmKind.UserAction }),
});
return;
}
}

setIfChanged(undefined);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { IOpenEvent, WorkbenchCompressibleAsyncDataTree } from '../../../../../p
import { $, append, EventHelper } from '../../../../../base/browser/dom.js';
import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, IMarshalledAgentSessionContext, isAgentSession, isAgentSessionSection } from './agentSessionsModel.js';
import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionsSorter, IAgentSessionsFilter, IAgentSessionsSorterOptions } from './agentSessionsViewer.js';
import { AgentSessionApprovalModel } from './agentSessionApprovalModel.js';
import { FuzzyScore } from '../../../../../base/common/filters.js';
import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js';
import { IChatSessionsService } from '../../common/chatSessionsService.js';
Expand Down Expand Up @@ -41,6 +42,7 @@ export interface IAgentSessionsControlOptions extends IAgentSessionsSorterOption
readonly filter: IAgentSessionsFilter;
readonly source: string;
readonly disableHover?: boolean;
readonly enableApprovalRow?: boolean;

getHoverPosition(): HoverPosition;
trackActiveEditorSession(): boolean;
Expand Down Expand Up @@ -163,13 +165,15 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo
};

const sorter = new AgentSessionsSorter(this.options);
const approvalModel = this.options.enableApprovalRow ? this._register(this.instantiationService.createInstance(AgentSessionApprovalModel)) : undefined;
const sessionRenderer = this._register(this.instantiationService.createInstance(AgentSessionRenderer, this.options, approvalModel));
const list = this.sessionsList = this._register(this.instantiationService.createInstance(WorkbenchCompressibleAsyncDataTree,
'AgentSessionsView',
this.sessionsContainer,
new AgentSessionsListDelegate(),
new AgentSessionsListDelegate(approvalModel),
new AgentSessionsCompressionDelegate(),
[
this._register(this.instantiationService.createInstance(AgentSessionRenderer, this.options)),
sessionRenderer,
this.instantiationService.createInstance(AgentSessionSectionRenderer),
],
new AgentSessionsDataSource(this.options.filter, sorter),
Expand All @@ -191,6 +195,12 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo

ChatContextKeys.agentSessionsViewerFocused.bindTo(list.contextKeyService);

this._register(sessionRenderer.onDidChangeItemHeight(session => {
if (list.hasNode(session)) {
list.updateElementHeight(session, undefined);
}
}));

const model = this.agentSessionsService.model;

this._register(this.options.filter.onDidChange(async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,18 @@ import { MenuId } from '../../../../../platform/actions/common/actions.js';
import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';
import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js';
import { Event } from '../../../../../base/common/event.js';
import { Emitter, Event } from '../../../../../base/common/event.js';
import { renderAsPlaintext } from '../../../../../base/browser/markdownRenderer.js';
import { MarkdownString, IMarkdownString } from '../../../../../base/common/htmlContent.js';
import { AgentSessionHoverWidget } from './agentSessionHoverWidget.js';
import { AgentSessionProviders, getAgentSessionTime } from './agentSessions.js';
import { AgentSessionsGrouping } from './agentSessionsFilter.js';
import { autorun } from '../../../../../base/common/observable.js';
import { Button } from '../../../../../base/browser/ui/button/button.js';
import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js';
import { AgentSessionApprovalModel } from './agentSessionApprovalModel.js';
import { BugIndicatingError } from '../../../../../base/common/errors.js';


export type AgentSessionListItem = IAgentSession | IAgentSessionSection;

Expand Down Expand Up @@ -70,6 +76,11 @@ interface IAgentSessionItemTemplate {
readonly separator: HTMLElement;
readonly description: HTMLElement;

// Approval row
readonly approvalRow: HTMLElement;
readonly approvalLabel: HTMLElement;
readonly approvalButtonContainer: HTMLElement;

readonly contextKeyService: IContextKeyService;
readonly elementDisposable: DisposableStore;
readonly disposables: IDisposable;
Expand All @@ -84,12 +95,18 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre

static readonly TEMPLATE_ID = 'agent-session';

static readonly APPROVAL_ROW_HEIGHT = 40;

readonly templateId = AgentSessionRenderer.TEMPLATE_ID;

private readonly sessionHover = this._register(new MutableDisposable<AgentSessionHoverWidget>());

private readonly _onDidChangeItemHeight = this._register(new Emitter<IAgentSession>());
readonly onDidChangeItemHeight: Event<IAgentSession> = this._onDidChangeItemHeight.event;

constructor(
private readonly options: IAgentSessionRendererOptions,
private readonly _approvalModel: AgentSessionApprovalModel | undefined,
@IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService,
@IProductService private readonly productService: IProductService,
@IHoverService private readonly hoverService: IHoverService,
Expand Down Expand Up @@ -129,6 +146,10 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre
h('span.agent-session-status-time@statusTime')
]),
]),
]),
h('div.agent-session-approval-row@approvalRow', [
h('span.agent-session-approval-label@approvalLabel'),
h('div.agent-session-approval-button@approvalButtonContainer'),
])
])
]
Expand Down Expand Up @@ -156,6 +177,9 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre
statusContainer: elements.statusContainer,
statusProviderIcon: elements.statusProviderIcon,
statusTime: elements.statusTime,
approvalRow: elements.approvalRow,
approvalLabel: elements.approvalLabel,
approvalButtonContainer: elements.approvalButtonContainer,
contextKeyService,
elementDisposable,
disposables
Expand Down Expand Up @@ -229,6 +253,11 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre

// Hover
this.renderHover(session, template);

// Approval row
if (this._approvalModel) {
this.renderApprovalRow(session, template);
}
}

private renderBadge(session: ITreeNode<IAgentSession, FuzzyScore>, template: IAgentSessionItemTemplate): boolean {
Expand Down Expand Up @@ -396,6 +425,55 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre
};
}

private renderApprovalRow(session: ITreeNode<IAgentSession, FuzzyScore>, template: IAgentSessionItemTemplate): void {
if (this._approvalModel === undefined) {
throw new BugIndicatingError('Approval model is required to render approval row');
}

const approvalModel = this._approvalModel;
// Initialize from current model state to avoid unnecessary height changes on first render
const initialInfo = approvalModel.getApproval(session.element.resource).get();
let wasVisible = !!initialInfo;
template.approvalRow.classList.toggle('visible', wasVisible);

const buttonStore = template.elementDisposable.add(new DisposableStore());

template.elementDisposable.add(autorun(reader => {
buttonStore.clear();

const info = approvalModel.getApproval(session.element.resource).read(reader);
const visible = !!info;

template.approvalRow.classList.toggle('visible', visible);

if (info) {
// Render as a syntax-highlighted code block
const codeblockContent = new MarkdownString().appendCodeblock(info.languageId ?? 'json', info.label);
Comment thread
benibenj marked this conversation as resolved.
Resolved
this.renderMarkdownOrText(codeblockContent, template.approvalLabel, buttonStore);

// Hover with full content as a code block
buttonStore.add(this.hoverService.setupDelayedHover(template.approvalLabel, {
content: codeblockContent,
style: HoverStyle.Pointer,
position: { hoverPosition: HoverPosition.BELOW },
Comment thread
benibenj marked this conversation as resolved.
Resolved
}));

template.approvalButtonContainer.textContent = '';
const button = buttonStore.add(new Button(template.approvalButtonContainer, {
title: localize('allowActionOnce', "Allow once"),
...defaultButtonStyles
}));
button.label = localize('allowAction', "Allow");
buttonStore.add(button.onDidClick(() => info.confirm()));
}

if (wasVisible !== visible) {
wasVisible = visible;
this._onDidChangeItemHeight.fire(session.element);
}
}));
}

renderCompressedElements(node: ITreeNode<ICompressedTreeNode<IAgentSession>, FuzzyScore>, index: number, templateData: IAgentSessionItemTemplate, details?: ITreeElementRenderDetails): void {
throw new Error('Should never happen since session is incompressible');
}
Expand Down Expand Up @@ -509,12 +587,22 @@ export class AgentSessionsListDelegate implements IListVirtualDelegate<AgentSess
static readonly ITEM_HEIGHT = 54;
static readonly SECTION_HEIGHT = 26;

constructor(private readonly _approvalModel?: AgentSessionApprovalModel) { }

getHeight(element: AgentSessionListItem): number {
if (isAgentSessionSection(element)) {
return AgentSessionsListDelegate.SECTION_HEIGHT;
}

return AgentSessionsListDelegate.ITEM_HEIGHT;
let height = AgentSessionsListDelegate.ITEM_HEIGHT;
if (this._approvalModel?.getApproval(element.resource).get()) {
height += AgentSessionRenderer.APPROVAL_ROW_HEIGHT;
}
return height;
}

hasDynamicHeight(element: AgentSessionListItem): boolean {
return isAgentSession(element);
Comment thread
benibenj marked this conversation as resolved.
Resolved
}

getTemplateId(element: AgentSessionListItem): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,54 @@
}
}
}
.agent-session-approval-row {
display: none;
align-items: center;
gap: 8px;
margin-top: 4px;
padding: 4px 8px;
box-sizing: border-box;
height: 36px; /* 36px + 4px margin-top = APPROVAL_ROW_HEIGHT (40px) */
border: 1px solid var(--vscode-contrastBorder, var(--vscode-widget-border, transparent));
border-radius: 4px;
background-color: var(--vscode-editor-background);
Comment thread
benibenj marked this conversation as resolved.
Resolved
color: var(--vscode-editor-foreground);

&.visible {
display: flex;
}

.agent-session-approval-label {
flex: 1;
overflow: hidden;
min-width: 0;

& > .rendered-markdown,
& > .rendered-markdown > .code,
& > .rendered-markdown > .code > span {
display: block;
overflow: hidden;
}

.monaco-tokenized-source {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 12px;
}
}

.agent-session-approval-button {
flex-shrink: 0;

.monaco-button {
padding: 2px 10px;
font-size: 12px;
white-space: nowrap;
}
}
}
}

.agent-session-section {
Expand Down
Loading
Loading