Skip to content

Commit

Permalink
Show exceeding attachments
Browse files Browse the repository at this point in the history
  • Loading branch information
benibenj committed Nov 20, 2024
1 parent 4b01d01 commit aec97d9
Show file tree
Hide file tree
Showing 6 changed files with 147 additions and 38 deletions.
75 changes: 73 additions & 2 deletions src/vs/workbench/contrib/chat/browser/chatAttachmentModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js';
import { basename } from '../../../../base/common/resources.js';
import { URI } from '../../../../base/common/uri.js';
import { IRange } from '../../../../editor/common/core/range.js';
import { IChatEditingService } from '../common/chatEditingService.js';
import { IChatRequestVariableEntry } from '../common/chatModel.js';

export class ChatAttachmentModel extends Disposable {
Expand All @@ -16,7 +17,7 @@ export class ChatAttachmentModel extends Disposable {
return Array.from(this._attachments.values());
}

private _onDidChangeContext = this._register(new Emitter<void>());
protected _onDidChangeContext = this._register(new Emitter<void>());
readonly onDidChangeContext = this._onDidChangeContext.event;

get size(): number {
Expand Down Expand Up @@ -52,17 +53,87 @@ export class ChatAttachmentModel extends Disposable {
}

addContext(...attachments: IChatRequestVariableEntry[]) {
let hasAdded = false;

for (const attachment of attachments) {
if (!this._attachments.has(attachment.id)) {
this._attachments.set(attachment.id, attachment);
hasAdded = true;
}
}

this._onDidChangeContext.fire();
if (hasAdded) {
this._onDidChangeContext.fire();
}
}

clearAndSetContext(...attachments: IChatRequestVariableEntry[]) {
this.clear();
this.addContext(...attachments);
}
}

export class EditsAttachmentModel extends ChatAttachmentModel {

private _onFileLimitExceeded = this._register(new Emitter<void>());
readonly onFileLimitExceeded = this._onFileLimitExceeded.event;

private get fileAttachments() {
return this.attachments.filter(attachment => attachment.isFile);
}

private readonly _excludedFileAttachments: IChatRequestVariableEntry[] = [];
get excludedFileAttachments(): IChatRequestVariableEntry[] {
return this._excludedFileAttachments;
}

constructor(
@IChatEditingService private readonly _chatEditingService: IChatEditingService,
) {
super();
}

private isExcludeFileAttachment(fileAttachmentId: string) {
return this._excludedFileAttachments.some(attachment => attachment.id === fileAttachmentId);
}

override addContext(...attachments: IChatRequestVariableEntry[]) {
const currentAttachmentIds = this.getAttachmentIDs();

const fileAttachments = attachments.filter(attachment => attachment.isFile);
const newFileAttachments = fileAttachments.filter(attachment => !currentAttachmentIds.has(attachment.id));
const otherAttachments = attachments.filter(attachment => !attachment.isFile);

const availableFileCount = Math.max(0, this._chatEditingService.editingSessionFileLimit - this.fileAttachments.length);
const fileAttachmentsToBeAdded = newFileAttachments.slice(0, availableFileCount);

if (newFileAttachments.length > availableFileCount) {
const attachmentsExceedingSize = newFileAttachments.slice(availableFileCount).filter(attachment => !this.isExcludeFileAttachment(attachment.id));
this._excludedFileAttachments.push(...attachmentsExceedingSize);
this._onDidChangeContext.fire();
this._onFileLimitExceeded.fire();
}

super.addContext(...otherAttachments, ...fileAttachmentsToBeAdded);
}

override clear(): void {
this._excludedFileAttachments.splice(0, this._excludedFileAttachments.length);
super.clear();
}

override delete(variableEntryId: string) {
const excludedFileIndex = this._excludedFileAttachments.findIndex(attachment => attachment.id === variableEntryId);
if (excludedFileIndex !== -1) {
this._excludedFileAttachments.splice(excludedFileIndex, 1);
}

super.delete(variableEntryId);

if (this.fileAttachments.length < this._chatEditingService.editingSessionFileLimit) {
const availableFileCount = Math.max(0, this._chatEditingService.editingSessionFileLimit - this.fileAttachments.length);
const reAddAttachments = this._excludedFileAttachments.splice(0, availableFileCount);
super.addContext(...reAddAttachments);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export interface IChatReferenceListItem extends IChatContentReference {
title?: string;
description?: string;
state?: WorkingSetEntryState;
excluded?: boolean;
}

export type IChatCollapsibleListItem = IChatReferenceListItem | IChatWarningMessage;
Expand Down Expand Up @@ -378,13 +379,13 @@ class CollapsibleListRenderer implements IListRenderer<IChatCollapsibleListItem,
// Parse a nicer label for GitHub URIs that point at a particular commit + file
const label = uri.path.split('/').slice(1, 3).join('/');
const description = uri.path.split('/').slice(5).join('/');
templateData.label.setResource({ resource: uri, name: label, description }, { icon: Codicon.github, title: data.title });
templateData.label.setResource({ resource: uri, name: label, description }, { icon: Codicon.github, title: data.title, strikethrough: data.excluded });
} else if (uri.scheme === this.productService.urlProtocol && isEqualAuthority(uri.authority, SETTINGS_AUTHORITY)) {
// a nicer label for settings URIs
const settingId = uri.path.substring(1);
templateData.label.setResource({ resource: uri, name: settingId }, { icon: Codicon.settingsGear, title: localize('setting.hover', "Open setting '{0}'", settingId) });
templateData.label.setResource({ resource: uri, name: settingId }, { icon: Codicon.settingsGear, title: localize('setting.hover', "Open setting '{0}'", settingId), strikethrough: data.excluded });
} else if (matchesSomeScheme(uri, Schemas.mailto, Schemas.http, Schemas.https)) {
templateData.label.setResource({ resource: uri, name: uri.toString() }, { icon: icon ?? Codicon.globe, title: data.options?.status?.description ?? data.title ?? uri.toString() });
templateData.label.setResource({ resource: uri, name: uri.toString() }, { icon: icon ?? Codicon.globe, title: data.options?.status?.description ?? data.title ?? uri.toString(), strikethrough: data.excluded });
} else {
if (data.state === WorkingSetEntryState.Transient || data.state === WorkingSetEntryState.Suggested) {
templateData.label.setResource(
Expand All @@ -393,14 +394,15 @@ class CollapsibleListRenderer implements IListRenderer<IChatCollapsibleListItem,
name: basenameOrAuthority(uri),
description: data.description ?? localize('chat.openEditor', 'Open Editor'),
range: 'range' in reference ? reference.range : undefined,
}, { icon, title: data.options?.status?.description ?? data.title });
}, { icon, title: data.options?.status?.description ?? data.title, strikethrough: data.excluded });
} else {
templateData.label.setFile(uri, {
fileKind: FileKind.FILE,
// Should not have this live-updating data on a historical reference
fileDecorations: undefined,
range: 'range' in reference ? reference.range : undefined,
title: data.options?.status?.description ?? data.title
title: data.options?.status?.description ?? data.title,
strikethrough: data.excluded
});
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

import { Codicon } from '../../../../../base/common/codicons.js';
import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js';
import { ResourceSet } from '../../../../../base/common/map.js';
import { basename } from '../../../../../base/common/resources.js';
import { URI } from '../../../../../base/common/uri.js';
import { isCodeEditor } from '../../../../../editor/browser/editorBrowser.js';
Expand Down Expand Up @@ -99,16 +98,9 @@ registerAction2(class RemoveFileFromWorkingSet extends WorkingSetAction {
currentEditingSession.remove(WorkingSetEntryRemovalReason.User, ...uris);

// Remove from chat input part
const resourceSet = new ResourceSet(uris);
const newContext = [];

for (const context of chatWidget.input.attachmentModel.attachments) {
if (!URI.isUri(context.value) || !context.isFile || !resourceSet.has(context.value)) {
newContext.push(context);
}
for (const uri of uris) {
chatWidget.attachmentModel.delete(uri.toString());
}

chatWidget.attachmentModel.clearAndSetContext(...newContext);
}
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,9 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio

// Add the currently active editors to the working set
this._trackCurrentEditorsInWorkingSet();
this._register(this._editorService.onDidActiveEditorChange(() => {
this._register(this._editorService.onDidVisibleEditorsChange(() => {
this._trackCurrentEditorsInWorkingSet();
}));
this._register(this._editorService.onDidCloseEditor((e) => {
this._trackCurrentEditorsInWorkingSet(e);
}));
this._register(autorun(reader => {
const entries = this.entries.read(reader);
entries.forEach(entry => {
Expand All @@ -171,8 +168,6 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio
}

private _trackCurrentEditorsInWorkingSet(e?: IEditorCloseEvent) {
const closedEditor = e?.editor.resource?.toString();

const existingTransientEntries = new ResourceSet();
for (const file of this._workingSet.keys()) {
if (this._workingSet.get(file)?.state === WorkingSetEntryState.Transient) {
Expand All @@ -191,10 +186,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio
}
if (isCodeEditor(activeEditorControl) && activeEditorControl.hasModel()) {
const uri = activeEditorControl.getModel().uri;
if (closedEditor === uri.toString()) {
// The editor group service sees recently closed editors?
// Continue, since we want this to be deleted from the working set
} else if (existingTransientEntries.has(uri)) {
if (existingTransientEntries.has(uri)) {
existingTransientEntries.delete(uri);
} else if (!this._workingSet.has(uri) && !this._removedTransientEntries.has(uri)) {
// Don't add as a transient entry if it's already part of the working set
Expand Down
68 changes: 58 additions & 10 deletions src/vs/workbench/contrib/chat/browser/chatInputPart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js';
import * as aria from '../../../../base/browser/ui/aria/aria.js';
import { Button } from '../../../../base/browser/ui/button/button.js';
import { IHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegate.js';
import { getBaseLayerHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegate2.js';
import { createInstantHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js';
import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js';
import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
import { ProgressBar } from '../../../../base/browser/ui/progressbar/progressbar.js';
import { IAction } from '../../../../base/common/actions.js';
Expand Down Expand Up @@ -84,7 +86,7 @@ import { ILanguageModelChatMetadata, ILanguageModelsService } from '../common/la
import { CancelAction, ChatModelPickerActionId, ChatSubmitSecondaryAgentAction, IChatExecuteActionContext, ChatSubmitAction } from './actions/chatExecuteActions.js';
import { ImplicitContextAttachmentWidget } from './attachments/implicitContextAttachment.js';
import { IChatWidget } from './chat.js';
import { ChatAttachmentModel } from './chatAttachmentModel.js';
import { ChatAttachmentModel, EditsAttachmentModel } from './chatAttachmentModel.js';
import { IDisposableReference } from './chatContentParts/chatCollections.js';
import { CollapsibleListPool, IChatCollapsibleListItem } from './chatContentParts/chatReferencesContentPart.js';
import { ChatDragAndDrop, EditsDragAndDrop } from './chatDragAndDrop.js';
Expand Down Expand Up @@ -223,6 +225,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge

private readonly _chatEditsActionsDisposables = this._register(new DisposableStore());
private readonly _chatEditsDisposables = this._register(new DisposableStore());
private readonly _chatEditsFileLimitHover = this._register(new MutableDisposable<IDisposable>());
private _chatEditsProgress: ProgressBar | undefined;
private _chatEditsListPool: CollapsibleListPool;
private _chatEditList: IDisposableReference<WorkbenchList<IChatCollapsibleListItem>> | undefined;
Expand Down Expand Up @@ -281,7 +284,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
) {
super();

this._attachmentModel = this._register(new ChatAttachmentModel());
if (this.location === ChatAgentLocation.EditingSession) {
this._attachmentModel = this._register(this.instantiationService.createInstance(EditsAttachmentModel));
this.dnd = this._register(this.instantiationService.createInstance(EditsDragAndDrop, this.attachmentModel, styles));
} else {
this._attachmentModel = this._register(this.instantiationService.createInstance(ChatAttachmentModel));
this.dnd = this._register(this.instantiationService.createInstance(ChatDragAndDrop, this.attachmentModel, styles));
}

this.getInputState = (): IChatInputState => {
return {
...getContribsInputState(),
Expand All @@ -304,7 +314,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
}));

this._chatEditsListPool = this._register(this.instantiationService.createInstance(CollapsibleListPool, this._onDidChangeVisibility.event, MenuId.ChatEditingWidgetModifiedFilesToolbar));
this.dnd = this._register(this.instantiationService.createInstance(this.location === ChatAgentLocation.EditingSession ? EditsDragAndDrop : ChatDragAndDrop, this.attachmentModel, styles));

this._hasFileAttachmentContextKey = ChatContextKeys.hasFileAttachments.bindTo(contextKeyService);
}
Expand Down Expand Up @@ -1044,6 +1053,19 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
});
}
}
const excludedEntries: IChatCollapsibleListItem[] = [];
for (const excludedAttachment of (this.attachmentModel as EditsAttachmentModel).excludedFileAttachments) {
if (excludedAttachment.isFile && URI.isUri(excludedAttachment.value) && !seenEntries.has(excludedAttachment.value)) {
excludedEntries.push({
reference: excludedAttachment.value,
state: WorkingSetEntryState.Attached,
kind: 'reference',
excluded: true,
title: localize('chatEditingSession.excludedFile', 'The Working Set file limit has ben reached. {0} is excluded from the Woking Set. Remove other files to make space for {0}.', basename(excludedAttachment.value.path))
});
seenEntries.add(excludedAttachment.value);
}
}
entries.sort((a, b) => {
if (a.kind === 'reference' && b.kind === 'reference') {
if (a.state === b.state || a.state === undefined || b.state === undefined) {
Expand All @@ -1055,14 +1077,18 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
});
let remainingFileEntriesBudget = this.chatEditingService.editingSessionFileLimit;
const overviewRegion = innerContainer.querySelector('.chat-editing-session-overview') as HTMLElement ?? dom.append(innerContainer, $('.chat-editing-session-overview'));
const overviewText = overviewRegion.querySelector('span') ?? dom.append(overviewRegion, $('span'));
overviewText.textContent = localize('chatEditingSession.workingSet', 'Working Set');
const overviewTitle = overviewRegion.querySelector('.working-set-title') as HTMLElement ?? dom.append(overviewRegion, $('.working-set-title'));
const overviewWorkingSet = overviewTitle.querySelector('span') ?? dom.append(overviewTitle, $('span'));
const overviewFileCount = overviewTitle.querySelector('span.working-set-count') ?? dom.append(overviewTitle, $('span.working-set-count'));

overviewWorkingSet.textContent = localize('chatEditingSession.workingSet', 'Working Set');

// Record the number of entries that the user wanted to add to the working set
this._attemptedWorkingSetEntriesCount = entries.length;
this._attemptedWorkingSetEntriesCount = entries.length + excludedEntries.length;

overviewFileCount.textContent = '';
if (entries.length === 1) {
overviewText.textContent += ' ' + localize('chatEditingSession.oneFile', '(1 file)');
overviewFileCount.textContent = ' ' + localize('chatEditingSession.oneFile', '(1 file)');
} else if (entries.length >= remainingFileEntriesBudget) {
// The user tried to attach too many files, we have to drop anything after the limit
const entriesToPreserve: IChatCollapsibleListItem[] = [];
Expand Down Expand Up @@ -1099,7 +1125,28 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
entries = [...entriesToPreserve, ...newEntriesThatFit, ...suggestedFilesThatFit];
}
if (entries.length > 1) {
overviewText.textContent += ' ' + localize('chatEditingSession.manyFiles', '({0} files)', entries.length);
overviewFileCount.textContent = ' ' + localize('chatEditingSession.manyFiles', '({0} files)', entries.length);
}

if (excludedEntries.length > 0) {
overviewFileCount.textContent = ' ' + localize('chatEditingSession.excludedFiles', '({0} files, {1} excluded)', entries.length, excludedEntries.length);
}

const fileLimitReached = remainingFileEntriesBudget <= 0;
overviewFileCount.classList.toggle('file-limit-reached', fileLimitReached);
if (fileLimitReached) {
let title = localize('chatEditingSession.fileLimitReached', 'You have reached the maximum number of files that can be added to the working set.');
title += excludedEntries.length === 1 ? ' ' + localize('chatEditingSession.excludedOneFile', '1 file is excluded from the Working Set.') : '';
title += excludedEntries.length > 1 ? ' ' + localize('chatEditingSession.excludedSomeFiles', '{0} files are excluded from the Working Set.', excludedEntries.length) : '';

this._chatEditsFileLimitHover.value = getBaseLayerHoverDelegate().setupDelayedHover(overviewFileCount as HTMLElement,
{
content: title,
appearance: { showPointer: true, compact: true },
position: { hoverPosition: HoverPosition.ABOVE }
});
} else {
this._chatEditsFileLimitHover.clear();
}

// Clear out the previous actions (if any)
Expand Down Expand Up @@ -1166,12 +1213,13 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
}

const maxItemsShown = 6;
const itemsShown = Math.min(entries.length, maxItemsShown);
const itemsShown = Math.min(entries.length + excludedEntries.length, maxItemsShown);
const height = itemsShown * 22;
const list = this._chatEditList.object;
list.layout(height);
list.getHTMLElement().style.height = `${height}px`;
list.splice(0, list.length, entries);
list.splice(entries.length, 0, excludedEntries);
this._combinedChatEditWorkingSetEntries = coalesce(entries.map((e) => e.kind === 'reference' && URI.isUri(e.reference) ? e.reference : undefined));

const addFilesElement = innerContainer.querySelector('.chat-editing-session-toolbar-actions') as HTMLElement ?? dom.append(innerContainer, $('.chat-editing-session-toolbar-actions'));
Expand All @@ -1183,7 +1231,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
// Disable the button if the entries that are not suggested exceed the budget
button.enabled = remainingFileEntriesBudget > 0;
button.label = localize('chatAddFiles', '{0} Add Files...', '$(add)');
button.setTitle(button.enabled ? localize('addFiles.label', 'Add files to your working set') : localize('addFilesDisabled.label', 'You have reached the maximum number of files that can be added to the working set.'));
button.setTitle(button.enabled ? localize('addFiles.label', 'Add files to your working set') : localize('chatEditingSession.fileLimitReached', 'You have reached the maximum number of files that can be added to the working set.'));
this._chatEditsActionsDisposables.add(button.onDidClick(() => {
this.commandService.executeCommand('workbench.action.chat.editing.attachFiles', { widget: chatWidget });
}));
Expand Down
Loading

0 comments on commit aec97d9

Please sign in to comment.