Group session artifact images in carousel (#331946)

* Group session artifact images in carousel

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Honor imageCarousel.chat.enabled for artifact images

Co-authored-by: benibenj <44439583+benibenj@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Benjamin Christopher Simmonds
2026-08-21 13:46:40 +00:00
committed by GitHub
co-authored by Copilot copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
parent be9a5231e1
commit 9711aeb181
2 changed files with 137 additions and 4 deletions
@@ -6,20 +6,28 @@
import { Codicon } from '../../../../base/common/codicons.js';
import { MarkdownString } from '../../../../base/common/htmlContent.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { getMediaMime } from '../../../../base/common/mime.js';
import { derived, IObservable, IReader } from '../../../../base/common/observable.js';
import { basename, getComparisonKey } from '../../../../base/common/resources.js';
import { ThemeIcon } from '../../../../base/common/themables.js';
import { URI } from '../../../../base/common/uri.js';
import { generateUuid } from '../../../../base/common/uuid.js';
import { localize } from '../../../../nls.js';
import { toAction } from '../../../../base/common/actions.js';
import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js';
import { ICommandService } from '../../../../platform/commands/common/commands.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js';
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
import type { IChatPillEntry, IChatPillSection } from '../../../../workbench/browser/chatPills.js';
import { openChatTurnFile, previewKind } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js';
import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js';
import type { IImageCarouselCollection } from '../../../../workbench/contrib/imageCarousel/browser/imageCarouselTypes.js';
import { SessionArtifactKind, SessionFileOperation, type ISessionArtifact, type ISessionFile } from '../../../services/sessions/common/session.js';
import type { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js';
const OPEN_IMAGE_CAROUSEL_COMMAND_ID = 'workbench.action.chat.openImageInCarousel';
const artifactIcons: ReadonlyMap<SessionArtifactKind, ThemeIcon> = new Map([
[SessionArtifactKind.PullRequest, Codicon.gitPullRequest],
[SessionArtifactKind.Issue, Codicon.issues],
@@ -42,9 +50,15 @@ const sectionOrder: readonly { readonly kind: SessionArtifactKind; readonly titl
export interface ISessionArtifactActions {
openExternal(link: URI): void;
openResource(uri: URI): void;
openImages(images: readonly ISessionArtifactImage[], startIndex: number): void;
copy(text: string): void;
}
export interface ISessionArtifactImage {
readonly uri: URI;
readonly mimeType: string;
}
function artifactValueKey(artifact: ISessionArtifact): string {
if (artifact.uri) {
return getComparisonKey(artifact.uri);
@@ -62,6 +76,11 @@ function artifactLocation(uri: URI, label: string): Pick<IChatPillEntry, 'ariaDe
};
}
function getImageMimeType(uri: URI): string | undefined {
const mimeType = getMediaMime(uri.path);
return mimeType?.startsWith('image/') ? mimeType : undefined;
}
function toEntry(artifact: ISessionArtifact, actions: ISessionArtifactActions): IChatPillEntry | undefined {
if (artifact.kind === SessionArtifactKind.File) {
if (!artifact.uri) {
@@ -109,11 +128,20 @@ function toEntry(artifact: ISessionArtifact, actions: ISessionArtifactActions):
* the previewable files the session wrote outside its workspace, de-duplicated
* with the agent's own entries winning.
*/
export function buildSessionArtifactSections(artifacts: readonly ISessionArtifact[], externalFiles: readonly ISessionFile[], actions: ISessionArtifactActions): readonly IChatPillSection[] {
export function buildSessionArtifactSections(artifacts: readonly ISessionArtifact[], externalFiles: readonly ISessionFile[], actions: ISessionArtifactActions, imageCarouselEnabled: boolean): readonly IChatPillSection[] {
const entriesByKind = new Map<SessionArtifactKind, IChatPillEntry[]>();
const images: ISessionArtifactImage[] = [];
const seen = new Set<string>();
for (const artifact of artifacts) {
const imageMimeType = artifact.uri ? getImageMimeType(artifact.uri) : undefined;
if (artifact.kind === SessionArtifactKind.File && artifact.uri && imageMimeType) {
if (!seen.has(artifactValueKey(artifact))) {
seen.add(artifactValueKey(artifact));
images.push({ uri: artifact.uri, mimeType: imageMimeType });
}
continue;
}
const entry = toEntry(artifact, actions);
if (!entry || seen.has(artifactValueKey(artifact))) {
continue;
@@ -125,10 +153,15 @@ export function buildSessionArtifactSections(artifacts: readonly ISessionArtifac
}
for (const file of externalFiles) {
if (file.operation === SessionFileOperation.Deleted || !previewKind(file.uri) || seen.has(getComparisonKey(file.uri))) {
const imageMimeType = getImageMimeType(file.uri);
if (file.operation === SessionFileOperation.Deleted || (!previewKind(file.uri) && !imageMimeType) || seen.has(getComparisonKey(file.uri))) {
continue;
}
seen.add(getComparisonKey(file.uri));
if (imageMimeType) {
images.push({ uri: file.uri, mimeType: imageMimeType });
continue;
}
const entries = entriesByKind.get(SessionArtifactKind.File) ?? [];
const label = basename(file.uri);
entries.push({ id: file.uri.toString(), label, resource: file.uri, ...artifactLocation(file.uri, label), open: () => actions.openResource(file.uri) });
@@ -137,6 +170,26 @@ export function buildSessionArtifactSections(artifacts: readonly ISessionArtifac
const sections: IChatPillSection[] = [];
for (const { kind, title } of sectionOrder) {
if (kind === SessionArtifactKind.File && images.length) {
sections.push({
title: localize('sessionArtifacts.images', "Images"),
entries: images.map(({ uri }, index) => {
const label = basename(uri);
return {
id: uri.toString(),
label,
resource: uri,
...artifactLocation(uri, label),
...(imageCarouselEnabled
? {
ariaLabel: localize('sessionArtifacts.openImage', "Open {0} in Images Preview", label),
open: () => actions.openImages(images, index),
}
: { open: () => actions.openResource(uri) }),
};
}),
});
}
const entries = entriesByKind.get(kind);
if (entries?.length) {
sections.push({ title, entries });
@@ -153,11 +206,14 @@ export class SessionArtifacts extends Disposable {
constructor(
session: IObservable<IActiveSession | undefined>,
@IClipboardService private readonly _clipboardService: IClipboardService,
@ICommandService private readonly _commandService: ICommandService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@IOpenerService private readonly _openerService: IOpenerService,
) {
super();
const imageCarouselEnabled = observableConfigValue<boolean>(ChatConfiguration.ImageCarouselEnabled, true, this._configurationService);
this.sections = derived(this, reader => {
const current = session.read(reader);
if (!current) {
@@ -167,6 +223,7 @@ export class SessionArtifacts extends Disposable {
current.artifacts?.read(reader) ?? [],
this._readExternalFiles(current, reader),
this._actions(),
imageCarouselEnabled.read(reader),
);
});
}
@@ -185,6 +242,22 @@ export class SessionArtifacts extends Disposable {
}
void this._openerService.open(uri, { fromUserGesture: true });
},
openImages: (images, startIndex) => {
const collection: IImageCarouselCollection = {
id: generateUuid(),
title: localize('sessionArtifacts.imageCarouselTitle', "Artifact Images"),
sections: [{
title: localize('sessionArtifacts.images', "Images"),
images: images.map(image => ({
id: image.uri.toString(),
name: basename(image.uri),
mimeType: image.mimeType,
uri: image.uri,
})),
}],
};
void this._commandService.executeCommand(OPEN_IMAGE_CAROUSEL_COMMAND_ID, { collection, startIndex });
},
copy: text => { void this._clipboardService.writeText(text); },
};
}
@@ -7,7 +7,7 @@ import assert from 'assert';
import { isMarkdownString } from '../../../../../base/common/htmlContent.js';
import { URI } from '../../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { buildSessionArtifactSections, type ISessionArtifactActions } from '../../browser/sessionArtifacts.js';
import { buildSessionArtifactSections, type ISessionArtifactActions, type ISessionArtifactImage } from '../../browser/sessionArtifacts.js';
import { type ISessionArtifact, SessionArtifactKind, SessionFileOperation } from '../../../../services/sessions/common/session.js';
suite('Session Artifacts', () => {
@@ -16,6 +16,7 @@ suite('Session Artifacts', () => {
const actions: ISessionArtifactActions = {
openExternal() { },
openResource() { },
openImages() { },
copy() { },
};
@@ -30,7 +31,7 @@ suite('Session Artifacts', () => {
{ id: 'resource', kind: SessionArtifactKind.Resource, label: 'Resource', uri: resourceUri },
];
const entries = buildSessionArtifactSections(artifacts, [{ uri: externalFileUri, operation: SessionFileOperation.Created }], actions).flatMap(section => section.entries);
const entries = buildSessionArtifactSections(artifacts, [{ uri: externalFileUri, operation: SessionFileOperation.Created }], actions, true).flatMap(section => section.entries);
assert.deepStrictEqual(entries.map(entry => {
const content = entry.hover?.content;
return {
@@ -47,4 +48,63 @@ suite('Session Artifacts', () => {
{ label: 'Resource', ariaLabel: 'Open Resource', ariaDescription: resourceUri.toString(true), hover: resourceUri.toString(true), tooltip: resourceUri.toString(true) },
]);
});
test('groups artifact images separately and opens all images in the carousel', () => {
const screenshotUri = URI.file('/artifacts/screenshot.png');
const diagramUri = URI.file('/external/diagram.jpg');
const reportUri = URI.file('/artifacts/report.md');
const opened: { images: readonly ISessionArtifactImage[]; startIndex: number }[] = [];
const imageActions: ISessionArtifactActions = {
...actions,
openImages: (images, startIndex) => opened.push({ images, startIndex }),
};
const artifacts: readonly ISessionArtifact[] = [
{ id: 'screenshot', kind: SessionArtifactKind.File, label: 'Screenshot', uri: screenshotUri },
{ id: 'report', kind: SessionArtifactKind.File, label: 'Report', uri: reportUri },
];
const sections = buildSessionArtifactSections(artifacts, [
{ uri: diagramUri, operation: SessionFileOperation.Created },
], imageActions, true);
const imageSection = sections.find(section => section.title === 'Images');
assert.ok(imageSection);
imageSection.entries[1].open();
assert.deepStrictEqual({
sections: sections.map(section => ({ title: section.title, labels: section.entries.map(entry => entry.label) })),
opened: opened.map(entry => ({ images: entry.images.map(image => image.uri.path), startIndex: entry.startIndex })),
}, {
sections: [
{ title: 'Images', labels: ['screenshot.png', 'diagram.jpg'] },
{ title: 'Files', labels: ['report.md'] },
],
opened: [{ images: ['/artifacts/screenshot.png', '/external/diagram.jpg'], startIndex: 1 }],
});
});
test('opens the image resource when the image carousel is disabled', () => {
const screenshotUri = URI.file('/artifacts/screenshot.png');
const opened: string[] = [];
const imageActions: ISessionArtifactActions = {
...actions,
openImages: () => opened.push('carousel'),
openResource: uri => opened.push(uri.path),
};
const artifacts: readonly ISessionArtifact[] = [
{ id: 'screenshot', kind: SessionArtifactKind.File, label: 'Screenshot', uri: screenshotUri },
];
const sections = buildSessionArtifactSections(artifacts, [], imageActions, false);
const imageSection = sections.find(section => section.title === 'Images');
assert.ok(imageSection);
imageSection.entries[0].open();
assert.deepStrictEqual({
ariaLabel: imageSection.entries[0].ariaLabel,
opened,
}, {
ariaLabel: 'Open screenshot.png',
opened: ['/artifacts/screenshot.png'],
});
});
});