diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index 3d641bae50f..26e1e273027 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -170,6 +170,10 @@ "name": "vs/workbench/contrib/inlineChat", "project": "vscode-workbench" }, + { + "name": "vs/workbench/contrib/imageCarousel", + "project": "vscode-workbench" + }, { "name": "vs/workbench/contrib/chat", "project": "vscode-workbench" diff --git a/src/vs/base/common/network.ts b/src/vs/base/common/network.ts index 74cb106fd3c..c2efd167054 100644 --- a/src/vs/base/common/network.ts +++ b/src/vs/base/common/network.ts @@ -75,6 +75,9 @@ export namespace Schemas { export const vscodeTerminal = 'vscode-terminal'; + /** Scheme used for the image carousel editor. */ + export const vscodeImageCarousel = 'vscode-image-carousel'; + /** Scheme used for code blocks in chat. */ export const vscodeChatCodeBlock = 'vscode-chat-code-block'; diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts index 2e2e0d74632..56681a5d39d 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts @@ -18,6 +18,7 @@ import { IMarkdownString, MarkdownString } from '../../../../../base/common/html import { Iterable } from '../../../../../base/common/iterator.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { getMediaMime } from '../../../../../base/common/mime.js'; import { Schemas } from '../../../../../base/common/network.js'; import { basename, dirname } from '../../../../../base/common/path.js'; import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js'; @@ -61,6 +62,7 @@ import { toHistoryItemHoverContent } from '../../../scm/browser/scmHistory.js'; import { getHistoryItemEditorTitle } from '../../../scm/browser/util.js'; import { ITerminalService } from '../../../terminal/browser/terminal.js'; import { IChatContentReference } from '../../common/chatService/chatService.js'; +import { ChatConfiguration } from '../../common/constants.js'; import { IChatRequestPasteVariableEntry, IChatRequestVariableEntry, IElementVariableEntry, INotebookOutputVariableEntry, IPromptFileVariableEntry, IPromptTextVariableEntry, ISCMHistoryItemVariableEntry, OmittedState, PromptFileVariableKind, ChatRequestToolReferenceEntry, ISCMHistoryItemChangeVariableEntry, ISCMHistoryItemChangeRangeVariableEntry, ITerminalVariableEntry, isStringVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../common/languageModels.js'; import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; @@ -435,7 +437,10 @@ export class ImageAttachmentWidget extends AbstractChatAttachmentWidget { const ref = attachment.references?.[0]?.reference; resource = ref && URI.isUri(ref) ? ref : undefined; const clickHandler = async () => { - if (resource) { + if (attachment.value instanceof Uint8Array && configurationService.getValue(ChatConfiguration.ImageCarouselEnabled)) { + const mimeType = getMediaMime(attachment.name) ?? 'image/png'; + await commandService.executeCommand('workbench.action.chat.openImageInCarousel', { name: attachment.name, mimeType, data: attachment.value }); + } else if (resource) { await this.openResource(resource, { editorOptions: { preserveFocus: true } }, false, undefined); } }; @@ -446,8 +451,16 @@ export class ImageAttachmentWidget extends AbstractChatAttachmentWidget { this._register(createImageElements(resource, attachment.name, fullName, this.element, attachment.value as Uint8Array, this.hoverService, ariaLabel, currentLanguageModelName, clickHandler, this.currentLanguageModel, attachment.omittedState, this.chatEntitlementService.previewFeaturesDisabled)); this.element.ariaLabel = this.appendDeletionHint(ariaLabel); + // Wire up click + keyboard (Enter/Space) open handlers + const canOpenCarousel = attachment.value instanceof Uint8Array && configurationService.getValue(ChatConfiguration.ImageCarouselEnabled); + if (canOpenCarousel || resource) { + this.element.style.cursor = 'pointer'; + this._register(registerOpenEditorListeners(this.element, async () => { + await clickHandler(); + })); + } + if (resource) { - this.addResourceOpenHandlers(resource, undefined); instantiationService.invokeFunction(accessor => { this._register(hookUpResourceAttachmentDragAndContextMenu(accessor, this.element, resource)); }); @@ -475,7 +488,6 @@ function createImageElements(resource: URI | undefined, name: string, fullName: if (resource) { element.style.cursor = 'pointer'; - disposable.add(dom.addDisposableListener(element, 'click', clickHandler)); } const supportsVision = modelSupportsVision(currentLanguageModel); const pillIcon = dom.$('div.chat-attached-context-pill', {}, dom.$((supportsVision && !previewFeaturesDisabled) ? 'span.codicon.codicon-file-media' : 'span.codicon.codicon-warning')); diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 72b15f884c4..c6392a26746 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -479,6 +479,12 @@ configurationRegistry.registerConfiguration({ type: 'boolean', tags: ['experimental'] }, + [ChatConfiguration.ImageCarouselEnabled]: { + default: false, + description: nls.localize('chat.imageCarousel.enabled', "Controls whether clicking an image attachment in chat opens the image carousel viewer."), + type: 'boolean', + tags: ['preview'] + }, 'chat.undoRequests.restoreInput': { default: true, markdownDescription: nls.localize('chat.undoRequests.restoreInput', "Controls whether the input of the chat should be restored when an undo request is made. The input will be filled with the text of the request that was restored."), diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 1cc89241a27..5d51053f843 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -55,6 +55,7 @@ export enum ChatConfiguration { GrowthNotificationEnabled = 'chat.growthNotification.enabled', ChatCustomizationMenuEnabled = 'chat.customizationsMenu.enabled', AutopilotEnabled = 'chat.autopilot.enabled', + ImageCarouselEnabled = 'chat.imageCarousel.enabled', } /** diff --git a/src/vs/workbench/contrib/imageCarousel/AGENTS.md b/src/vs/workbench/contrib/imageCarousel/AGENTS.md new file mode 100644 index 00000000000..f24cf4d9bb8 --- /dev/null +++ b/src/vs/workbench/contrib/imageCarousel/AGENTS.md @@ -0,0 +1,36 @@ +# Image Carousel + +A generic workbench editor for viewing collections of images in a carousel/slideshow UI. Opens as a modal editor pane with navigation arrows, a counter, and a thumbnail strip. + +## Architecture + +The image carousel is a self-contained workbench contribution that follows the **custom editor** pattern: + +- **URI scheme**: `vscode-image-carousel` (registered in `Schemas` in `src/vs/base/common/network.ts`) — used for `EditorInput.resource` identity. +- **Direct editor input**: Callers create `ImageCarouselEditorInput` with a collection and open it directly via `IEditorService.openEditor()`. +- **Image extraction**: `IImageCarouselService.extractImagesFromResponse()` extracts images from chat response tool invocations. The collection ID is derived from the chat response identity (`sessionResource + responseId`). + +## How to open the carousel + +### From code (generic) + +```ts +const collection: IImageCarouselCollection = { id, title, images: [...] }; +const input = new ImageCarouselEditorInput(collection, startIndex); +await editorService.openEditor(input, { pinned: true }, MODAL_GROUP); +``` + +### From chat (via click handler) + +Clicking an image attachment pill in chat (when `chat.imageCarousel.enabled` is true) executes the `workbench.action.chat.openImageInCarousel` command, which extracts all images from the chat response and opens them in the carousel. MIME types are resolved via `getMediaMime()` from `src/vs/base/common/mime.ts`. + +## Key design decisions + +- **Stable DOM skeleton**: Builds DOM once per `setInput()`, updates only changing parts to avoid flash on navigation. +- **Blob URL lifecycle**: Main image URLs tracked in `_imageDisposables` (revoked on nav), thumbnails in `_contentDisposables` (revoked on `clearInput()`). +- **Modal editor**: Opens in `MODAL_GROUP` (-4) as an overlay. +- **Not restorable**: `canSerialize()` returns `false` — image data is in-memory only. +- **Collection ID = chat response identity**: `sessionResource + '_' + responseId` for stable dedup via `EditorInput.matches()`. +- **Preview-gated**: `chat.imageCarousel.enabled` (default `false`, tagged `preview`). When off, clicks fall through to `openResource()`. +- **Exact image matching**: Scans responses in reverse, only opens a collection if the clicked image bytes are found in it (`findIndex` + `VSBuffer.equals`). +- **Keyboard parity**: Uses `registerOpenEditorListeners` (click, double-click, Enter, Space) matching other attachment widgets. diff --git a/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts b/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts new file mode 100644 index 00000000000..412daacc264 --- /dev/null +++ b/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts @@ -0,0 +1,114 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/imageCarousel.css'; +import { localize, localize2 } from '../../../../nls.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/editor.js'; +import { EditorExtensions, IEditorFactoryRegistry, IEditorSerializer } from '../../../common/editor.js'; +import { IEditorService, MODAL_GROUP } from '../../../services/editor/common/editorService.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { IChatWidgetService } from '../../chat/browser/chat.js'; +import { IChatResponseViewModel, isResponseVM } from '../../chat/common/model/chatViewModel.js'; +import { ImageCarouselEditor } from './imageCarouselEditor.js'; +import { ImageCarouselEditorInput } from './imageCarouselEditorInput.js'; +import { IImageCarouselService, ImageCarouselService } from './imageCarouselService.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; + +// --- Service Registration --- + +registerSingleton(IImageCarouselService, ImageCarouselService, InstantiationType.Delayed); + +// --- Editor Pane Registration --- + +Registry.as(EditorExtensions.EditorPane).registerEditorPane( + EditorPaneDescriptor.create( + ImageCarouselEditor, + ImageCarouselEditor.ID, + localize('imageCarouselEditor', "Image Carousel") + ), + [ + new SyncDescriptor(ImageCarouselEditorInput) + ] +); + +// --- Serializer --- + +class ImageCarouselEditorInputSerializer implements IEditorSerializer { + canSerialize(): boolean { + return false; + } + + serialize(): string | undefined { + return undefined; + } + + deserialize(): ImageCarouselEditorInput | undefined { + return undefined; + } +} + +Registry.as(EditorExtensions.EditorFactory) + .registerEditorSerializer(ImageCarouselEditorInput.ID, ImageCarouselEditorInputSerializer); + +// --- Actions --- + +class OpenImageInCarouselAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.openImageInCarousel', + title: localize2('openImageInCarousel', "Open Image in Carousel"), + f1: false + }); + } + + async run(accessor: ServicesAccessor, args: { name: string; mimeType: string; data: Uint8Array }): Promise { + const editorService = accessor.get(IEditorService); + const chatWidgetService = accessor.get(IChatWidgetService); + const carouselService = accessor.get(IImageCarouselService); + + const clickedData = VSBuffer.wrap(args.data); + + // Try to find all images from the focused chat widget's responses + const widget = chatWidgetService.lastFocusedWidget; + if (widget?.viewModel) { + const responses = widget.viewModel.getItems().filter((item): item is IChatResponseViewModel => isResponseVM(item)); + // Search responses in reverse to find the one containing the clicked image + for (let i = responses.length - 1; i >= 0; i--) { + const collection = await carouselService.extractImagesFromResponse(responses[i]); + if (collection && collection.images.length > 0) { + // Only use this collection if it actually contains the clicked image + const startIndex = collection.images.findIndex(img => img.data.equals(clickedData)); + if (startIndex !== -1) { + const input = new ImageCarouselEditorInput(collection, startIndex); + await editorService.openEditor(input, { pinned: true }, MODAL_GROUP); + return; + } + } + } + } + + // Fallback: open just the single clicked image + const collection = { + id: generateUuid(), + title: localize('imageCarousel.title', "Image Carousel"), + images: [{ + id: generateUuid(), + name: args.name, + mimeType: args.mimeType, + data: VSBuffer.wrap(args.data), + }], + }; + + const input = new ImageCarouselEditorInput(collection); + await editorService.openEditor(input, { pinned: true }, MODAL_GROUP); + } +} + +registerAction2(OpenImageInCarouselAction); diff --git a/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselEditor.ts b/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselEditor.ts new file mode 100644 index 00000000000..e1fece3445d --- /dev/null +++ b/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselEditor.ts @@ -0,0 +1,244 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { addDisposableListener, clearNode, Dimension, EventType, h } from '../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { EditorPane } from '../../../browser/parts/editor/editorPane.js'; +import { IEditorOpenContext } from '../../../common/editor.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; +import { ImageCarouselEditorInput } from './imageCarouselEditorInput.js'; +import { ICarouselImage } from './imageCarouselTypes.js'; + +export class ImageCarouselEditor extends EditorPane { + static readonly ID = 'workbench.editor.imageCarousel'; + + private _container: HTMLElement | undefined; + private _currentIndex: number = 0; + private _images: ReadonlyArray = []; + private readonly _contentDisposables = this._register(new DisposableStore()); + private readonly _imageDisposables = this._register(new DisposableStore()); + + private _elements: { + root: HTMLElement; + mainImage: HTMLImageElement; + prevBtn: HTMLButtonElement; + nextBtn: HTMLButtonElement; + counter: HTMLElement; + thumbnails: HTMLElement; + } | undefined; + private _thumbnailElements: HTMLElement[] = []; + + constructor( + group: IEditorGroup, + @ITelemetryService telemetryService: ITelemetryService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService + ) { + super(ImageCarouselEditor.ID, group, telemetryService, themeService, storageService); + } + + protected override createEditor(parent: HTMLElement): void { + this._container = h('div.image-carousel-editor').root; + parent.appendChild(this._container); + } + + override async setInput(input: ImageCarouselEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { + await super.setInput(input, options, context, token); + + this._images = input.collection.images; + this._currentIndex = Math.min(input.startIndex, Math.max(0, input.collection.images.length - 1)); + this.buildSlideshow(); + } + + override clearInput(): void { + this._contentDisposables.clear(); + this._imageDisposables.clear(); + if (this._container) { + clearNode(this._container); + } + this._elements = undefined; + this._thumbnailElements = []; + super.clearInput(); + } + + /** + * Build the full DOM skeleton. Called once per setInput. + */ + private buildSlideshow(): void { + if (!this._container) { + return; + } + + this._contentDisposables.clear(); + this._imageDisposables.clear(); + clearNode(this._container); + + if (this._images.length === 0) { + const empty = h('div.empty-message'); + empty.root.textContent = localize('imageCarousel.noImages', "No images to display"); + this._container.appendChild(empty.root); + return; + } + + const elements = h('div.slideshow-container', [ + h('div.image-area@imageArea', [ + h('div.main-image-container', [ + h('img.main-image@mainImage'), + ]), + h('button.nav-arrow.prev-arrow@prevBtn', { ariaLabel: localize('imageCarousel.previousImage', "Previous image") }, [ + h('span.codicon.codicon-chevron-left'), + ]), + h('button.nav-arrow.next-arrow@nextBtn', { ariaLabel: localize('imageCarousel.nextImage', "Next image") }, [ + h('span.codicon.codicon-chevron-right'), + ]), + ]), + h('div.image-counter@counter'), + h('div.thumbnails-container@thumbnails'), + ]); + + this._elements = { + root: elements.root, + mainImage: elements.mainImage as HTMLImageElement, + prevBtn: elements.prevBtn as HTMLButtonElement, + nextBtn: elements.nextBtn as HTMLButtonElement, + counter: elements.counter, + thumbnails: elements.thumbnails, + }; + + // Navigation listeners + this._contentDisposables.add(addDisposableListener(this._elements.prevBtn, 'click', () => { + if (this._currentIndex > 0) { + this._currentIndex--; + this.updateCurrentImage(); + } + })); + this._contentDisposables.add(addDisposableListener(this._elements.nextBtn, 'click', () => { + if (this._currentIndex < this._images.length - 1) { + this._currentIndex++; + this.updateCurrentImage(); + } + })); + + // Keyboard navigation — handle locally and stop propagation + // so the modal editor's key handler does not block these keys + this._contentDisposables.add(addDisposableListener(elements.root, EventType.KEY_DOWN, e => { + const event = new StandardKeyboardEvent(e); + if (event.keyCode === KeyCode.LeftArrow) { + this.previous(); + event.stopPropagation(); + event.preventDefault(); + } else if (event.keyCode === KeyCode.RightArrow) { + this.next(); + event.stopPropagation(); + event.preventDefault(); + } + })); + elements.root.tabIndex = 0; + + // Thumbnails + this._thumbnailElements = []; + for (let i = 0; i < this._images.length; i++) { + const image = this._images[i]; + const thumbnail = h('button.thumbnail@root', [ + h('img.thumbnail-image@img'), + ]); + + const btn = thumbnail.root as HTMLButtonElement; + btn.ariaLabel = localize('imageCarousel.thumbnailLabel', "Image {0} of {1}", i + 1, this._images.length); + + const img = thumbnail.img as HTMLImageElement; + const blob = new Blob([image.data.buffer.slice(0)], { type: image.mimeType }); + const url = URL.createObjectURL(blob); + img.src = url; + img.alt = image.name; + this._contentDisposables.add({ dispose: () => URL.revokeObjectURL(url) }); + + this._contentDisposables.add(addDisposableListener(btn, 'click', () => { + this._currentIndex = i; + this.updateCurrentImage(); + })); + + this._elements.thumbnails.appendChild(btn); + this._thumbnailElements.push(btn); + } + + this._container.appendChild(elements.root); + + // Set initial image + this.updateCurrentImage(); + } + + /** + * Update only the changing parts: main image src, counter, button states, thumbnail selection. + * No DOM teardown/rebuild — eliminates the blank flash. + */ + private updateCurrentImage(): void { + if (!this._elements) { + return; + } + + // Swap main image blob URL + this._imageDisposables.clear(); + const currentImage = this._images[this._currentIndex]; + const blob = new Blob([currentImage.data.buffer.slice(0)], { type: currentImage.mimeType }); + const url = URL.createObjectURL(blob); + this._elements.mainImage.src = url; + this._elements.mainImage.alt = currentImage.name; + this._imageDisposables.add({ dispose: () => URL.revokeObjectURL(url) }); + + // Update button states + this._elements.prevBtn.disabled = this._currentIndex === 0; + this._elements.nextBtn.disabled = this._currentIndex === this._images.length - 1; + + // Update counter + this._elements.counter.textContent = localize('imageCarousel.counter', "{0} / {1}", this._currentIndex + 1, this._images.length); + + // Update thumbnail selection + for (let i = 0; i < this._thumbnailElements.length; i++) { + const isActive = i === this._currentIndex; + const thumbnail = this._thumbnailElements[i]; + thumbnail.classList.toggle('active', isActive); + if (isActive) { + thumbnail.setAttribute('aria-current', 'page'); + } else { + thumbnail.removeAttribute('aria-current'); + } + } + } + + previous(): void { + if (this._currentIndex > 0) { + this._currentIndex--; + this.updateCurrentImage(); + } + } + + next(): void { + if (this._currentIndex < this._images.length - 1) { + this._currentIndex++; + this.updateCurrentImage(); + } + } + + override focus(): void { + super.focus(); + this._elements?.root.focus(); + } + + override layout(dimension: Dimension): void { + if (this._container) { + this._container.style.width = `${dimension.width}px`; + this._container.style.height = `${dimension.height}px`; + } + } +} diff --git a/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselEditorInput.ts b/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselEditorInput.ts new file mode 100644 index 00000000000..826db15c86b --- /dev/null +++ b/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselEditorInput.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { EditorInput } from '../../../common/editor/editorInput.js'; +import { IUntypedEditorInput } from '../../../common/editor.js'; +import { URI } from '../../../../base/common/uri.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { IImageCarouselCollection } from './imageCarouselTypes.js'; + +export class ImageCarouselEditorInput extends EditorInput { + static readonly ID = 'workbench.input.imageCarousel'; + + private _resource: URI; + + constructor( + public readonly collection: IImageCarouselCollection, + public readonly startIndex: number = 0 + ) { + super(); + this._resource = URI.from({ + scheme: Schemas.vscodeImageCarousel, + path: `/${encodeURIComponent(collection.id)}`, + }); + } + + get typeId(): string { + return ImageCarouselEditorInput.ID; + } + + get resource(): URI { + return this._resource; + } + + override getName(): string { + return this.collection.title; + } + + override matches(other: EditorInput | IUntypedEditorInput): boolean { + if (other instanceof ImageCarouselEditorInput) { + return other.collection.id === this.collection.id; + } + return false; + } +} diff --git a/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselService.ts b/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselService.ts new file mode 100644 index 00000000000..9539f988b13 --- /dev/null +++ b/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselService.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { decodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { ICarouselImage, IImageCarouselCollection } from './imageCarouselTypes.js'; +import { IChatResponseViewModel } from '../../chat/common/model/chatViewModel.js'; +import { IChatToolInvocation, IChatToolInvocationSerialized, IToolResultOutputDetailsSerialized } from '../../chat/common/chatService/chatService.js'; +import { isToolResultInputOutputDetails, isToolResultOutputDetails, IToolResultOutputDetails } from '../../chat/common/tools/languageModelToolsService.js'; + +export const IImageCarouselService = createDecorator('imageCarouselService'); + +export interface IImageCarouselService { + readonly _serviceBrand: undefined; + + /** + * Extract images from a chat response's tool invocations. + */ + extractImagesFromResponse(response: IChatResponseViewModel): Promise; +} + +export class ImageCarouselService extends Disposable implements IImageCarouselService { + readonly _serviceBrand: undefined; + + async extractImagesFromResponse(response: IChatResponseViewModel): Promise { + const images: ICarouselImage[] = []; + + for (const item of response.response.value) { + if (item.kind === 'toolInvocation' || item.kind === 'toolInvocationSerialized') { + const toolImages = this.extractImagesFromToolInvocation(item); + images.push(...toolImages); + } + } + + if (images.length === 0) { + return undefined; + } + + return { + id: response.sessionResource.toString() + '_' + response.id, + title: localize('imageCarousel.title', "Image Carousel"), + images + }; + } + + private extractImagesFromToolInvocation(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): ICarouselImage[] { + const images: ICarouselImage[] = []; + + const resultDetails = IChatToolInvocation.resultDetails(toolInvocation); + + const pushImage = (mimeType: string, data: VSBuffer) => { + images.push({ + id: `${toolInvocation.toolCallId}_${images.length}`, + name: localize('imageCarousel.imageName', "Image {0}", images.length + 1), + mimeType, + data, + source: localize('imageCarousel.toolSource', "Tool: {0}", toolInvocation.toolId) + }); + }; + + if (isToolResultInputOutputDetails(resultDetails)) { + for (const outputItem of resultDetails.output) { + if (outputItem.type === 'embed' && outputItem.mimeType?.startsWith('image/') && !outputItem.isText) { + pushImage(outputItem.mimeType, decodeBase64(outputItem.value)); + } + } + } + else if (isToolResultOutputDetails(resultDetails)) { + const output = resultDetails.output; + if (output.mimeType?.startsWith('image/')) { + const data = this.getImageDataFromOutputDetails(resultDetails, toolInvocation); + if (data) { + pushImage(output.mimeType, data); + } + } + } + + return images; + } + + private getImageDataFromOutputDetails(resultDetails: IToolResultOutputDetails, toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): VSBuffer | undefined { + if (toolInvocation.kind === 'toolInvocationSerialized') { + const serializedDetails = resultDetails as unknown as IToolResultOutputDetailsSerialized; + if (serializedDetails.output.base64Data) { + return decodeBase64(serializedDetails.output.base64Data); + } + return undefined; + } else { + return resultDetails.output.value; + } + } +} diff --git a/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselTypes.ts b/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselTypes.ts new file mode 100644 index 00000000000..63d37e34fc8 --- /dev/null +++ b/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselTypes.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../base/common/uri.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; + +export interface ICarouselImage { + readonly id: string; + readonly name: string; + readonly mimeType: string; + readonly data: VSBuffer; + readonly uri?: URI; + readonly source?: string; +} + +export interface IImageCarouselCollection { + readonly id: string; + readonly title: string; + readonly images: ReadonlyArray; +} diff --git a/src/vs/workbench/contrib/imageCarousel/browser/media/imageCarousel.css b/src/vs/workbench/contrib/imageCarousel/browser/media/imageCarousel.css new file mode 100644 index 00000000000..229815a55d6 --- /dev/null +++ b/src/vs/workbench/contrib/imageCarousel/browser/media/imageCarousel.css @@ -0,0 +1,158 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.image-carousel-editor { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.image-carousel-editor .empty-message { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--vscode-descriptionForeground); + font-size: 14px; +} + +.image-carousel-editor .slideshow-container { + display: flex; + flex-direction: column; + height: 100%; + padding: 12px; + gap: 8px; +} + +.image-carousel-editor .slideshow-container:focus, +.image-carousel-editor .slideshow-container:focus-visible { + outline: none !important; +} + +/* Image viewing area */ +.image-carousel-editor .image-area { + flex: 1; + min-height: 0; + position: relative; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + background-color: var(--vscode-editorWidget-background); + border-radius: 6px; +} + +.image-carousel-editor .main-image-container { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 24px 48px; +} + +.image-carousel-editor .main-image { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: 4px; +} + +/* Overlay navigation arrows */ +.image-carousel-editor .nav-arrow { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + border: none; + border-radius: 50%; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease; + z-index: 1; +} + +.monaco-workbench.monaco-reduce-motion .image-carousel-editor .nav-arrow { + transition: none; +} +.image-carousel-editor .image-area:hover .nav-arrow:not(:disabled) { + opacity: 0.8; +} + +.image-carousel-editor .nav-arrow:hover:not(:disabled) { + opacity: 1 !important; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} + +.image-carousel-editor .nav-arrow:disabled { + opacity: 0 !important; + cursor: default; + pointer-events: none; +} + +.image-carousel-editor .nav-arrow.prev-arrow { + left: 8px; +} + +.image-carousel-editor .nav-arrow.next-arrow { + right: 8px; +} + +/* Counter */ +.image-carousel-editor .image-counter { + text-align: center; + color: var(--vscode-descriptionForeground); + font-size: 12px; + padding: 2px 0; +} + +/* Thumbnail strip */ +.image-carousel-editor .thumbnails-container { + display: flex; + gap: 6px; + overflow-x: auto; + padding: 4px 0; + justify-content: center; + flex-shrink: 0; +} + +.image-carousel-editor .thumbnail { + width: 48px; + height: 48px; + border: 2px solid transparent; + border-radius: 4px; + overflow: hidden; + cursor: pointer; + transition: border-color 0.15s ease, opacity 0.15s ease; + flex-shrink: 0; + opacity: 0.6; + padding: 0; + background: none; +} + +.image-carousel-editor .thumbnail:hover { + opacity: 1; + border-color: var(--vscode-focusBorder); +} + +.image-carousel-editor .thumbnail.active { + opacity: 1; + border-color: var(--vscode-focusBorder); +} + +.image-carousel-editor .thumbnail-image { + width: 100%; + height: 100%; + object-fit: cover; +} diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index be64fd940aa..1d89cb13a56 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -212,6 +212,7 @@ import './contrib/inlineChat/browser/inlineChat.contribution.js'; import './contrib/mcp/browser/mcp.contribution.js'; import './contrib/chat/browser/chatSessions/chatSessions.contribution.js'; import './contrib/chat/browser/contextContrib/chatContext.contribution.js'; +import './contrib/imageCarousel/browser/imageCarousel.contribution.js'; // Interactive import './contrib/interactive/browser/interactive.contribution.js';