Skip to content

Commit ce8e5bb

Browse files
authored
Show exceeding attachments in chat (microsoft#234308)
Show exceeding attachments
1 parent ad81868 commit ce8e5bb

File tree

6 files changed

+147
-38
lines changed

6 files changed

+147
-38
lines changed

src/vs/workbench/contrib/chat/browser/chatAttachmentModel.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js';
88
import { basename } from '../../../../base/common/resources.js';
99
import { URI } from '../../../../base/common/uri.js';
1010
import { IRange } from '../../../../editor/common/core/range.js';
11+
import { IChatEditingService } from '../common/chatEditingService.js';
1112
import { IChatRequestVariableEntry } from '../common/chatModel.js';
1213

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

19-
private _onDidChangeContext = this._register(new Emitter<void>());
20+
protected _onDidChangeContext = this._register(new Emitter<void>());
2021
readonly onDidChangeContext = this._onDidChangeContext.event;
2122

2223
get size(): number {
@@ -52,17 +53,87 @@ export class ChatAttachmentModel extends Disposable {
5253
}
5354

5455
addContext(...attachments: IChatRequestVariableEntry[]) {
56+
let hasAdded = false;
57+
5558
for (const attachment of attachments) {
5659
if (!this._attachments.has(attachment.id)) {
5760
this._attachments.set(attachment.id, attachment);
61+
hasAdded = true;
5862
}
5963
}
6064

61-
this._onDidChangeContext.fire();
65+
if (hasAdded) {
66+
this._onDidChangeContext.fire();
67+
}
6268
}
6369

6470
clearAndSetContext(...attachments: IChatRequestVariableEntry[]) {
6571
this.clear();
6672
this.addContext(...attachments);
6773
}
6874
}
75+
76+
export class EditsAttachmentModel extends ChatAttachmentModel {
77+
78+
private _onFileLimitExceeded = this._register(new Emitter<void>());
79+
readonly onFileLimitExceeded = this._onFileLimitExceeded.event;
80+
81+
private get fileAttachments() {
82+
return this.attachments.filter(attachment => attachment.isFile);
83+
}
84+
85+
private readonly _excludedFileAttachments: IChatRequestVariableEntry[] = [];
86+
get excludedFileAttachments(): IChatRequestVariableEntry[] {
87+
return this._excludedFileAttachments;
88+
}
89+
90+
constructor(
91+
@IChatEditingService private readonly _chatEditingService: IChatEditingService,
92+
) {
93+
super();
94+
}
95+
96+
private isExcludeFileAttachment(fileAttachmentId: string) {
97+
return this._excludedFileAttachments.some(attachment => attachment.id === fileAttachmentId);
98+
}
99+
100+
override addContext(...attachments: IChatRequestVariableEntry[]) {
101+
const currentAttachmentIds = this.getAttachmentIDs();
102+
103+
const fileAttachments = attachments.filter(attachment => attachment.isFile);
104+
const newFileAttachments = fileAttachments.filter(attachment => !currentAttachmentIds.has(attachment.id));
105+
const otherAttachments = attachments.filter(attachment => !attachment.isFile);
106+
107+
const availableFileCount = Math.max(0, this._chatEditingService.editingSessionFileLimit - this.fileAttachments.length);
108+
const fileAttachmentsToBeAdded = newFileAttachments.slice(0, availableFileCount);
109+
110+
if (newFileAttachments.length > availableFileCount) {
111+
const attachmentsExceedingSize = newFileAttachments.slice(availableFileCount).filter(attachment => !this.isExcludeFileAttachment(attachment.id));
112+
this._excludedFileAttachments.push(...attachmentsExceedingSize);
113+
this._onDidChangeContext.fire();
114+
this._onFileLimitExceeded.fire();
115+
}
116+
117+
super.addContext(...otherAttachments, ...fileAttachmentsToBeAdded);
118+
}
119+
120+
override clear(): void {
121+
this._excludedFileAttachments.splice(0, this._excludedFileAttachments.length);
122+
super.clear();
123+
}
124+
125+
override delete(variableEntryId: string) {
126+
const excludedFileIndex = this._excludedFileAttachments.findIndex(attachment => attachment.id === variableEntryId);
127+
if (excludedFileIndex !== -1) {
128+
this._excludedFileAttachments.splice(excludedFileIndex, 1);
129+
}
130+
131+
super.delete(variableEntryId);
132+
133+
if (this.fileAttachments.length < this._chatEditingService.editingSessionFileLimit) {
134+
const availableFileCount = Math.max(0, this._chatEditingService.editingSessionFileLimit - this.fileAttachments.length);
135+
const reAddAttachments = this._excludedFileAttachments.splice(0, availableFileCount);
136+
super.addContext(...reAddAttachments);
137+
}
138+
}
139+
}

src/vs/workbench/contrib/chat/browser/chatContentParts/chatReferencesContentPart.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export interface IChatReferenceListItem extends IChatContentReference {
5050
title?: string;
5151
description?: string;
5252
state?: WorkingSetEntryState;
53+
excluded?: boolean;
5354
}
5455

5556
export type IChatCollapsibleListItem = IChatReferenceListItem | IChatWarningMessage;
@@ -378,13 +379,13 @@ class CollapsibleListRenderer implements IListRenderer<IChatCollapsibleListItem,
378379
// Parse a nicer label for GitHub URIs that point at a particular commit + file
379380
const label = uri.path.split('/').slice(1, 3).join('/');
380381
const description = uri.path.split('/').slice(5).join('/');
381-
templateData.label.setResource({ resource: uri, name: label, description }, { icon: Codicon.github, title: data.title });
382+
templateData.label.setResource({ resource: uri, name: label, description }, { icon: Codicon.github, title: data.title, strikethrough: data.excluded });
382383
} else if (uri.scheme === this.productService.urlProtocol && isEqualAuthority(uri.authority, SETTINGS_AUTHORITY)) {
383384
// a nicer label for settings URIs
384385
const settingId = uri.path.substring(1);
385-
templateData.label.setResource({ resource: uri, name: settingId }, { icon: Codicon.settingsGear, title: localize('setting.hover', "Open setting '{0}'", settingId) });
386+
templateData.label.setResource({ resource: uri, name: settingId }, { icon: Codicon.settingsGear, title: localize('setting.hover', "Open setting '{0}'", settingId), strikethrough: data.excluded });
386387
} else if (matchesSomeScheme(uri, Schemas.mailto, Schemas.http, Schemas.https)) {
387-
templateData.label.setResource({ resource: uri, name: uri.toString() }, { icon: icon ?? Codicon.globe, title: data.options?.status?.description ?? data.title ?? uri.toString() });
388+
templateData.label.setResource({ resource: uri, name: uri.toString() }, { icon: icon ?? Codicon.globe, title: data.options?.status?.description ?? data.title ?? uri.toString(), strikethrough: data.excluded });
388389
} else {
389390
if (data.state === WorkingSetEntryState.Transient || data.state === WorkingSetEntryState.Suggested) {
390391
templateData.label.setResource(
@@ -393,14 +394,15 @@ class CollapsibleListRenderer implements IListRenderer<IChatCollapsibleListItem,
393394
name: basenameOrAuthority(uri),
394395
description: data.description ?? localize('chat.openEditor', 'Open Editor'),
395396
range: 'range' in reference ? reference.range : undefined,
396-
}, { icon, title: data.options?.status?.description ?? data.title });
397+
}, { icon, title: data.options?.status?.description ?? data.title, strikethrough: data.excluded });
397398
} else {
398399
templateData.label.setFile(uri, {
399400
fileKind: FileKind.FILE,
400401
// Should not have this live-updating data on a historical reference
401402
fileDecorations: undefined,
402403
range: 'range' in reference ? reference.range : undefined,
403-
title: data.options?.status?.description ?? data.title
404+
title: data.options?.status?.description ?? data.title,
405+
strikethrough: data.excluded
404406
});
405407
}
406408
}

src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

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

101100
// Remove from chat input part
102-
const resourceSet = new ResourceSet(uris);
103-
const newContext = [];
104-
105-
for (const context of chatWidget.input.attachmentModel.attachments) {
106-
if (!URI.isUri(context.value) || !context.isFile || !resourceSet.has(context.value)) {
107-
newContext.push(context);
108-
}
101+
for (const uri of uris) {
102+
chatWidget.attachmentModel.delete(uri.toString());
109103
}
110-
111-
chatWidget.attachmentModel.clearAndSetContext(...newContext);
112104
}
113105
});
114106

src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -154,12 +154,9 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio
154154

155155
// Add the currently active editors to the working set
156156
this._trackCurrentEditorsInWorkingSet();
157-
this._register(this._editorService.onDidActiveEditorChange(() => {
157+
this._register(this._editorService.onDidVisibleEditorsChange(() => {
158158
this._trackCurrentEditorsInWorkingSet();
159159
}));
160-
this._register(this._editorService.onDidCloseEditor((e) => {
161-
this._trackCurrentEditorsInWorkingSet(e);
162-
}));
163160
this._register(autorun(reader => {
164161
const entries = this.entries.read(reader);
165162
entries.forEach(entry => {
@@ -171,8 +168,6 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio
171168
}
172169

173170
private _trackCurrentEditorsInWorkingSet(e?: IEditorCloseEvent) {
174-
const closedEditor = e?.editor.resource?.toString();
175-
176171
const existingTransientEntries = new ResourceSet();
177172
for (const file of this._workingSet.keys()) {
178173
if (this._workingSet.get(file)?.state === WorkingSetEntryState.Transient) {
@@ -191,10 +186,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio
191186
}
192187
if (isCodeEditor(activeEditorControl) && activeEditorControl.hasModel()) {
193188
const uri = activeEditorControl.getModel().uri;
194-
if (closedEditor === uri.toString()) {
195-
// The editor group service sees recently closed editors?
196-
// Continue, since we want this to be deleted from the working set
197-
} else if (existingTransientEntries.has(uri)) {
189+
if (existingTransientEntries.has(uri)) {
198190
existingTransientEntries.delete(uri);
199191
} else if (!this._workingSet.has(uri) && !this._removedTransientEntries.has(uri)) {
200192
// Don't add as a transient entry if it's already part of the working set

src/vs/workbench/contrib/chat/browser/chatInputPart.ts

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js';
1212
import * as aria from '../../../../base/browser/ui/aria/aria.js';
1313
import { Button } from '../../../../base/browser/ui/button/button.js';
1414
import { IHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegate.js';
15+
import { getBaseLayerHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegate2.js';
1516
import { createInstantHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js';
17+
import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js';
1618
import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
1719
import { ProgressBar } from '../../../../base/browser/ui/progressbar/progressbar.js';
1820
import { IAction } from '../../../../base/common/actions.js';
@@ -84,7 +86,7 @@ import { ILanguageModelChatMetadata, ILanguageModelsService } from '../common/la
8486
import { CancelAction, ChatModelPickerActionId, ChatSubmitSecondaryAgentAction, IChatExecuteActionContext, ChatSubmitAction } from './actions/chatExecuteActions.js';
8587
import { ImplicitContextAttachmentWidget } from './attachments/implicitContextAttachment.js';
8688
import { IChatWidget } from './chat.js';
87-
import { ChatAttachmentModel } from './chatAttachmentModel.js';
89+
import { ChatAttachmentModel, EditsAttachmentModel } from './chatAttachmentModel.js';
8890
import { IDisposableReference } from './chatContentParts/chatCollections.js';
8991
import { CollapsibleListPool, IChatCollapsibleListItem } from './chatContentParts/chatReferencesContentPart.js';
9092
import { ChatDragAndDrop, EditsDragAndDrop } from './chatDragAndDrop.js';
@@ -223,6 +225,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
223225

224226
private readonly _chatEditsActionsDisposables = this._register(new DisposableStore());
225227
private readonly _chatEditsDisposables = this._register(new DisposableStore());
228+
private readonly _chatEditsFileLimitHover = this._register(new MutableDisposable<IDisposable>());
226229
private _chatEditsProgress: ProgressBar | undefined;
227230
private _chatEditsListPool: CollapsibleListPool;
228231
private _chatEditList: IDisposableReference<WorkbenchList<IChatCollapsibleListItem>> | undefined;
@@ -281,7 +284,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
281284
) {
282285
super();
283286

284-
this._attachmentModel = this._register(new ChatAttachmentModel());
287+
if (this.location === ChatAgentLocation.EditingSession) {
288+
this._attachmentModel = this._register(this.instantiationService.createInstance(EditsAttachmentModel));
289+
this.dnd = this._register(this.instantiationService.createInstance(EditsDragAndDrop, this.attachmentModel, styles));
290+
} else {
291+
this._attachmentModel = this._register(this.instantiationService.createInstance(ChatAttachmentModel));
292+
this.dnd = this._register(this.instantiationService.createInstance(ChatDragAndDrop, this.attachmentModel, styles));
293+
}
294+
285295
this.getInputState = (): IChatInputState => {
286296
return {
287297
...getContribsInputState(),
@@ -304,7 +314,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
304314
}));
305315

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

309318
this._hasFileAttachmentContextKey = ChatContextKeys.hasFileAttachments.bindTo(contextKeyService);
310319
}
@@ -1044,6 +1053,19 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
10441053
});
10451054
}
10461055
}
1056+
const excludedEntries: IChatCollapsibleListItem[] = [];
1057+
for (const excludedAttachment of (this.attachmentModel as EditsAttachmentModel).excludedFileAttachments) {
1058+
if (excludedAttachment.isFile && URI.isUri(excludedAttachment.value) && !seenEntries.has(excludedAttachment.value)) {
1059+
excludedEntries.push({
1060+
reference: excludedAttachment.value,
1061+
state: WorkingSetEntryState.Attached,
1062+
kind: 'reference',
1063+
excluded: true,
1064+
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))
1065+
});
1066+
seenEntries.add(excludedAttachment.value);
1067+
}
1068+
}
10471069
entries.sort((a, b) => {
10481070
if (a.kind === 'reference' && b.kind === 'reference') {
10491071
if (a.state === b.state || a.state === undefined || b.state === undefined) {
@@ -1055,14 +1077,18 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
10551077
});
10561078
let remainingFileEntriesBudget = this.chatEditingService.editingSessionFileLimit;
10571079
const overviewRegion = innerContainer.querySelector('.chat-editing-session-overview') as HTMLElement ?? dom.append(innerContainer, $('.chat-editing-session-overview'));
1058-
const overviewText = overviewRegion.querySelector('span') ?? dom.append(overviewRegion, $('span'));
1059-
overviewText.textContent = localize('chatEditingSession.workingSet', 'Working Set');
1080+
const overviewTitle = overviewRegion.querySelector('.working-set-title') as HTMLElement ?? dom.append(overviewRegion, $('.working-set-title'));
1081+
const overviewWorkingSet = overviewTitle.querySelector('span') ?? dom.append(overviewTitle, $('span'));
1082+
const overviewFileCount = overviewTitle.querySelector('span.working-set-count') ?? dom.append(overviewTitle, $('span.working-set-count'));
1083+
1084+
overviewWorkingSet.textContent = localize('chatEditingSession.workingSet', 'Working Set');
10601085

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

1089+
overviewFileCount.textContent = '';
10641090
if (entries.length === 1) {
1065-
overviewText.textContent += ' ' + localize('chatEditingSession.oneFile', '(1 file)');
1091+
overviewFileCount.textContent = ' ' + localize('chatEditingSession.oneFile', '(1 file)');
10661092
} else if (entries.length >= remainingFileEntriesBudget) {
10671093
// The user tried to attach too many files, we have to drop anything after the limit
10681094
const entriesToPreserve: IChatCollapsibleListItem[] = [];
@@ -1099,7 +1125,28 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
10991125
entries = [...entriesToPreserve, ...newEntriesThatFit, ...suggestedFilesThatFit];
11001126
}
11011127
if (entries.length > 1) {
1102-
overviewText.textContent += ' ' + localize('chatEditingSession.manyFiles', '({0} files)', entries.length);
1128+
overviewFileCount.textContent = ' ' + localize('chatEditingSession.manyFiles', '({0} files)', entries.length);
1129+
}
1130+
1131+
if (excludedEntries.length > 0) {
1132+
overviewFileCount.textContent = ' ' + localize('chatEditingSession.excludedFiles', '({0} files, {1} excluded)', entries.length, excludedEntries.length);
1133+
}
1134+
1135+
const fileLimitReached = remainingFileEntriesBudget <= 0;
1136+
overviewFileCount.classList.toggle('file-limit-reached', fileLimitReached);
1137+
if (fileLimitReached) {
1138+
let title = localize('chatEditingSession.fileLimitReached', 'You have reached the maximum number of files that can be added to the working set.');
1139+
title += excludedEntries.length === 1 ? ' ' + localize('chatEditingSession.excludedOneFile', '1 file is excluded from the Working Set.') : '';
1140+
title += excludedEntries.length > 1 ? ' ' + localize('chatEditingSession.excludedSomeFiles', '{0} files are excluded from the Working Set.', excludedEntries.length) : '';
1141+
1142+
this._chatEditsFileLimitHover.value = getBaseLayerHoverDelegate().setupDelayedHover(overviewFileCount as HTMLElement,
1143+
{
1144+
content: title,
1145+
appearance: { showPointer: true, compact: true },
1146+
position: { hoverPosition: HoverPosition.ABOVE }
1147+
});
1148+
} else {
1149+
this._chatEditsFileLimitHover.clear();
11031150
}
11041151

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

11681215
const maxItemsShown = 6;
1169-
const itemsShown = Math.min(entries.length, maxItemsShown);
1216+
const itemsShown = Math.min(entries.length + excludedEntries.length, maxItemsShown);
11701217
const height = itemsShown * 22;
11711218
const list = this._chatEditList.object;
11721219
list.layout(height);
11731220
list.getHTMLElement().style.height = `${height}px`;
11741221
list.splice(0, list.length, entries);
1222+
list.splice(entries.length, 0, excludedEntries);
11751223
this._combinedChatEditWorkingSetEntries = coalesce(entries.map((e) => e.kind === 'reference' && URI.isUri(e.reference) ? e.reference : undefined));
11761224

11771225
const addFilesElement = innerContainer.querySelector('.chat-editing-session-toolbar-actions') as HTMLElement ?? dom.append(innerContainer, $('.chat-editing-session-toolbar-actions'));
@@ -1183,7 +1231,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
11831231
// Disable the button if the entries that are not suggested exceed the budget
11841232
button.enabled = remainingFileEntriesBudget > 0;
11851233
button.label = localize('chatAddFiles', '{0} Add Files...', '$(add)');
1186-
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.'));
1234+
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.'));
11871235
this._chatEditsActionsDisposables.add(button.onDidClick(() => {
11881236
this.commandService.executeCommand('workbench.action.chat.editing.attachFiles', { widget: chatWidget });
11891237
}));

0 commit comments

Comments
 (0)