Skip to content
Open
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 @@ -538,9 +538,20 @@ export class ImageAttachmentWidget extends AbstractChatAttachmentWidget {
const currentLanguageModelName = this.currentLanguageModel ? this.languageModelsService.lookupLanguageModel(this.currentLanguageModel.identifier)?.name ?? this.currentLanguageModel.identifier : 'Current model';

const fullName = resource ? this.labelService.getUriLabel(resource) : (attachment.fullName || attachment.name);
this._register(createImageElements(resource, attachment.name, fullName, this.element, imageData ?? (attachment.value as Uint8Array), attachment.id, this.hoverService, ariaLabel, currentLanguageModelName, clickHandler, this.currentLanguageModel, omittedState));

const imageElements = this._register(new MutableDisposable<IDisposable>());
const renderImageElements = (buffer: Uint8Array) => {
imageElements.value = createImageElements(resource, attachment.name, fullName, this.element, buffer, attachment.id, this.hoverService, ariaLabel, currentLanguageModelName, clickHandler, this.currentLanguageModel, omittedState);
// createImageElements resets the label; restore the deletion hint after each render.
this.element.ariaLabel = this.appendDeletionHint(ariaLabel);
};
renderImageElements(imageData ?? new Uint8Array());

// Hydrated attachments need disk bytes so the preview does not fall back to a generic file icon.
if (!imageData && resource && omittedState !== OmittedState.Full && omittedState !== OmittedState.ImageLimitExceeded) {
void this.loadImageBytes(resource, renderImageElements);
}
this.attachSaveButton(resource, imageData, attachment.name, options.supportsDeletion);
this.element.ariaLabel = this.appendDeletionHint(ariaLabel);

// Wire up click + keyboard (Enter/Space) open handlers
const canOpenCarousel = !!imageData && configurationService.getValue<boolean>(ChatConfiguration.ImageCarouselEnabled);
Expand All @@ -558,6 +569,20 @@ export class ImageAttachmentWidget extends AbstractChatAttachmentWidget {
}
}

private async loadImageBytes(resource: URI, render: (buffer: Uint8Array) => void): Promise<void> {
let content: VSBuffer;
try {
content = (await this.fileService.readFile(resource)).value;
} catch {
// The file may no longer exist; keep the icon fallback that is already rendered.
return;
}
if (this._store.isDisposed) {
return;
}
render(content.buffer);
}

private async openInCarousel(id: string, name: string, data: Uint8Array | undefined, referenceUri: URI | undefined, preferCurrentInput: boolean | undefined): Promise<void> {
const resource = referenceUri ?? URI.from({ scheme: 'data', path: `${id}/${encodeURIComponent(name)}` });
await this.chatImageCarouselService.openCarouselAtResource(resource, data, { preferCurrentInput });
Expand Down Expand Up @@ -723,6 +748,13 @@ function createImageElements(resource: URI | undefined, name: string, fullName:
hoverElement.appendChild(dom.$('div', undefined, localize('chat.autoImageAttachmentHover', "Image support depends on the model selected by Auto.")));
}
}

// Remove old DOM so the widget can safely re-render after hydrated bytes load.
disposable.add(toDisposable(() => {
currentPill.remove();
textLabel.remove();
}));

return disposable;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import { mainWindow } from '../../../../../../../base/browser/window.js';
import { URI } from '../../../../../../../base/common/uri.js';
import { ExtensionIdentifier } from '../../../../../../../platform/extensions/common/extensions.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js';
import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js';
import { getEffectiveImageOmittedState } from '../../../../browser/attachments/chatAttachmentWidgets.js';
import { IFileService } from '../../../../../../../platform/files/common/files.js';
import { TestFileService, workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js';
import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../../../../../browser/labels.js';
import { getEffectiveImageOmittedState, ImageAttachmentWidget } from '../../../../browser/attachments/chatAttachmentWidgets.js';
import { ChatAttachmentsContentPart } from '../../../../browser/widget/chatContentParts/chatAttachmentsContentPart.js';
import { AgentHostCompletionReferenceKind, IChatRequestVariableEntry, OmittedState, toAgentHostCompletionVariableEntry } from '../../../../common/attachments/chatVariableEntries.js';
import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../../common/languageModels.js';
Expand Down Expand Up @@ -347,5 +349,82 @@ suite('ChatAttachmentsContentPart', () => {

assert.strictEqual(getEffectiveImageOmittedState(OmittedState.Full, autoModel, true), OmittedState.NotOmitted);
});

suite('hydrated image attachments', () => {
async function renderImageAndCollectReads(image: IChatRequestVariableEntry): Promise<string[]> {
const fileService = instantiationService.get(IFileService) as TestFileService;
const part = store.add(instantiationService.createInstance(
ChatAttachmentsContentPart,
{ variables: [image] }
));

mainWindow.document.body.appendChild(part.domNode!);
disposables.add(toDisposable(() => part.domNode?.remove()));

// Let the widget's lazy byte load (a microtask) settle before inspecting reads.
await new Promise<void>(resolve => setTimeout(resolve, 0));

return fileService.readOperations.map(read => read.resource.toString());
}

test('should load bytes from the resource for a hydrated (uri-only) image', async () => {
const resource = URI.file('/test/pasted-image.png');
const reads = await renderImageAndCollectReads({
kind: 'image',
id: 'hydrated-image',
name: 'pasted-image.png',
value: resource,
mimeType: 'image/png',
isURL: true,
references: [{ kind: 'reference', reference: resource }]
});

assert.deepStrictEqual(reads, [resource.toString()]);
});

test('should not read the resource for an image with inline bytes', async () => {
const resource = URI.file('/test/inline-image.png');
const reads = await renderImageAndCollectReads({
kind: 'image',
id: 'inline-image',
name: 'inline-image.png',
value: new Uint8Array([0x89, 0x50, 0x4E, 0x47]),
mimeType: 'image/png',
isURL: false,
references: [{ kind: 'reference', reference: resource }]
});

assert.deepStrictEqual(reads, []);
});

test('should keep delete hint after loading hydrated image bytes', async () => {
const resource = URI.file('/test/pasted-image.png');
const container = mainWindow.document.createElement('div');
mainWindow.document.body.appendChild(container);
disposables.add(toDisposable(() => container.remove()));
const contextResourceLabels = disposables.add(instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER));
const widget = disposables.add(instantiationService.createInstance(
ImageAttachmentWidget,
resource,
{
kind: 'image',
id: 'hydrated-image-with-delete',
name: 'pasted-image.png',
value: resource,
mimeType: 'image/png',
isURL: true,
references: [{ kind: 'reference', reference: resource }]
},
undefined,
{ shouldFocusClearButton: false, supportsDeletion: true },
container,
contextResourceLabels
));

await new Promise<void>(resolve => setTimeout(resolve, 0));

assert.strictEqual(widget.element.ariaLabel, 'Attached image, pasted-image.png (Delete)');
});
});
});
});
Loading