From 53e407469a6e18f0442231918a4edb1719c914ee Mon Sep 17 00:00:00 2001 From: Lakshay Khattar Date: Wed, 9 Sep 2026 12:18:20 +0530 Subject: [PATCH 1/2] chat: allow collapsing queued chat requests --- .../chat/browser/widget/chatListRenderer.ts | 44 ++- .../chat/browser/widget/media/chat.css | 36 ++- .../browser/widget/chatListRenderer.test.ts | 283 +++++++++++++++++- 3 files changed, 359 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index de71a92ea4902b..e234a4756b6af2 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'; @@ -741,6 +742,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer(); + private readonly requestExpansionState = new Map(); constructor( editorOptions: ChatEditorOptions, @@ -913,6 +915,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer 0) { const timestamp = renderChatRequestTimestamp(templateData.requestTimestampContainer, element.requestTimestamp); if (timestamp?.hoverText) { @@ -2375,6 +2385,38 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { + templateData.rowContainer.classList.toggle('chat-request-collapsed', !expanded); + 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"))); + 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..81b42e04fd2534 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,40 @@ 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; +} + +.interactive-item-container.chat-request-collapsed .value > :not(.chat-request-collapse-control) { + display: none !important; +} + /* Drag handle for reordering pending messages */ .interactive-item-container .chat-pending-drag-handle { position: absolute; diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts index 08c31c58af3bf2..a1abc425b66548 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts @@ -33,8 +33,8 @@ import { ChatCollapsibleContentPart } from '../../../browser/widget/chatContentP import { ChatRequestQueueKind, IChatMcpServersStartingSlow, IChatQuestionCarousel, IChatService, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { formatChatRequestTimestamp, formatChatResponseDetails, formatElapsedTime } from '../../../common/chatProgressFormatting.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, CollapsedToolsDisplayMode, ThinkingDisplayMode } from '../../../common/constants.js'; -import { ChatModel } from '../../../common/model/chatModel.js'; -import { ChatViewModel, IChatPendingDividerViewModel, IChatRendererContent, IChatResponseViewModel, isRequestVM, isResponseVM } from '../../../common/model/chatViewModel.js'; +import { ChatModel, ChatRequestModel } from '../../../common/model/chatModel.js'; +import { ChatViewModel, IChatPendingDividerViewModel, IChatRendererContent, IChatRequestViewModel, IChatResponseViewModel, isRequestVM, isResponseVM } from '../../../common/model/chatViewModel.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { ChatAgentService, IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ChatRequestTextPart } from '../../../common/requestParser/chatParserTypes.js'; @@ -45,6 +45,7 @@ import { getGeneratedImageResultParts, getGeneratedImageResultPartsFromContent } import { MockChatService } from '../../common/chatService/mockChatService.js'; import { IChatModelFeedbackSurveyService } from '../../../browser/feedbackSurvey/chatModelFeedbackSurveyService.js'; import { MockChatModelFeedbackSurveyService } from '../feedbackSurvey/mockChatModelFeedbackSurveyService.js'; +import '../../../browser/widget/media/chat.css'; suite('ChatListRenderer', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -712,6 +713,284 @@ suite('ChatListRenderer', () => { }); }); + suite('pending request collapse', () => { + function createRenderer(renderStyle?: 'minimal') { + const disposables = store.add(new DisposableStore()); + const instantiationService = workbenchInstantiationService(undefined, disposables); + 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) { + 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: { variables: [] }, + 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 the collapsed state and expansion control when the request starts processing', () => { + 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(); + }); + + 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); From 49af9453cf2bda583e20aafa37e8a007f06f6d9f Mon Sep 17 00:00:00 2001 From: Lakshay Khattar Date: Wed, 9 Sep 2026 12:58:26 +0530 Subject: [PATCH 2/2] chat: preserve queued request state through dequeue --- .../browser/actions/chatAccessibilityHelp.ts | 1 + .../chat/browser/widget/chatListRenderer.ts | 34 +++- .../chat/browser/widget/media/chat.css | 4 - .../common/chatService/chatServiceImpl.ts | 18 ++- .../contrib/chat/common/model/chatModel.ts | 7 + .../chat/common/model/chatViewModel.ts | 5 + .../browser/widget/chatListRenderer.test.ts | 98 +++++++++++- .../common/chatService/chatService.test.ts | 151 +++++++++++++++++- 8 files changed, 294 insertions(+), 24 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index 3462f16ee53931..77795eba55b81a 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -73,6 +73,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui content.push(localize('chat.modelPicker.optimizeFor', "In the tabbed model picker, the selected model's details open beside the list. Moving between model rows updates the details immediately without selecting a model. The card stays visible when the pointer leaves a row. Press Right Arrow from a model row to focus its details, and Left Arrow to return. When Auto's \"Optimize for\" options are available, Efficiency, Balance, and Intelligence remain visible while Auto is off. Use arrow keys to move between options, then Enter or Space to choose a preference and turn Auto on. Turning Auto off preserves the selected preference.")); 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 e234a4756b6af2..57f07b5f852929 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -915,7 +915,7 @@ 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.requestExpansionState.get(element.id) ?? true; + 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); @@ -2404,7 +2428,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer expanded ? localize('collapseRequest', "Collapse Message") - : localize('expandRequestTooltip', "Expand 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 })); 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 81b42e04fd2534..d78a1cc7fcac41 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -4794,10 +4794,6 @@ have to be updated for changes to the rules above, or to support more deeply nes flex-shrink: 0; } -.interactive-item-container.chat-request-collapsed .value > :not(.chat-request-collapse-control) { - display: none !important; -} - /* 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 4ce87591eef3a7..fda3210032debf 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -1462,7 +1462,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); @@ -1490,6 +1490,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, pendingRequestIds); + preservedRequest?.response?.reopen(); + return requestWithoutAgent; + }; let responseCreatedComplete = false; function completeResponseCreated(): void { if (!responseCreatedComplete && request?.response) { @@ -1542,7 +1547,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)); @@ -1678,7 +1683,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, pendingRequestIds); preservedRequest?.response?.reopen(); const thisRequest = request; completeResponseCreated(); @@ -1883,7 +1888,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 @@ -2112,7 +2117,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 0bf5db5e881522..5c0e156dd15bbc 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -120,6 +120,8 @@ export namespace IChatRequestVariableData { export interface IChatRequestModel { readonly id: string; + /** IDs of pending messages combined into this request, retained for the current session only. */ + readonly pendingRequestIds?: readonly string[]; readonly timestamp: number; readonly requestTimestamp: number | undefined; readonly version: number; @@ -391,6 +393,7 @@ export interface IChatRequestModelParameters { isCompleteAddedRequest?: boolean; modelId?: string; restoredId?: string; + pendingRequestIds?: readonly string[]; editedFileEvents?: IChatAgentEditedFileEvent[]; userSelectedTools?: UserSelectedTools; isSystemInitiated?: boolean; @@ -405,6 +408,7 @@ export interface IChatRequestModelParameters { export class ChatRequestModel implements IChatRequestModel { public readonly id: string; + public readonly pendingRequestIds?: readonly string[]; public response: ChatResponseModel | undefined; public shouldBeRemovedOnSend: IChatRequestDisablement | undefined; public readonly timestamp: number; @@ -491,6 +495,7 @@ export class ChatRequestModel implements IChatRequestModel { this.isCompleteAddedRequest = params.isCompleteAddedRequest ?? false; this.modelId = params.modelId; this.id = params.restoredId ?? 'request_' + generateUuid(); + this.pendingRequestIds = params.pendingRequestIds; this._editedFileEvents = params.editedFileEvents; this.userSelectedTools = params.userSelectedTools; this.isSystemInitiated = params.isSystemInitiated; @@ -3165,6 +3170,7 @@ export class ChatModel extends Disposable implements IChatModel { hideFromTranscript?: boolean, origin?: IChatRequestOrigin, isRequestHiddenFromTranscript?: boolean, + pendingRequestIds?: readonly string[], ): ChatRequestModel { const editedFileEvents = [...this.currentEditedFileEvents.values()]; this.currentEditedFileEvents.clear(); @@ -3175,6 +3181,7 @@ export class ChatModel extends Disposable implements IChatModel { : undefined; const request = new ChatRequestModel({ restoredId: id, + pendingRequestIds, session: this, message, variableData, diff --git a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts index f99f7495bb2b71..d08399e0c0483d 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts @@ -100,6 +100,7 @@ export interface IChatViewModel { export interface IChatRequestViewModel { readonly id: string; + readonly pendingRequestIds?: IChatRequestModel['pendingRequestIds']; readonly sessionResource: URI; /** This ID updates every time the underlying data changes */ readonly dataId: string; @@ -411,6 +412,10 @@ class ChatRequestViewModel implements IChatRequestViewModel { return this._model.id; } + get pendingRequestIds() { + return this._model.pendingRequestIds; + } + /** * An ID that changes when the request should be re-rendered. */ diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts index a1abc425b66548..53e9e26b36b5c1 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import * as dom from '../../../../../../base/browser/dom.js'; +import { IManagedHoverContentOrFactory } from '../../../../../../base/browser/ui/hover/hover.js'; import { mainWindow } from '../../../../../../base/browser/window.js'; import { timeout } from '../../../../../../base/common/async.js'; import { Event } from '../../../../../../base/common/event.js'; @@ -33,7 +34,7 @@ import { ChatCollapsibleContentPart } from '../../../browser/widget/chatContentP import { ChatRequestQueueKind, IChatMcpServersStartingSlow, IChatQuestionCarousel, IChatService, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { formatChatRequestTimestamp, formatChatResponseDetails, formatElapsedTime } from '../../../common/chatProgressFormatting.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, CollapsedToolsDisplayMode, ThinkingDisplayMode } from '../../../common/constants.js'; -import { ChatModel, ChatRequestModel } from '../../../common/model/chatModel.js'; +import { ChatModel, ChatRequestModel, IChatRequestVariableData } from '../../../common/model/chatModel.js'; import { ChatViewModel, IChatPendingDividerViewModel, IChatRendererContent, IChatRequestViewModel, IChatResponseViewModel, isRequestVM, isResponseVM } from '../../../common/model/chatViewModel.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { ChatAgentService, IChatAgentService } from '../../../common/participants/chatAgents.js'; @@ -714,9 +715,12 @@ suite('ChatListRenderer', () => { }); suite('pending request collapse', () => { - function createRenderer(renderStyle?: 'minimal') { + 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); @@ -754,12 +758,12 @@ suite('ChatListRenderer', () => { const template = renderer.renderTemplate(container); disposables.add(toDisposable(() => renderer.disposeTemplate(template))); - function addPending(text: string, kind = ChatRequestQueueKind.Queued) { + 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: { variables: [] }, + variableData, timestamp: 0, }); model.addPendingRequest(request, kind, {}); @@ -862,7 +866,7 @@ suite('ChatListRenderer', () => { }); } - test('retains the collapsed state and expansion control when the request starts processing', () => { + 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); @@ -893,6 +897,90 @@ suite('ChatListRenderer', () => { 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, 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'); 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 7a08309cbe7aba..1324c3bf922d02 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 @@ -57,7 +57,7 @@ import { ChatEditingSessionState, IChatEditingService, IChatEditingSession, IMod import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../common/languageModels.js'; import { ChatModel, IChatModel, ISerializableChatData, ISerializableChatModelInputState } from '../../../common/model/chatModel.js'; import { LocalChatSessionUri } from '../../../common/model/chatUri.js'; -import { ChatViewModel, isPendingDividerVM, isResponseVM } from '../../../common/model/chatViewModel.js'; +import { ChatViewModel, isPendingDividerVM, isRequestVM, isResponseVM } from '../../../common/model/chatViewModel.js'; import { ChatAgentService, IChatAgent, IChatAgentData, IChatAgentImplementation, IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ChatSlashCommandService, IChatSlashCommandService } from '../../../common/participants/chatSlashCommands.js'; import { IConfiguredHooksInfo, IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; @@ -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 () => {