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
2 changes: 1 addition & 1 deletion build/npm/update-localization-extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ function update(options: Options) {
});
});
}
if (path.basename(process.argv[1]) === 'update-localization-extension.js') {
if (path.basename(process.argv[1]) === 'update-localization-extension.ts') {
const options = minimist(process.argv.slice(2), {
string: ['location', 'externalExtensionsLocation']
}) as Options;
Expand Down
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -454,8 +454,8 @@ export class ImageAttachmentWidget extends AbstractChatAttachmentWidget {
this.element.ariaLabel = this.appendDeletionHint(ariaLabel);

// Wire up click + keyboard (Enter/Space) open handlers
const canOpenCarousel = attachment.value instanceof Uint8Array && configurationService.getValue<boolean>(ChatConfiguration.ImageCarouselEnabled);
if ((imageData && canOpenCarousel) || resource) {
const canOpenCarousel = !!imageData && configurationService.getValue<boolean>(ChatConfiguration.ImageCarouselEnabled);
if (canOpenCarousel || resource) {
this.element.style.cursor = 'pointer';
this._register(registerOpenEditorListeners(this.element, async () => {
await clickHandler();
Expand Down
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
19 changes: 12 additions & 7 deletions src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ export class ChatSlashCommandsContribution extends Disposable {
executeImmediately: true,
silent: true,
locations: [ChatAgentLocation.Chat],
targets: [Target.VSCode]
}, async () => {
await commandService.executeCommand(OpenModelPickerAction.ID);
}));
Expand Down Expand Up @@ -200,7 +199,8 @@ export class ChatSlashCommandsContribution extends Disposable {
sortText: 'z1_autoApprove',
executeImmediately: true,
silent: true,
locations: [ChatAgentLocation.Chat]
locations: [ChatAgentLocation.Chat],
targets: [Target.VSCode, Target.GitHubCopilot]
}, async (_prompt, _progress, _history, _location, sessionResource) => {
setPermissionLevelForSession(sessionResource, ChatPermissionLevel.AutoApprove);
}));
Expand All @@ -210,7 +210,8 @@ export class ChatSlashCommandsContribution extends Disposable {
sortText: 'z1_disableAutoApprove',
executeImmediately: true,
silent: true,
locations: [ChatAgentLocation.Chat]
locations: [ChatAgentLocation.Chat],
targets: [Target.VSCode, Target.GitHubCopilot]
}, async (_prompt, _progress, _history, _location, sessionResource) => {
setPermissionLevelForSession(sessionResource, ChatPermissionLevel.Default);
}));
Expand All @@ -220,7 +221,8 @@ export class ChatSlashCommandsContribution extends Disposable {
sortText: 'z1_yolo',
executeImmediately: true,
silent: true,
locations: [ChatAgentLocation.Chat]
locations: [ChatAgentLocation.Chat],
targets: [Target.VSCode, Target.GitHubCopilot]
}, async (_prompt, _progress, _history, _location, sessionResource) => {
setPermissionLevelForSession(sessionResource, ChatPermissionLevel.AutoApprove);
}));
Expand All @@ -230,7 +232,8 @@ export class ChatSlashCommandsContribution extends Disposable {
sortText: 'z1_disableYolo',
executeImmediately: true,
silent: true,
locations: [ChatAgentLocation.Chat]
locations: [ChatAgentLocation.Chat],
targets: [Target.VSCode, Target.GitHubCopilot]
}, async (_prompt, _progress, _history, _location, sessionResource) => {
setPermissionLevelForSession(sessionResource, ChatPermissionLevel.Default);
}));
Expand All @@ -241,7 +244,8 @@ export class ChatSlashCommandsContribution extends Disposable {
sortText: 'z1_autopilot',
executeImmediately: true,
silent: true,
locations: [ChatAgentLocation.Chat]
locations: [ChatAgentLocation.Chat],
targets: [Target.VSCode, Target.GitHubCopilot]
}, async (_prompt, _progress, _history, _location, sessionResource) => {
setPermissionLevelForSession(sessionResource, ChatPermissionLevel.Autopilot);
}));
Expand All @@ -251,7 +255,8 @@ export class ChatSlashCommandsContribution extends Disposable {
sortText: 'z1_exitAutopilot',
executeImmediately: true,
silent: true,
locations: [ChatAgentLocation.Chat]
locations: [ChatAgentLocation.Chat],
targets: [Target.VSCode, Target.GitHubCopilot]
}, async (_prompt, _progress, _history, _location, sessionResource) => {
setPermissionLevelForSession(sessionResource, ChatPermissionLevel.Default);
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,6 @@ class SlashCommandCompletions extends Disposable {

let customAgentTarget: Target | undefined = undefined;
if (widget.lockedAgentId) {
if (!widget.attachmentCapabilities.supportsPromptAttachments) {
return null;
}
const sessionResource = widget.viewModel.model.sessionResource;
const ctx = sessionResource && chatService.getChatSessionFromInternalUri(sessionResource);
customAgentTarget = (ctx ? chatSessionsService.getCustomAgentTargetForSessionType(getChatSessionType(sessionResource)) : undefined) ?? Target.Undefined;
Expand Down Expand Up @@ -137,6 +134,12 @@ class SlashCommandCompletions extends Disposable {
return {
suggestions: slashCommands
.filter(c => {
// silent commands are client-side only... so they're not "attaching anything"
// so this check can be scoped to when the command _does_ attach something before
// checking if the widget supports attachments at all
if (!c.silent && !widget.attachmentCapabilities.supportsPromptAttachments) {
return false;
}
if (c.when && !widget.scopedContextKeyService.contextMatchesRules(c.when)) {
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,10 +223,14 @@ export class ChatRequestParser {
}
}

const capabilities = context?.attachmentCapabilities ?? usedAgent?.capabilities ?? context?.attachmentCapabilities;
if (!usedAgent || capabilities?.supportsPromptAttachments) {
const slashCommands = this.slashCommandService.getCommands(location, context?.mode ?? ChatModeKind.Ask);
const slashCommand = slashCommands.find(c => c.command === command);
const capabilities = context?.attachmentCapabilities ?? usedAgent?.capabilities;
const slashCommands = this.slashCommandService.getCommands(location, context?.mode ?? ChatModeKind.Ask);
const slashCommand = slashCommands.find(c => c.command === command);
// If there is no agent, we allow any slash command.
// If there is an agent, we let
// * silent ones go through since they are only UI-facing and don't influence chat history
// * slash commands that support prompt attachments, since those are meant to be used in conjunction with an agent and we can assume the agent can handle them.
if (!usedAgent || slashCommand?.silent || capabilities?.supportsPromptAttachments) {
if (slashCommand) {
// Valid standalone slash command
return new ChatRequestSlashCommandPart(slashRange, slashEditorRange, slashCommand);
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
parts: [
{
range: {
start: 0,
endExclusive: 6
},
editorRange: {
startLineNumber: 1,
startColumn: 1,
endLineNumber: 1,
endColumn: 7
},
agent: {
id: "agent",
name: "agent",
extensionId: {
value: "nullExtensionDescription",
_lower: "nullextensiondescription"
},
extensionVersion: undefined,
publisherDisplayName: "",
extensionDisplayName: "",
extensionPublisherId: "",
locations: [ "panel" ],
modes: [ "ask" ],
metadata: { },
slashCommands: [
{
name: "subCommand",
description: ""
}
],
disambiguation: [ ]
},
kind: "agent"
},
{
range: {
start: 6,
endExclusive: 16
},
editorRange: {
startLineNumber: 1,
startColumn: 7,
endLineNumber: 1,
endColumn: 17
},
text: " /fix this",
kind: "text"
}
],
text: "@agent /fix this"
}
Loading
Loading