diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index 61abb61cef82c1..29d38e7a01a28c 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -75,6 +75,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui content.push(localize('chat.modelPicker.pricingDetails', "Pricing Details expands in place without moving the model's controls. Expansion and collapse are immediate when reduced motion is enabled. If the details exceed the available space, use Page Up or Page Down while the model details have focus to scroll.")); content.push(localize('chat.modelPicker.search', "Type while the model list is focused to search across all providers. In the search field, use Up and Down Arrow to navigate results, Enter to select a model, and Escape to close the picker. Left and Right Arrow move the text cursor.")); content.push(localize('chat.fileChangesDisclosure', 'File change summaries show the total files, additions, and deletions. Focus the disclosure and press Enter or Space to show or hide the individual files. Focus an additions and deletions label and press Enter or Space to open the changes in a diff editor.')); + content.push(localize('chat.queuedMessageDisclosure', "Use Tab or Shift+Tab to focus a queued message's collapse or expand button. Press Enter or Space to switch between a one-line preview and the full message. Each message can be collapsed independently.")); } if (type === 'panelChat' || type === 'quickChat' || type === 'agentView') { if (type === 'quickChat') { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 7884a3adfac660..1456b9d99e66f8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -8,6 +8,7 @@ import { renderFormattedText } from '../../../../../base/browser/formattedTextRe import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; import { IActionViewItemOptions } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { alert } from '../../../../../base/browser/ui/aria/aria.js'; +import { ButtonWithIcon } from '../../../../../base/browser/ui/button/button.js'; import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IManagedHover } from '../../../../../base/browser/ui/hover/hover.js'; import { CachedListVirtualDelegate, IListElementRenderDetails } from '../../../../../base/browser/ui/list/list.js'; @@ -740,6 +741,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer(); + private readonly requestExpansionState = new Map(); constructor( editorOptions: ChatEditorOptions, @@ -912,6 +914,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer 0) { const timestamp = renderChatRequestTimestamp(templateData.requestTimestampContainer, element.requestTimestamp); if (timestamp?.hoverText) { @@ -2374,6 +2384,62 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer ({ element, display: element.style.display })); + const control = dom.$('.chat-request-collapse-control.chat-used-context-label'); + templateData.value.prepend(control); + const button = templateData.elementDisposables.add(new ButtonWithIcon(control, {})); + button.iconElement.setAttribute('aria-hidden', 'true'); + const preview = element.messageText.replace(/\s+/g, ' ').trim() || localize('requestCollapseMessage', "Message"); + let expanded = this.getRequestExpansionState(element) ?? true; + const update = () => { + templateData.rowContainer.classList.toggle('chat-request-collapsed', !expanded); + for (const content of contentElements) { + content.element.style.display = expanded ? content.display : 'none'; + } + button.label = expanded ? localize('requestCollapseMessage', "Message") : preview; + button.icon = expanded ? Codicon.chevronDownCompact : Codicon.chevronRightCompact; + button.element.ariaExpanded = String(expanded); + button.element.ariaLabel = expanded + ? localize('collapseRequest', "Collapse Message") + : localize('expandRequest', "Expand Message: {0}", preview); + }; + update(); + templateData.elementDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), button.element, () => expanded + ? localize('collapseRequest', "Collapse Message") + : localize('expandRequestTooltip', "Expand Message: {0}", preview))); + templateData.elementDisposables.add(button.onDidClick(e => { + dom.EventHelper.stop(e, true); + control.dispatchEvent(new CustomEvent(ChatCollapsibleContentPart.userToggleEvent, { bubbles: true })); + expanded = !expanded; + // Pending view models are recreated on updates. Keep the choice by request ID, + // including when the same request moves into the transcript. + this.requestExpansionState.set(element.id, expanded); + update(); + this.fireItemHeightChange(templateData); + })); + } + private getRequestMarkdown(element: IChatRequestViewModel, explicitFileOrImageVariables = element.variables.filter(isExplicitFileOrImageVariableEntry)): string | undefined { const markdown = isChatFollowup(element.message) ? element.message.message diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 38f68e05f0b4ca..d78a1cc7fcac41 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -3383,7 +3383,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } /* Hide the leading icon on collapsible parts by default; show-checkmarks restores it */ -.interactive-session .chat-used-context-label .monaco-icon-button > .codicon:first-child:not(.chat-collapsible-hover-chevron):not(.codicon-error-compact):not(.codicon-warning-compact) { +.interactive-session .chat-used-context-label:not(.chat-request-collapse-control) .monaco-icon-button > .codicon:first-child:not(.chat-collapsible-hover-chevron):not(.codicon-error-compact):not(.codicon-warning-compact) { display: none; } @@ -4764,6 +4764,36 @@ have to be updated for changes to the rules above, or to support more deeply nes } } +.interactive-item-container .chat-request-collapse-control { + max-width: 90%; + min-width: 0; + margin-left: auto; + margin-bottom: var(--vscode-spacing-size40); +} + +.interactive-item-container .chat-request-collapse-control .monaco-button { + width: auto; + max-width: 100%; + min-width: 0; + justify-content: flex-start; + gap: var(--vscode-spacing-size40); + padding: var(--vscode-spacing-size40); + color: var(--vscode-descriptionForeground); + border: none; +} + +.interactive-item-container .chat-request-collapse-control .monaco-button-mdlabel { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.interactive-item-container .chat-request-collapse-control .codicon { + font-size: var(--vscode-codiconFontSize-compact); + flex-shrink: 0; +} + /* Drag handle for reordering pending messages */ .interactive-item-container .chat-pending-drag-handle { position: absolute; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 32ed3684b8fe3d..413effaf4f5525 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -1464,7 +1464,7 @@ export class ChatService extends Disposable implements IChatService { return newTokenSource.token; } - private _sendRequestAsync(model: ChatModel, sessionResource: URI, parsedRequest: IParsedChatRequest, attempt: number, enableCommandDetection: boolean, defaultAgent: IChatAgentData, location: ChatAgentLocation, options?: IChatSendRequestOptions, preservedRequest?: ChatRequestModel, requestId?: string): IChatSendRequestResponseState { + private _sendRequestAsync(model: ChatModel, sessionResource: URI, parsedRequest: IParsedChatRequest, attempt: number, enableCommandDetection: boolean, defaultAgent: IChatAgentData, location: ChatAgentLocation, options?: IChatSendRequestOptions, preservedRequest?: ChatRequestModel, requestId?: string, pendingRequestIds?: readonly string[]): IChatSendRequestResponseState { const followupsCancelToken = this.refreshFollowupsCancellationToken(sessionResource); let request: ChatRequestModel | undefined; const agentPart = parsedRequest.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart); @@ -1492,6 +1492,11 @@ export class ChatService extends Disposable implements IChatService { const requestType = commandPart ? 'slashCommand' : 'string'; const responseCreated = new DeferredPromise(); + const addRequestWithoutAgent = () => { + const requestWithoutAgent = preservedRequest ?? model.addRequest(parsedRequest, { variables: [] }, attempt, options?.modeInfo, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, requestId, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, pendingRequestIds); + preservedRequest?.response?.reopen(); + return requestWithoutAgent; + }; let responseCreatedComplete = false; function completeResponseCreated(): void { if (!responseCreatedComplete && request?.response) { @@ -1544,7 +1549,7 @@ export class ChatService extends Disposable implements IChatService { return uri && (uri.scheme === COPILOT_SKILL_URI_SCHEME || uri.path.includes(TROUBLESHOOT_SKILL_PATH)); }); if (isTroubleshootCommand || hasTroubleshootSkill) { - request = model.addRequest(parsedRequest, { variables: [] }, attempt, options?.modeInfo); + request = addRequestWithoutAgent(); completeResponseCreated(); const settingsArg = encodeURIComponent(JSON.stringify(AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING)); @@ -1680,7 +1685,7 @@ export class ChatService extends Disposable implements IChatService { const initialAgent = agentPart?.agent ?? defaultAgent; const initialCommand = agentSlashCommandPart?.command; const initVariableData: IChatRequestVariableData = { variables: [] }; - request = preservedRequest ?? model.addRequest(parsedRequest, initVariableData, attempt, options?.modeInfo, initialAgent, initialCommand, options?.confirmation, options?.locationData, options?.attachedContext, undefined, options?.userSelectedModelId, options?.userSelectedTools?.get(), requestId, options?.isSystemInitiated, options?.systemInitiatedLabel, options?.terminalExecutionId, isTerminalCommand, undefined, options?.hideFromTranscript); + request = preservedRequest ?? model.addRequest(parsedRequest, initVariableData, attempt, options?.modeInfo, initialAgent, initialCommand, options?.confirmation, options?.locationData, options?.attachedContext, undefined, options?.userSelectedModelId, options?.userSelectedTools?.get(), requestId, options?.isSystemInitiated, options?.systemInitiatedLabel, options?.terminalExecutionId, isTerminalCommand, undefined, options?.hideFromTranscript, undefined, undefined, undefined, pendingRequestIds); preservedRequest?.response?.reopen(); const thisRequest = request; completeResponseCreated(); @@ -1885,7 +1890,7 @@ export class ChatService extends Disposable implements IChatService { agentOrCommandFollowups = this.chatAgentService.getFollowups(agent.id, requestProps, agentResult, history, followupsCancelToken); } else if (commandPart && this.chatSlashCommandService.hasCommand(commandPart.slashCommand.command, getChatSessionType(model.sessionResource))) { if (commandPart.slashCommand.silent !== true) { - request = model.addRequest(parsedRequest, { variables: [] }, attempt, options?.modeInfo); + request = addRequestWithoutAgent(); completeResponseCreated(); } // contributed slash commands @@ -2114,7 +2119,10 @@ export class ChatService extends Disposable implements IChatService { const agent = silentAgent ?? parsedRequest.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart)?.agent ?? defaultAgent; const agentSlashCommandPart = parsedRequest.parts.find((r): r is ChatRequestAgentSubcommandPart => r instanceof ChatRequestAgentSubcommandPart); - const responseState = this._sendRequestAsync(model, model.sessionResource, parsedRequest, firstRequest.request.attempt, !sendOptions.noCommandDetection, silentAgent ?? defaultAgent, location, sendOptions); + // Keep the canonical request ID when moving into the transcript. Merged steering + // messages also retain their source IDs so per-message UI state can follow them. + const pendingRequestIds = allRequests.length > 1 ? allRequests.map(req => req.request.id) : undefined; + const responseState = this._sendRequestAsync(model, model.sessionResource, parsedRequest, firstRequest.request.attempt, !sendOptions.noCommandDetection, silentAgent ?? defaultAgent, location, sendOptions, undefined, firstRequest.request.id, pendingRequestIds); const result: ChatSendResultSent = { kind: 'sent', diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 17df8b26391526..d62cd50262f770 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -136,6 +136,8 @@ export function getRestoredChatRequestSource(request: Pick { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -712,6 +714,371 @@ suite('ChatListRenderer', () => { }); }); + suite('pending request collapse', () => { + function createRenderer(renderStyle?: 'minimal', hoverService?: IHoverService) { + const disposables = store.add(new DisposableStore()); + const instantiationService = workbenchInstantiationService(undefined, disposables); + if (hoverService) { + instantiationService.stub(IHoverService, hoverService); + } + const configurationService = new TestConfigurationService(); + configurationService.setUserConfiguration('chat.editRequests', 'hover'); + configurationService.setUserConfiguration('chat.checkpoints.enabled', false); + configurationService.setUserConfiguration('chat.checkpoints.showFileChanges', false); + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(IChatService, new MockChatService()); + instantiationService.stub(IChatModelFeedbackSurveyService, new MockChatModelFeedbackSurveyService()); + instantiationService.stub(IChatAgentService, disposables.add(instantiationService.createInstance(ChatAgentService))); + + const model = disposables.add(instantiationService.createInstance(ChatModel, undefined, { initialLocation: ChatAgentLocation.Chat, canUseTools: true })); + const viewModel = disposables.add(instantiationService.createInstance(ChatViewModel, model, undefined)); + const container = mainWindow.document.createElement('div'); + container.classList.add('interactive-session', 'monaco-reduce-motion'); + container.style.width = '360px'; + container.style.height = 'auto'; + mainWindow.document.body.appendChild(container); + disposables.add(toDisposable(() => container.remove())); + const renderer = disposables.add(instantiationService.createInstance( + ChatListItemRenderer, + {} as ChatEditorOptions, + { editable: true, renderStyle }, + { + getListLength: () => viewModel.getItems().length, + onDidScroll: () => toDisposable(() => { }), + container, + currentChatMode: () => ChatModeKind.Agent, + isStickyScrollEnabled: () => false, + refreshStickyScroll: () => { }, + stickyScrollTopPadding: 0, + }, + undefined, + viewModel, + )); + renderer.layout(360); + const template = renderer.renderTemplate(container); + disposables.add(toDisposable(() => renderer.disposeTemplate(template))); + + function addPending(text: string, kind = ChatRequestQueueKind.Queued, variableData: IChatRequestVariableData = { variables: [] }) { + const lines = text.split('\n'); + const request = new ChatRequestModel({ + session: model, + message: { text, parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, 1, lines.length, lines[lines.length - 1].length + 1), text)] }, + variableData, + timestamp: 0, + }); + model.addPendingRequest(request, kind, {}); + return request; + } + + function render(element: IChatRequestViewModel | IChatPendingDividerViewModel) { + renderer.renderElement({ element, children: [], depth: 0, visibleChildrenCount: 0, visibleChildIndex: 0, collapsible: false, collapsed: false, visible: true, filterData: undefined }, 0, template); + } + + function renderRequest(request: ChatRequestModel) { + const requestViewModel = viewModel.getItems().find(item => isRequestVM(item) && item.id === request.id); + assert.ok(isRequestVM(requestViewModel)); + render(requestViewModel); + return requestViewModel; + } + + return { disposables, instantiationService, model, viewModel, container, renderer, template, addPending, render, renderRequest }; + } + + function getCollapseButton(template: IChatListItemTemplate): HTMLElement { + const button = template.value.querySelector('.chat-request-collapse-control [role="button"]'); + assert.ok(button, 'request should have a collapse control'); + return button; + } + + function renderedState(template: IChatListItemTemplate) { + const button = template.value.querySelector('.chat-request-collapse-control [role="button"]'); + return { + collapsed: template.rowContainer.classList.contains('chat-request-collapsed'), + expanded: button?.getAttribute('aria-expanded') ?? null, + label: button?.textContent ?? null, + controls: template.value.querySelectorAll('.chat-request-collapse-control').length, + pending: template.rowContainer.classList.contains('pending-request'), + }; + } + + test('preserves independent state across fresh view models and queue reorder', () => { + const fixture = createRenderer(); + const first = fixture.addPending('First queued message'); + const second = fixture.addPending('Second queued message', ChatRequestQueueKind.Steering); + fixture.renderRequest(first); + const initial = renderedState(fixture.template); + getCollapseButton(fixture.template).click(); + fixture.renderRequest(second); + const otherRequest = renderedState(fixture.template); + fixture.model.setPendingRequests([ + { requestId: second.id, kind: ChatRequestQueueKind.Queued }, + { requestId: first.id, kind: ChatRequestQueueKind.Queued }, + ]); + fixture.renderRequest(first); + fixture.renderRequest(first); + + assert.deepStrictEqual({ + initial, + otherRequest, + afterRerender: renderedState(fixture.template), + queuedMessages: fixture.model.getPendingRequests().map(pending => pending.request.message.text), + }, { + initial: { collapsed: false, expanded: 'true', label: 'Message', controls: 1, pending: true }, + otherRequest: { collapsed: false, expanded: 'true', label: 'Message', controls: 1, pending: true }, + afterRerender: { collapsed: true, expanded: 'false', label: 'First queued message', controls: 1, pending: true }, + queuedMessages: ['Second queued message', 'First queued message'], + }); + fixture.disposables.dispose(); + }); + + for (const renderStyle of [undefined, 'minimal'] as const) { + test(`collapsing a long prompt reclaims row height and keeps the chevron visible (${renderStyle ?? 'default'})`, () => { + const fixture = createRenderer(renderStyle); + const text = Array.from({ length: 12 }, (_, index) => `Queued step ${index}: review the changes and explain how to verify the expected behavior in the application.`).join('\n\n'); + fixture.renderRequest(fixture.addPending(text)); + const button = getCollapseButton(fixture.template); + const expandedHeight = fixture.template.rowContainer.getBoundingClientRect().height; + const icon = button.querySelector('.codicon'); + assert.ok(icon); + const expandedIconVisible = mainWindow.getComputedStyle(icon).display !== 'none'; + button.click(); + const collapsedHeight = fixture.template.rowContainer.getBoundingClientRect().height; + const collapsedIconVisible = mainWindow.getComputedStyle(icon).display !== 'none'; + button.click(); + const restoredHeight = fixture.template.rowContainer.getBoundingClientRect().height; + + assert.deepStrictEqual({ + longPromptHasHeight: expandedHeight > 100, + collapsedRowHasHeight: collapsedHeight > 0, + collapsedRowIsSmaller: collapsedHeight < expandedHeight / 2, + expansionRestoresHeight: Math.abs(restoredHeight - expandedHeight) < 1, + expandedIconVisible, + collapsedIconVisible, + }, { + longPromptHasHeight: true, + collapsedRowHasHeight: true, + collapsedRowIsSmaller: true, + expansionRestoresHeight: true, + expandedIconVisible: true, + collapsedIconVisible: true, + }); + fixture.disposables.dispose(); + }); + } + + test('retains renderer state for a processing request with the same identity', () => { + const fixture = createRenderer(); + const pending = fixture.addPending('Queued message to process'); + fixture.renderRequest(pending); + getCollapseButton(fixture.template).click(); + fixture.model.dequeuePendingRequest(); + const sent = fixture.model.addRequest(pending.message, pending.variableData, 0, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, pending.id); + const sentViewModel = fixture.renderRequest(sent); + const processing = renderedState(fixture.template); + const defaultRange = { start: 0, end: 100 }; + const collapsedStickySource = fixture.renderer.getStickyScrollSourceRange(sentViewModel, defaultRange); + getCollapseButton(fixture.template).click(); + + assert.deepStrictEqual({ + processing, + expanded: renderedState(fixture.template), + collapsedStickySource, + expandedStickySource: fixture.renderer.getStickyScrollSourceRange(sentViewModel, defaultRange), + requestText: fixture.viewModel.getItems().filter(isRequestVM).map(request => request.messageText), + pendingCount: fixture.model.getPendingRequests().length, + }, { + processing: { collapsed: true, expanded: 'false', label: 'Queued message to process', controls: 1, pending: false }, + expanded: { collapsed: false, expanded: 'true', label: 'Message', controls: 1, pending: false }, + collapsedStickySource: undefined, + expandedStickySource: { ...defaultRange, estimated: true }, + requestText: ['Queued message to process'], + pendingCount: 0, + }); + fixture.disposables.dispose(); + }); + + for (const collapsedIndex of [0, 1]) { + test(`inherits a collapse choice from merged steering message ${collapsedIndex + 1} and allows later expansion`, () => { + const fixture = createRenderer(); + const pending = [fixture.addPending('First steering message', ChatRequestQueueKind.Steering), fixture.addPending('Second steering message', ChatRequestQueueKind.Steering)]; + fixture.renderRequest(pending[1 - collapsedIndex]); + getCollapseButton(fixture.template).click(); + getCollapseButton(fixture.template).click(); + fixture.renderRequest(pending[collapsedIndex]); + getCollapseButton(fixture.template).click(); + fixture.model.dequeueAllSteeringRequests(); + const combinedText = pending.map(request => request.message.text).join('\n\n'); + const sent = fixture.model.addRequest( + { text: combinedText, parts: [new ChatRequestTextPart(new OffsetRange(0, combinedText.length), new Range(1, 1, 3, pending[1].message.text.length + 1), combinedText)] }, + { variables: [] }, 0, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, + pending[0].id, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, pending.map(request => request.id), + ); + const sentViewModel = fixture.renderRequest(sent); + const collapsed = renderedState(fixture.template); + const collapsedStickySource = fixture.renderer.getStickyScrollSourceRange(sentViewModel, { start: 0, end: 100 }); + getCollapseButton(fixture.template).click(); + fixture.renderRequest(sent); + fixture.renderRequest(sent); + + assert.deepStrictEqual({ + collapsed, + collapsedStickySource, + afterExpansionAndRerender: renderedState(fixture.template), + requestText: sentViewModel.messageText, + }, { + collapsed: { collapsed: true, expanded: 'false', label: 'First steering message Second steering message', controls: 1, pending: false }, + collapsedStickySource: undefined, + afterExpansionAndRerender: { collapsed: false, expanded: 'true', label: 'Message', controls: 1, pending: false }, + requestText: combinedText, + }); + fixture.disposables.dispose(); + }); + } + + test('hides file attachments on collapse and restores their original layout on expansion', () => { + const fixture = createRenderer(); + const pending = fixture.addPending('Review the attached file', ChatRequestQueueKind.Queued, { + variables: [{ kind: 'file', id: 'queued-file', name: 'example.ts', value: URI.file('/workspace/example.ts') }], + }); + fixture.renderRequest(pending); + const attachments = fixture.template.value.querySelector('.chat-request-file-attachments'); + assert.ok(attachments); + const originalDisplay = mainWindow.getComputedStyle(attachments).display; + getCollapseButton(fixture.template).click(); + const collapsedDisplay = mainWindow.getComputedStyle(attachments).display; + getCollapseButton(fixture.template).click(); + + assert.deepStrictEqual({ + originalDisplay, + collapsedDisplay, + restoredDisplay: mainWindow.getComputedStyle(attachments).display, + attachmentStillPresent: attachments.textContent?.includes('example.ts'), + }, { originalDisplay: 'flex', collapsedDisplay: 'none', restoredDisplay: 'flex', attachmentStillPresent: true }); + fixture.disposables.dispose(); + }); + + test('exposes the complete collapsed preview through its managed hover', () => { + const hoverContents: IManagedHoverContentOrFactory[] = []; + const fixture = createRenderer(undefined, { + ...NullHoverService, + setupManagedHover: (delegate, target, content, options) => { + if (target.closest('.chat-request-collapse-control')) { + hoverContents.push(content); + } + return NullHoverService.setupManagedHover(delegate, target, content, options); + }, + }); + fixture.renderRequest(fixture.addPending('A long queued message\nwith the rest of the preview available on hover')); + const content = hoverContents[0]; + assert.ok(typeof content === 'function'); + const expandedHover = content(); + getCollapseButton(fixture.template).click(); + + assert.deepStrictEqual({ expandedHover, collapsedHover: content() }, { + expandedHover: 'Collapse Message', + collapsedHover: 'Expand Message: A long queued message with the rest of the preview available on hover', + }); + fixture.disposables.dispose(); + }); + + test('clears collapse presentation when recycling a template for an unrelated request or divider', () => { + const fixture = createRenderer(); + const pending = fixture.addPending('Queued message'); + fixture.renderRequest(pending); + getCollapseButton(fixture.template).click(); + const normal = fixture.model.addRequest(pending.message, pending.variableData, 0); + fixture.renderRequest(normal); + const unrelatedRequest = renderedState(fixture.template); + fixture.renderRequest(pending); + fixture.render({ + kind: 'pendingDivider', + id: 'pending-divider-queued', + sessionResource: fixture.model.sessionResource, + isComplete: true, + dividerKind: ChatRequestQueueKind.Queued, + currentRenderedHeight: undefined, + }); + + assert.deepStrictEqual({ unrelatedRequest, divider: renderedState(fixture.template) }, { + unrelatedRequest: { collapsed: false, expanded: null, label: null, controls: 0, pending: false }, + divider: { collapsed: false, expanded: null, label: null, controls: 0, pending: false }, + }); + fixture.disposables.dispose(); + }); + + for (const [key, keyCode] of [['Enter', 13], [' ', 32]] as const) { + test(`${key === ' ' ? 'Space' : key} and click toggle without editing and announce each user toggle`, () => { + const fixture = createRenderer(); + fixture.renderRequest(fixture.addPending('Keyboard queued message')); + let editRequests = 0; + let userToggles = 0; + fixture.disposables.add(fixture.renderer.onDidClickRequest(() => editRequests++)); + fixture.disposables.add(dom.addDisposableListener(fixture.container, ChatCollapsibleContentPart.userToggleEvent, () => userToggles++)); + const button = getCollapseButton(fixture.template); + button.focus(); + const event = new mainWindow.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }); + Object.defineProperty(event, 'keyCode', { get: () => keyCode }); + button.dispatchEvent(event); + const collapsed = renderedState(fixture.template); + button.click(); + + assert.deepStrictEqual({ + collapsed, + expanded: renderedState(fixture.template), + focusable: button.tabIndex, + focused: mainWindow.document.activeElement === button, + hasAccessibleName: !!button.getAttribute('aria-label'), + defaultPrevented: event.defaultPrevented, + editRequests, + userToggles, + }, { + collapsed: { collapsed: true, expanded: 'false', label: 'Keyboard queued message', controls: 1, pending: true }, + expanded: { collapsed: false, expanded: 'true', label: 'Message', controls: 1, pending: true }, + focusable: 0, + focused: true, + hasAccessibleName: true, + defaultPrevented: true, + editRequests: 0, + userToggles: 2, + }); + fixture.disposables.dispose(); + }); + } + + test('renders the collapsed preview as normalized plain text', () => { + const fixture = createRenderer(); + fixture.renderRequest(fixture.addPending(' Review\n\t queued $(zap) message ')); + getCollapseButton(fixture.template).click(); + const button = getCollapseButton(fixture.template); + + assert.deepStrictEqual({ + label: button.textContent, + hasHtmlFromPrompt: !!button.querySelector('b'), + hasIconFromPrompt: !!button.querySelector('.codicon-zap'), + }, { + label: 'Review queued $(zap) message', + hasHtmlFromPrompt: false, + hasIconFromPrompt: false, + }); + fixture.disposables.dispose(); + }); + + test('clears remembered state when switching sessions', () => { + const fixture = createRenderer(); + const pending = fixture.addPending('Queued message'); + fixture.renderRequest(pending); + getCollapseButton(fixture.template).click(); + const otherModel = fixture.disposables.add(fixture.instantiationService.createInstance(ChatModel, undefined, { initialLocation: ChatAgentLocation.Chat, canUseTools: true })); + const otherViewModel = fixture.disposables.add(fixture.instantiationService.createInstance(ChatViewModel, otherModel, undefined)); + fixture.renderer.updateViewModel(otherViewModel); + fixture.renderer.updateViewModel(fixture.viewModel); + fixture.renderRequest(pending); + + assert.deepStrictEqual(renderedState(fixture.template), { collapsed: false, expanded: 'true', label: 'Message', controls: 1, pending: true }); + fixture.disposables.dispose(); + }); + }); + test('pending divider clears a timestamp from a recycled request template', () => { const disposables = store.add(new DisposableStore()); const instantiationService = workbenchInstantiationService(undefined, disposables); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 4164b9050606dd..5703ba3b9c28c7 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -907,14 +907,135 @@ suite('ChatService', () => { await response.data.responseCompletePromise; }); - test('multiple steering messages are combined into a single request', async () => { + for (const kind of [ChatRequestQueueKind.Queued, ChatRequestQueueKind.Steering]) { + test(`${kind} requests retain their identity after the active turn completes`, async () => { + const requestStarted = new DeferredPromise(); + const completeRequest = new DeferredPromise(); + const invokedRequestIds: string[] = []; + testDisposables.add(chatAgentService.registerAgent('queueIdentityAgent', getAgentData('queueIdentityAgent'))); + testDisposables.add(chatAgentService.registerAgentImplementation('queueIdentityAgent', { + async invoke(request) { + invokedRequestIds.push(request.requestId); + if (invokedRequestIds.length === 1) { + requestStarted.complete(); + await completeRequest.p; + } + return {}; + }, + })); + const testService = createChatService(); + const model = startSessionModel(testService).object; + const viewModel = testDisposables.add(instantiationService.createInstance(ChatViewModel, model, undefined)); + const active = await testService.sendRequest(model.sessionResource, 'active request', { agentId: 'queueIdentityAgent' }); + ChatSendResult.assertSent(active); + await requestStarted.p; + const queued = await testService.sendRequest(model.sessionResource, 'follow-up request', { agentId: 'queueIdentityAgent', queue: kind }); + assert.ok(ChatSendResult.isQueued(queued)); + const pendingId = model.getPendingRequests()[0].request.id; + + completeRequest.complete(); + await active.data.responseCompletePromise; + const sent = await queued.deferred; + ChatSendResult.assertSent(sent); + await sent.data.responseCompletePromise; + const request = model.getRequests()[1]; + + assert.deepStrictEqual({ + requestId: request.id, + responseRequestId: request.response?.requestId, + invokedRequestId: invokedRequestIds[1], + viewModelRequestId: viewModel.getItems().filter(isRequestVM)[1]?.id, + pendingRequestIds: request.pendingRequestIds, + pendingCount: model.getPendingRequests().length, + }, { + requestId: pendingId, + responseRequestId: pendingId, + invokedRequestId: pendingId, + viewModelRequestId: pendingId, + pendingRequestIds: undefined, + pendingCount: 0, + }); + }); + } + + test('queued slash commands retain their identity through processing and retry', async () => { + const slashCommandService = instantiationService.get(IChatSlashCommandService); + let executions = 0; + testDisposables.add(slashCommandService.registerSlashCommand({ + command: 'queued-command', + detail: 'Queued command', + locations: [ChatAgentLocation.Chat], + }, async () => { + executions++; + })); + const testService = createChatService(); + const model = startSessionModel(testService).object; + const queued = await testService.sendRequest(model.sessionResource, '/queued-command', { queue: ChatRequestQueueKind.Queued, pauseQueue: true }); + assert.ok(ChatSendResult.isQueued(queued)); + const pendingId = model.getPendingRequests()[0].request.id; + + testService.processPendingRequests(model.sessionResource); + const sent = await queued.deferred; + ChatSendResult.assertSent(sent); + await sent.data.responseCompletePromise; + const request = model.getRequests()[0]; + await testService.resendRequest(request, undefined, true); + + assert.deepStrictEqual({ + executions, + requestIds: model.getRequests().map(request => request.id), + sameRequest: model.getRequests()[0] === request, + isComplete: request.response?.isComplete, + pendingCount: model.getPendingRequests().length, + }, { + executions: 2, + requestIds: [pendingId], + sameRequest: true, + isComplete: true, + pendingCount: 0, + }); + }); + + test('blocked queued troubleshoot requests retain their identity', async () => { + const configurationService = instantiationService.get(IConfigurationService) as TestConfigurationService; + await configurationService.setUserConfiguration(AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING, false); + const testService = createChatService(); + const model = startSessionModel(testService).object; + const queued = await testService.sendRequest(model.sessionResource, 'investigate this issue', { + queue: ChatRequestQueueKind.Queued, + pauseQueue: true, + attachedContext: [{ id: 'troubleshoot-skill', name: 'troubleshoot', kind: 'generic', value: URI.from({ scheme: COPILOT_SKILL_URI_SCHEME, path: TROUBLESHOOT_SKILL_PATH }) }], + }); + assert.ok(ChatSendResult.isQueued(queued)); + const pendingId = model.getPendingRequests()[0].request.id; + + testService.processPendingRequests(model.sessionResource); + const sent = await queued.deferred; + ChatSendResult.assertSent(sent); + await sent.data.responseCompletePromise; + const request = model.getRequests()[0]; + + assert.deepStrictEqual({ + requestId: request.id, + blocked: request.response?.response.toString().includes(AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING), + pendingCount: model.getPendingRequests().length, + }, { + requestId: pendingId, + blocked: true, + pendingCount: 0, + }); + }); + + test('multiple steering messages are combined into a single request with their pending identities', async () => { const requestStarted = new DeferredPromise(); const completeRequest = new DeferredPromise(); const invokedRequests: string[] = []; + const invokedRequestIds: string[] = []; const slowAgent: IChatAgentImplementation = { async invoke(request, progress, history, token) { invokedRequests.push(request.message); + invokedRequestIds.push(request.requestId); if (invokedRequests.length === 1) { requestStarted.complete(); await completeRequest.p; @@ -929,6 +1050,7 @@ suite('ChatService', () => { const testService = createChatService(); const modelRef = testDisposables.add(startSessionModel(testService)); const model = modelRef.object; + const viewModel = testDisposables.add(instantiationService.createInstance(ChatViewModel, model, undefined)); // Start a request that will wait const response = await testService.sendRequest(model.sessionResource, 'first request', { agentId: 'slowAgent' }); @@ -944,15 +1066,16 @@ suite('ChatService', () => { assert.ok(ChatSendResult.isQueued(steering1)); assert.ok(ChatSendResult.isQueued(steering2)); assert.ok(ChatSendResult.isQueued(steering3)); + const pendingRequestIds = model.getPendingRequests().map(pending => pending.request.id); // Complete the first request - should trigger processing of combined steering requests completeRequest.complete(); await response.data.responseCompletePromise; // Wait for all deferred promises to resolve - await steering1.deferred; - await steering2.deferred; - await steering3.deferred; + const results = await Promise.all([steering1.deferred, steering2.deferred, steering3.deferred]); + ChatSendResult.assertSent(results[0]); + await results[0].data.responseCompletePromise; // Should have only invoked 2 requests: the initial and the combined steering assert.strictEqual(invokedRequests.length, 2, 'Should have only 2 invocations (initial + combined steering)'); @@ -961,6 +1084,24 @@ suite('ChatService', () => { assert.ok(invokedRequests[1].includes('steering2'), 'Combined message should include steering2'); assert.ok(invokedRequests[1].includes('steering3'), 'Combined message should include steering3'); assert.ok(invokedRequests[1].includes('\n\n'), 'Combined message should use \\n\\n as separator'); + const merged = model.getRequests()[1]; + assert.deepStrictEqual({ + requestId: merged.id, + responseRequestId: merged.response?.requestId, + invokedRequestId: invokedRequestIds[1], + pendingRequestIds: merged.pendingRequestIds, + viewModelPendingRequestIds: viewModel.getItems().filter(isRequestVM)[1]?.pendingRequestIds, + sameResult: results.every(result => result === results[0]), + pendingCount: model.getPendingRequests().length, + }, { + requestId: pendingRequestIds[0], + responseRequestId: pendingRequestIds[0], + invokedRequestId: pendingRequestIds[0], + pendingRequestIds, + viewModelPendingRequestIds: pendingRequestIds, + sameResult: true, + pendingCount: 0, + }); }); test('steering message on a streamed (activeResponseCallback) session dispatches immediately, mid-turn', async () => {