microsoft/vscode · #333191
sessions: Improve GitHub context attachments
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 @@  #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Dark-+ #### sessions/chat/newWidget/newChatWidget/NewSessionAttachedContext/Light-+ #### sessions/chat/newWidget/newChatWidget/NewSessionGitHubContextPicker/Dark 