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
33 changes: 22 additions & 11 deletions src/vs/workbench/contrib/chat/browser/chatImageCarouselService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export async function collectCarouselSections(
if (dedupedImages.length > 0) {
sections.push({
title: request?.messageText ?? extractedTitle,
images: dedupedImages.map(({ id, name, mimeType, data, caption }) => ({ id, name, mimeType, data: data.buffer, caption }))
images: dedupedImages.map(({ uri, name, mimeType, data, caption }) => ({ id: uri.toString(), name, mimeType, data: data.buffer, caption }))
});
}
}
Expand All @@ -118,7 +118,7 @@ export async function collectCarouselSections(
if (dedupedImages.length > 0) {
sections.push({
title: item.messageText,
images: dedupedImages.map(({ id, name, mimeType, data, caption }) => ({ id, name, mimeType, data: data.buffer, caption }))
images: dedupedImages.map(({ uri, name, mimeType, data, caption }) => ({ id: uri.toString(), name, mimeType, data: data.buffer, caption }))
});
}
}
Expand Down Expand Up @@ -151,7 +151,20 @@ export function findClickedImageIndex(
let globalOffset = 0;

for (const section of sections) {
const localIndex = findImageInList(section.images, resource, data);
const localIndex = findImageInListByUri(section.images, resource);
if (localIndex >= 0) {
return globalOffset + localIndex;
}
globalOffset += section.images.length;
}

if (!data) {
return -1;
}

globalOffset = 0;
for (const section of sections) {
const localIndex = findImageInListByData(section.images, data);
if (localIndex >= 0) {
return globalOffset + localIndex;
}
Expand All @@ -161,10 +174,9 @@ export function findClickedImageIndex(
return -1;
}

function findImageInList(
function findImageInListByUri(
images: ICarouselImage[],
resource: URI,
data?: Uint8Array,
): number {
// Try matching by URI string (for inline references and tool images with URIs)
const uriStr = resource.toString();
Expand All @@ -185,15 +197,14 @@ function findImageInList(
return byParsedUri;
}

// Fall back to matching by data buffer equality
if (data) {
const wrapped = VSBuffer.wrap(data);
return images.findIndex(img => VSBuffer.wrap(img.data).equals(wrapped));
}

return -1;
}

function findImageInListByData(images: ICarouselImage[], data: Uint8Array): number {
const wrapped = VSBuffer.wrap(data);
return images.findIndex(img => VSBuffer.wrap(img.data).equals(wrapped));
}

/**
* Builds the collection arguments for the carousel command.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ import { VSBuffer } from '../../../../../base/common/buffer.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { URI } from '../../../../../base/common/uri.js';
import { buildCollectionArgs, buildSingleImageArgs, collectCarouselSections, findClickedImageIndex, ICarouselSection } from '../../browser/chatImageCarouselService.js';
import { IChatToolInvocationSerialized } from '../../common/chatService/chatService.js';
import { ChatResponseResource } from '../../common/model/chatModel.js';
import { IImageVariableEntry } from '../../common/attachments/chatVariableEntries.js';
import { IChatRequestViewModel, IChatResponseViewModel } from '../../common/model/chatViewModel.js';
import { ToolDataSource } from '../../common/tools/languageModelToolsService.js';

suite('ChatImageCarouselService helpers', () => {
ensureNoDisposablesAreLeakedInTestSuite();
Expand All @@ -35,12 +38,12 @@ suite('ChatImageCarouselService helpers', () => {
} as unknown as IChatRequestViewModel;
}

function makeResponse(requestId: string, id: string = 'resp-1'): IChatResponseViewModel {
function makeResponse(requestId: string, id: string = 'resp-1', responseValue: IChatResponseViewModel['response']['value'] = []): IChatResponseViewModel {
return {
id,
requestId,
sessionResource: URI.parse('chat-session://test/session'),
response: { value: [] },
response: { value: responseValue },
session: { getItems: () => [] },
setVote: () => { },
} as unknown as IChatResponseViewModel;
Expand Down Expand Up @@ -104,6 +107,28 @@ suite('ChatImageCarouselService helpers', () => {
assert.strictEqual(findClickedImageIndex(sections, unknownUri, new Uint8Array([30, 40])), 1);
});

test('prefers a later exact URI match over an earlier image with identical data', () => {
const firstUri = URI.parse('vscode-chat-response-resource://session/tool-call-1/0/file.png');
const secondUri = URI.parse('vscode-chat-response-resource://session/tool-call-2/0/file.png');
const identicalData = new Uint8Array([10, 20, 30]);
const sections: ICarouselSection[] = [
{
title: 'Earlier',
images: [
{ id: firstUri.toString(), name: 'first.png', mimeType: 'image/png', data: identicalData },
],
},
{
title: 'Later',
images: [
{ id: secondUri.toString(), name: 'second.png', mimeType: 'image/png', data: identicalData },
],
},
];

assert.strictEqual(findClickedImageIndex(sections, secondUri, identicalData), 1);
});

test('returns -1 for empty sections', () => {
assert.strictEqual(findClickedImageIndex([], URI.file('/x.png')), -1);
});
Expand Down Expand Up @@ -283,6 +308,42 @@ suite('ChatImageCarouselService helpers', () => {
assert.strictEqual(result[0].images.length, 3);
});

test('uses tool image URIs as carousel image ids', async () => {
const request = makeRequest('req-1', [], 'Request with tool output image');
const toolCallId = 'tool-call-1';
const sessionResource = URI.parse('chat-session://test/session');
const expectedUri = ChatResponseResource.createUri(sessionResource, toolCallId, 0, 'file.png').toString();
const response = makeResponse('req-1', 'resp-1', [
{
kind: 'toolInvocationSerialized',
toolId: 'test_tool',
toolCallId,
invocationMessage: 'Took screenshot',
originMessage: undefined,
pastTenseMessage: undefined,
presentation: undefined,
resultDetails: {
output: {
type: 'data',
mimeType: 'image/png',
base64Data: 'AQID'
}
},
isConfirmed: { type: 0 },
isComplete: true,
source: ToolDataSource.Internal,
generatedTitle: undefined,
isAttachedToThinking: false,
} as unknown as IChatToolInvocationSerialized,
]);

const result = await collectCarouselSections([request, response], async () => new Uint8Array());

assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].images.length, 1);
assert.strictEqual(result[0].images[0].id, expectedUri);
});

test('image data is a plain Uint8Array usable by Blob constructor', async () => {
const request = makeRequest('req-1', [
makeImageVariableEntry({ value: new Uint8Array([1, 2, 3]) }),
Expand Down
Loading