microsoft/vscode · #333191

sessions: Improve GitHub context attachments

meganrogge · merged Aug 29, 20267 files · 146 + / 37
extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts31 + / 0
@@ -228,6 +228,25 @@ const OPEN_PULL_REQUEST_COMMAND_ID = 'github.copilot.chat.cloudSessions.openPull const CLEAR_CACHES_COMMAND_ID = 'github.copilot.chat.cloudSessions.clearCaches'; const CREATE_PULL_REQUEST_FOR_TASK_COMMAND_ID = 'github.copilot.chat.cloudSessions.createPullRequestForTask'; const OPEN_PULL_REQUEST_FOR_TASK_COMMAND_ID = 'github.copilot.chat.cloudSessions.openPullRequestForTask';++export function parseGitHubContextUrl(value: string, kind: 'issue' | 'pullRequest'): { readonly repoId: string; readonly url: string; readonly label: string } | undefined {+	const match = /^https:\/\/(?:www\.)?github\.com\/(?<owner>[^/?#]+)\/(?<repository>[^/?#]+)\/(?<resource>issues|pull)\/(?<number>[1-9]\d*)\/?(?:[?#].*)?$/i.exec(value.trim());+	if (!match?.groups) {+		return undefined;+	}+	const resource = match.groups.resource.toLowerCase();+	if ((resource === 'issues') !== (kind === 'issue')) {+		return undefined;+	}++	const repoId = `${match.groups.owner}/${match.groups.repository}`;+	return {+		repoId,+		url: `https://github.com/${repoId}/${resource}/${match.groups.number}`,+		label: `${repoId}#${match.groups.number}`,+	};+}+ /** Context key gating the chat-input "Create pull request" toolbar action: true while the viewed cloud task is settled and has no PR yet. */ const CAN_CREATE_PULL_REQUEST_CONTEXT_KEY = 'github.copilot.chat.cloudTaskCanCreatePullRequest'; /** Context key gating the chat-input "Open pull request" toolbar action: true once the viewed cloud task has a pull request. */@@ -706,6 +725,18 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C 						clearTimeout(searchTimeout); 					} 					const query = value.trim();+					const pastedSelection = parseGitHubContextUrl(query, kind);+					if (pastedSelection) {+						searchGeneration++;+						quickPick.busy = false;+						quickPick.items = [{+							label: pastedSelection.label,+							description: pastedSelection.repoId,+							alwaysShow: true,+							selection: pastedSelection,+						}];+						return;+					} 					if (query.length < 2) { 						if (query.length === 0) { 							void search('', ++searchGeneration);
extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCloudSessionsProvider.spec.ts23 + / 1
@@ -12,7 +12,7 @@ import { mock } from '../../../../util/common/test/simpleMock'; import { ChatRequestTurn2, ChatResponseMarkdownPart, ChatResponseTurn2, ChatToolInvocationPart } from '../../../../vscodeTypes'; import { ITaskApiClient, ListTaskEventsOptions, ListTasksOptions } from '../../common/taskApiTypes'; import { ChatSessionContentBuilder, extractTaskErrorDetail, formatTaskStoppedMessage } from '../copilotCloudSessionContentBuilder';-import { formatNewSessionContextReference, getCloudSessionItemMetadata, getCloudSessionResources, normalizeInitialSessionOptions, taskStateToChatSessionStatus } from '../copilotCloudSessionsProvider';+import { formatNewSessionContextReference, getCloudSessionItemMetadata, getCloudSessionResources, normalizeInitialSessionOptions, parseGitHubContextUrl, taskStateToChatSessionStatus } from '../copilotCloudSessionsProvider'; import { TaskApiBackend, parseRepoFromTaskUrl, isCloudCodingAgentTask } from '../taskApiBackend'; import { isActiveTaskState, isFailedTaskState } from '../../vscode/copilotCodingAgentUtils'; import { NullCloudBackendInstrumentation } from '../cloudBackendTelemetry';@@ -60,6 +60,28 @@ describe('copilotCloudSessionsProvider helpers', () => { 		]); 	}); +	it('parses pasted GitHub issue and pull request URLs for the matching picker', () => {+		expect({+			issue: parseGitHubContextUrl(' https://github.com/microsoft/vscode/ISSUES/333149#issuecomment-1 ', 'issue'),+			pullRequest: parseGitHubContextUrl('https://www.github.com/microsoft/vscode/pull/333149/', 'pullRequest'),+			wrongPicker: parseGitHubContextUrl('https://github.com/microsoft/vscode/pull/333149', 'issue'),+			unrelated: parseGitHubContextUrl('https://example.com/microsoft/vscode/issues/333149', 'issue'),+		}).toEqual({+			issue: {+				repoId: 'microsoft/vscode',+				url: 'https://github.com/microsoft/vscode/issues/333149',+				label: 'microsoft/vscode#333149',+			},+			pullRequest: {+				repoId: 'microsoft/vscode',+				url: 'https://github.com/microsoft/vscode/pull/333149',+				label: 'microsoft/vscode#333149',+			},+			wrongPicker: undefined,+			unrelated: undefined,+		});+	});+ 	it('coerces object-shaped initialSessionOptions into option entries', () => { 		const logService = new RecordingLogService(); 		const sessionResource = vscode.Uri.parse('copilot-cloud-agent:/1');
src/vs/sessions/contrib/chat/browser/media/chatInput.css33 + / 6
@@ -602,8 +602,8 @@ 	align-items: center; 	overflow: hidden; 	font-size: var(--vscode-fontSize-body2, 11px);-	padding: 0 4px 0 0;-	border: 1px solid var(--vscode-chat-requestBorder, var(--vscode-input-background, transparent));+	padding: 0 var(--vscode-spacing-size20) 0 var(--vscode-spacing-size40);+	border: var(--vscode-strokeThickness) solid var(--vscode-chat-requestBorder, var(--vscode-input-background, transparent)); 	border-radius: var(--vscode-cornerRadius-small); 	height: 18px; 	max-width: 200px;@@ -616,6 +616,30 @@ 	white-space: nowrap; } +.sessions-chat-attachment-content,+.sessions-chat-attachment-open {+	display: inline-flex;+	align-items: center;+	min-width: 0;+	height: 100%;+	overflow: hidden;+	padding: 0;+	border: 0;+	background: none;+	color: inherit;+	font: inherit;+}++.sessions-chat-attachment-open {+	cursor: pointer;+}++.sessions-chat-attachment-open:focus-visible,+.sessions-chat-attachment-remove:focus-visible {+	outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder);+	outline-offset: calc(-1 * var(--vscode-strokeThickness));+}+ /* Mirrors the workbench paste pill's `.attachment-additional-info`. */ .sessions-chat-attachment-info { 	opacity: 0.7;@@ -625,9 +649,9 @@ }  .sessions-chat-attachment-pill .codicon {-	font-size: 14px;+	font-size: var(--vscode-codiconFontSize-compact); 	flex-shrink: 0;-	padding: 0 3px;+	padding: 0 var(--vscode-spacing-size20); }  .sessions-chat-attachment-pill .monaco-icon-label {@@ -657,16 +681,19 @@ 	display: flex; 	align-items: center; 	justify-content: center;+	width: var(--vscode-spacing-size160);+	height: var(--vscode-spacing-size160); 	border: 0;+	border-radius: var(--vscode-cornerRadius-xSmall); 	padding: 0; 	background: none; 	cursor: pointer; 	flex-shrink: 0; 	color: var(--vscode-descriptionForeground);-	margin-left: 4px;+	margin-left: var(--vscode-spacing-size20); } -.sessions-chat-attachment-pill:hover {+.sessions-chat-attachment-pill.openable:hover { 	background-color: var(--vscode-toolbar-hoverBackground); } 
src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts31 + / 26
@@ -54,6 +54,8 @@ export interface INewChatAttachments { 	removeAttachment(id: string): void; } +const GITHUB_CONTEXT_ID_PREFIX = 'github-context:';+ /**  * Manages context attachments for the sessions new-chat widget.  *@@ -132,26 +134,41 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt 		for (const entry of visibleAttachments) { 			const pill = dom.append(this._container, dom.$('.sessions-chat-attachment-pill')); 			const resource = URI.isUri(entry.value) ? entry.value : isLocation(entry.value) ? entry.value.uri : undefined;+			const githubContextResource = entry.id.startsWith(GITHUB_CONTEXT_ID_PREFIX)+				? URI.parse(entry.id.slice(GITHUB_CONTEXT_ID_PREFIX.length))+				: undefined;+			const openResource = resource ?? githubContextResource;+			const imageData = entry.kind === 'image' ? coerceImageBuffer(entry.value) : undefined;+			const canOpen = Boolean(imageData || openResource || isPastedTextArtifact(entry));+			let content: HTMLElement;+			if (canOpen) {+				const openButton = dom.append(pill, dom.$<HTMLButtonElement>('button.sessions-chat-attachment-open'));+				openButton.type = 'button';+				openButton.setAttribute('aria-label', localize('openNamedAttachment', "Open {0}", entry.name));+				content = openButton;+				pill.classList.add('openable');+			} else {+				content = dom.append(pill, dom.$('span.sessions-chat-attachment-content'));+			} 			if (entry.kind === 'image') {-				const icon = dom.append(pill, renderIcon(Codicon.fileMedia));-				dom.append(pill, dom.$('span.sessions-chat-attachment-name', undefined, entry.name));-				const buffer = coerceImageBuffer(entry.value);-				if (buffer) {+				const icon = dom.append(content, renderIcon(Codicon.fileMedia));+				dom.append(content, dom.$('span.sessions-chat-attachment-name', undefined, entry.name));+				if (imageData) { 					// Swap the generic icon for a thumbnail once the shared helper 					// has decoded one, matching the workbench attachment pill.-					const preview = createImageHoverContent(resource, entry.name, buffer, entry.id, undefined, undefined, (url, isThumbnail) => {+					const preview = createImageHoverContent(resource, entry.name, imageData, entry.id, undefined, undefined, (url, isThumbnail) => { 						if (isThumbnail) { 							icon.replaceWith(dom.$('img.sessions-chat-attachment-image', { src: url, alt: '' })); 						} 					}); 					this._renderDisposables.add(preview.disposable); 				} 			} else if (entry.id.startsWith(ADDITIONAL_REPOSITORY_CONTEXT_ID_PREFIX)) {-				const icon = dom.append(pill, renderIcon(Codicon.repo));+				const icon = dom.append(content, renderIcon(Codicon.repo)); 				icon.setAttribute('aria-hidden', 'true');-				dom.append(pill, dom.$('span.sessions-chat-attachment-name', undefined, entry.name));+				dom.append(content, dom.$('span.sessions-chat-attachment-name', undefined, entry.name)); 			} else {-				const label = this._resourceLabels.create(pill, { supportIcons: true });+				const label = this._resourceLabels.create(content, { supportIcons: true }); 				this._renderDisposables.add(label); 				if (resource) { 					label.setFile(resource, {@@ -162,44 +179,32 @@ export class NewChatContextAttachments extends Disposable implements INewChatAtt 					// Matches the workbench paste pill: a file icon for the artifact's 					// language, and how much text it stands in for. 					label.setLabel(entry.fileName, undefined, { extraClasses: ['file-icon', `${entry.language}-lang-file-icon`] });-					dom.append(pill, dom.$('span.sessions-chat-attachment-info', undefined, localize('pastedLines', "Pasted {0}", entry.pastedLines)));+					dom.append(content, dom.$('span.sessions-chat-attachment-info', undefined, localize('pastedLines', "Pasted {0}", entry.pastedLines))); 				} else { 					label.setLabel(entry.name); 				} 			}  			// Click to open the resource or image-			const imageData = entry.kind === 'image' ? coerceImageBuffer(entry.value) : undefined; 			if (imageData) {-				pill.style.cursor = 'pointer';-				this._renderDisposables.add(registerOpenEditorListeners(pill, async () => {+				this._renderDisposables.add(registerOpenEditorListeners(content, async () => { 					if (this.configurationService.getValue<boolean>(ChatConfiguration.ImageCarouselEnabled)) { 						const imageResource = resource ?? URI.from({ scheme: 'data', path: entry.name }); 						await this.chatImageCarouselService.openCarouselAtResource(imageResource, imageData); 					} else if (resource) { 						await this.openerService.open(resource, { fromUserGesture: true }); 					} 				}));-			} else if (resource) {-				pill.style.cursor = 'pointer';-				this._renderDisposables.add(registerOpenEditorListeners(pill, async () => {-					await this.openerService.open(resource, { fromUserGesture: true });+			} else if (openResource) {+				this._renderDisposables.add(registerOpenEditorListeners(content, async () => {+					await this.openerService.open(openResource, { fromUserGesture: true }); 				})); 			} else if (isPastedTextArtifact(entry)) {-				pill.style.cursor = 'pointer';-				this._renderDisposables.add(registerOpenEditorListeners(pill, async () => {+				this._renderDisposables.add(registerOpenEditorListeners(content, async () => { 					await this.instantiationService.invokeFunction(openPastedTextArtifact, entry); 				})); 			} -			// Only expose the pill itself as a focusable button when it has an open-			// action; reference pills without a resource (e.g. `#session`) would-			// otherwise be a focusable control that does nothing.-			if (imageData || resource || isPastedTextArtifact(entry)) {-				pill.tabIndex = 0;-				pill.role = 'button';-			}- 			const removeButton = dom.append(pill, dom.$<HTMLButtonElement>('button.sessions-chat-attachment-remove')); 			removeButton.type = 'button'; 			removeButton.title = localize('removeAttachment', "Remove");
src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts1 + / 1
@@ -58,7 +58,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat 		content.push(localize('sessionsChat.dictation', "When dictation is configured, dictate your message into the input{0}. Tap to start and stop, or hold to dictate only while pressed. If the speech-to-text model is still preparing, activate the dictation control again to cancel.", '<keybinding:sessions.action.chat.toggleDictation>')); 		content.push(localize('sessionsChat.voiceMode', "Start or stop Voice Mode to interact with the agent using your microphone{0}.", '<keybinding:agentsVoice.startVoiceInChat>')); 		content.push(localize('sessionsChat.micContextMenu', "To choose a microphone or turn off dictation or Voice Mode, focus the microphone button in the input toolbar and open its context menu (for example Shift+F10)."));-		content.push(localize('sessionsChat.contextReferences', "Type # in the chat input to attach context. Use #file to reference a file or folder, or #session to reference another agent session. Referencing a session together with the /troubleshoot command analyzes that session's logs instead of the current one. Accept a suggestion with Tab or Enter; the reference appears as a pill above the input that you can remove."));+		content.push(localize('sessionsChat.contextReferences', "Type # in the chat input to attach context. Use #file to reference a file or folder, or #session to reference another agent session. Referencing a session together with the /troubleshoot command analyzes that session's logs instead of the current one. Accept a suggestion with Tab or Enter. Openable references appear as buttons above the input; activate one to open it, or use its Remove button to detach it.")); 		content.push(localize('sessionsChat.pastedText', "Long pasted text is stored as an attached text item and replaced in the input with a numbered inline reference.")); 		content.push(localize('sessionsChat.pasteAsText', "To paste the clipboard as plain text, without converting it to Markdown or storing it as an attachment, invoke Paste as Text{0}.", '<keybinding:editor.action.pasteAsText>')); 		content.push(localize('sessionsChat.backgroundActivities', "Press Shift+Tab from the chat input to reach metadata and status pills above it, then press Enter or Space to activate a pill. Live browsers appear in their own pill, and the chat's subagents of any status appear in another. A pill with more than one entry opens a picker; use the up and down arrows to navigate, Enter to open an entry, and Escape to dismiss the picker and return focus to the pill."));
src/vs/sessions/contrib/chat/test/browser/newChatInput.test.ts25 + / 1
@@ -63,6 +63,9 @@ interface IAttachmentRenderingHarness { 			setFile(resource: URI, options: object): void; 		}; 	};+	readonly openerService: {+		open(resource: URI): Promise<boolean>;+	}; 	removeAttachment(id: string): void; } @@ -202,12 +205,13 @@ suite('NewChatInputWidget', () => { 		}); 	}); -	test('renders a keyboard-reachable remove button for string context pills', () => {+	test('renders GitHub context pills as openable with a keyboard-reachable remove button', async () => { 		const container = document.createElement('div'); 		const entry = toPasteVariableEntry('microsoft/vscode#332825', 'GitHub context: https://github.com/microsoft/vscode/pull/332825', { 			id: 'github-context:https://github.com/microsoft/vscode/pull/332825', 		}); 		let removed: string | undefined;+		let opened: string | undefined; 		const renderDisposables = disposables.add(new DisposableStore()); 		updateAttachmentRendering.call({ 			_container: container,@@ -221,26 +225,45 @@ suite('NewChatInputWidget', () => { 					setFile: () => { }, 				}), 			},+			openerService: {+				open: async resource => {+					opened = resource.toString();+					return true;+				},+			}, 			removeAttachment: id => removed = id, 		}); 		const removeButton = container.querySelector<HTMLButtonElement>('.sessions-chat-attachment-remove'); 		const pill = container.querySelector<HTMLElement>('.sessions-chat-attachment-pill');+		const openButton = container.querySelector<HTMLButtonElement>('.sessions-chat-attachment-open'); 		let bubbledKeyDown = false; 		pill?.addEventListener('keydown', () => bubbledKeyDown = true); 		removeButton?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));+		openButton?.click();+		await Promise.resolve(); 		removeButton?.click();  		assert.deepStrictEqual({+			pillRole: pill?.getAttribute('role'),+			pillTabIndex: pill?.tabIndex,+			openTagName: openButton?.tagName,+			openAriaLabel: openButton?.getAttribute('aria-label'), 			tagName: removeButton?.tagName, 			tabIndex: removeButton?.tabIndex, 			ariaLabel: removeButton?.getAttribute('aria-label'), 			bubbledKeyDown,+			opened, 			removed, 		}, {+			pillRole: null,+			pillTabIndex: -1,+			openTagName: 'BUTTON',+			openAriaLabel: 'Open microsoft/vscode#332825', 			tagName: 'BUTTON', 			tabIndex: 0, 			ariaLabel: 'Remove microsoft/vscode#332825', 			bubbledKeyDown: false,+			opened: 'https://github.com/microsoft/vscode/pull/332825', 			removed: entry.id, 		}); 	});@@ -276,6 +299,7 @@ suite('NewChatInputWidget', () => { 					setFile: (_resource, _options) => pill.textContent = 'docs', 				}), 			},+			openerService: { open: async () => true }, 			removeAttachment: () => { }, 		}); 
test/componentFixtures/blocks-ci-screenshots.md2 + / 2
@@ -181,10 +181,10 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/fe4b95bf8348637bba9f8c0dda791924e6c67fd7b5d173398f9b2c0bfc9f7071)  #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Dark-![screenshot](https://hediet-screenshots.azurewebsites.net/images/6a5bf30c31efb3f541b310b04a0b2a864c3ebd5af771a2927a83ca0cb12fb13f)+![screenshot](https://hediet-screenshots.azurewebsites.net/images/f3db1a2e75f5320f7047aac91b148f6600fea29ce8135d370a39fa964e64e873)  #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Light-![screenshot](https://hediet-screenshots.azurewebsites.net/images/85144b21574a7f0df6efaa70ff311e5b7a5da6f413e5afb6741130d04482a926)+![screenshot](https://hediet-screenshots.azurewebsites.net/images/284dc3b398fe35444af49cd1726f3efdf071835f9a12b173c624b16f329dc738)  #### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/d65a5c982df3188ca688e5b0c317ecc5f0be1451a0061f56dab251a43d708c21)