mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-17 15:24:40 +01:00
Merge pull request #300983 from microsoft/rebornix/hollow-tarantula-image
feat: add image carousel
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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<boolean>(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<boolean>(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'));
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -55,6 +55,7 @@ export enum ChatConfiguration {
|
||||
GrowthNotificationEnabled = 'chat.growthNotification.enabled',
|
||||
ChatCustomizationMenuEnabled = 'chat.customizationsMenu.enabled',
|
||||
AutopilotEnabled = 'chat.autopilot.enabled',
|
||||
ImageCarouselEnabled = 'chat.imageCarousel.enabled',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
36
src/vs/workbench/contrib/imageCarousel/AGENTS.md
Normal file
36
src/vs/workbench/contrib/imageCarousel/AGENTS.md
Normal file
@@ -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.
|
||||
@@ -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<IEditorPaneRegistry>(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<IEditorFactoryRegistry>(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<void> {
|
||||
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);
|
||||
@@ -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<ICarouselImage> = [];
|
||||
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<void> {
|
||||
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`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<IImageCarouselService>('imageCarouselService');
|
||||
|
||||
export interface IImageCarouselService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/**
|
||||
* Extract images from a chat response's tool invocations.
|
||||
*/
|
||||
extractImagesFromResponse(response: IChatResponseViewModel): Promise<IImageCarouselCollection | undefined>;
|
||||
}
|
||||
|
||||
export class ImageCarouselService extends Disposable implements IImageCarouselService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
async extractImagesFromResponse(response: IChatResponseViewModel): Promise<IImageCarouselCollection | undefined> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ICarouselImage>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user