From 8470b2cbd7b6ea42d3a20e44e08882ec65bf4fba Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Thu, 5 Feb 2026 13:17:42 -0800 Subject: [PATCH] move questions carousel above input part + many ux fixes (#292990) * move questions carousel to input part, many fixes * some fixes --- .../chatQuestionCarouselPart.ts | 90 +++++++++++------ .../media/chatQuestionCarousel.css | 37 +++---- .../chat/browser/widget/chatListRenderer.ts | 97 +++++++++++++++---- .../browser/widget/input/chatInputPart.ts | 56 ++++++++++- .../chat/browser/widget/media/chat.css | 17 ++++ .../contrib/chat/common/model/chatModel.ts | 2 +- .../chatQuestionCarouselPart.test.ts | 2 +- 7 files changed, 223 insertions(+), 78 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts index 4bf4872c009..d5e8cb44102 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -5,8 +5,6 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../../../base/browser/keyboardEvent.js'; -import { getBaseLayerHoverDelegate } from '../../../../../../base/browser/ui/hover/hoverDelegate2.js'; -import { getDefaultHoverDelegate } from '../../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; @@ -21,6 +19,8 @@ import { IChatContentPart, IChatContentPartRenderContext } from './chatContentPa import { IChatRendererContent, isResponseVM } from '../../../common/model/chatViewModel.js'; import { ChatTreeItem } from '../../chat.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; +import { HoverPosition } from '../../../../../../base/browser/ui/hover/hoverWidget.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import './media/chatQuestionCarousel.css'; export interface IChatQuestionCarouselOptions { @@ -44,6 +44,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent private _navigationButtons: HTMLElement | undefined; private _prevButton: Button | undefined; private _nextButton: Button | undefined; + private readonly _nextButtonHover: MutableDisposable<{ dispose(): void }> = this._register(new MutableDisposable()); private _skipAllButton: Button | undefined; private _isSkipped = false; @@ -61,9 +62,10 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent private readonly _interactiveUIStore: MutableDisposable = this._register(new MutableDisposable()); constructor( - private readonly carousel: IChatQuestionCarousel, + public readonly carousel: IChatQuestionCarousel, context: IChatContentPartRenderContext, private readonly _options: IChatQuestionCarouselOptions, + @IHoverService private readonly _hoverService: IHoverService, ) { super(); @@ -98,10 +100,11 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent if (carousel.allowSkip) { this._closeButtonContainer = dom.$('.chat-question-close-container'); const skipAllTitle = localize('chat.questionCarousel.skipAllTitle', 'Skip all questions'); - const skipAllButton = interactiveStore.add(new Button(this._closeButtonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: skipAllTitle })); + const skipAllButton = interactiveStore.add(new Button(this._closeButtonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); skipAllButton.label = `$(${Codicon.close.id})`; skipAllButton.element.classList.add('chat-question-nav-arrow', 'chat-question-close'); skipAllButton.element.setAttribute('aria-label', skipAllTitle); + interactiveStore.add(this._hoverService.setupDelayedHover(skipAllButton.element, { content: skipAllTitle })); this._skipAllButton = skipAllButton; } @@ -121,14 +124,14 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const arrowsContainer = dom.$('.chat-question-nav-arrows'); const previousLabel = localize('previous', 'Previous'); - const prevButton = interactiveStore.add(new Button(arrowsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: previousLabel })); + const prevButton = interactiveStore.add(new Button(arrowsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); prevButton.element.classList.add('chat-question-nav-arrow', 'chat-question-nav-prev'); prevButton.label = `$(${Codicon.chevronLeft.id})`; prevButton.element.setAttribute('aria-label', previousLabel); + interactiveStore.add(this._hoverService.setupDelayedHover(prevButton.element, { content: previousLabel })); this._prevButton = prevButton; - const nextLabel = localize('next', 'Next'); - const nextButton = interactiveStore.add(new Button(arrowsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: nextLabel })); + const nextButton = interactiveStore.add(new Button(arrowsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); nextButton.element.classList.add('chat-question-nav-arrow', 'chat-question-nav-next'); nextButton.label = `$(${Codicon.chevronRight.id})`; this._nextButton = nextButton; @@ -430,16 +433,16 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const nextLabel = localize('next', 'Next'); if (isLastQuestion) { this._nextButton!.label = submitLabel; - this._nextButton!.element.title = submitLabel; this._nextButton!.element.setAttribute('aria-label', submitLabel); // Switch to primary style for submit this._nextButton!.element.classList.add('chat-question-nav-submit'); + this._nextButtonHover.value = this._hoverService.setupDelayedHover(this._nextButton!.element, { content: submitLabel }); } else { this._nextButton!.label = `$(${Codicon.chevronRight.id})`; - this._nextButton!.element.title = nextLabel; this._nextButton!.element.setAttribute('aria-label', nextLabel); // Keep secondary style for next this._nextButton!.element.classList.remove('chat-question-nav-submit'); + this._nextButtonHover.value = this._hoverService.setupDelayedHover(this._nextButton!.element, { content: nextLabel }); } this._onDidChangeHeight.fire(); @@ -520,7 +523,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent options.forEach((option, index) => { if (previousSelectedValue !== undefined && option.value === previousSelectedValue) { selectedIndex = index; - } else if (selectedIndex === -1 && defaultOptionId !== undefined && option.id === defaultOptionId) { + } else if (selectedIndex === -1 && !previousFreeform && defaultOptionId !== undefined && option.id === defaultOptionId) { selectedIndex = index; } }); @@ -589,13 +592,22 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent listItem.classList.add('selected'); } - this._inputBoxes.add(getBaseLayerHoverDelegate().setupManagedHover(getDefaultHoverDelegate('mouse'), listItem, option.label)); - - // Click handler + // if we select an option, clear text and go to next question this._inputBoxes.add(dom.addDisposableListener(listItem, dom.EventType.CLICK, (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); updateSelection(index); + const freeform = this._freeformTextareas.get(question.id); + if (freeform) { + freeform.value = ''; + } + this.handleNext(); + })); + + this._inputBoxes.add(this._hoverService.setupDelayedHover(listItem, { + content: option.label, + position: { hoverPosition: HoverPosition.BELOW }, + appearance: { showPointer: true } })); selectContainer.appendChild(listItem); @@ -683,16 +695,22 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(freeformTextarea), () => autoResize())); } - // focus on the row when first rendered - if (this._options.shouldAutoFocus !== false && listItems.length > 0) { - const focusIndex = selectedIndex >= 0 ? selectedIndex : 0; - // if no default, select the first answer - if (selectedIndex < 0) { - updateSelection(0); + // focus on the row when first rendered or textarea if it has content + if (this._options.shouldAutoFocus !== false) { + if (previousFreeform) { + this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(freeformTextarea), () => { + freeformTextarea.focus(); + })); + } else if (listItems.length > 0) { + const focusIndex = selectedIndex >= 0 ? selectedIndex : 0; + // if no default and no freeform text, select the first answer + if (selectedIndex < 0) { + updateSelection(0); + } + this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(selectContainer), () => { + listItems[focusIndex]?.focus(); + })); } - this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(selectContainer), () => { - listItems[focusIndex]?.focus(); - })); } } @@ -729,7 +747,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent let isChecked = false; if (previousSelectedValues && previousSelectedValues.length > 0) { isChecked = previousSelectedValues.includes(option.value); - } else if (defaultOptionIds.includes(option.id)) { + } else if (!previousFreeform && defaultOptionIds.includes(option.id)) { isChecked = true; } @@ -791,7 +809,11 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } })); - this._inputBoxes.add(getBaseLayerHoverDelegate().setupManagedHover(getDefaultHoverDelegate('mouse'), listItem, option.label)); + this._inputBoxes.add(this._hoverService.setupDelayedHover(listItem, { + content: option.label, + position: { hoverPosition: HoverPosition.BELOW }, + appearance: { showPointer: true } + })); selectContainer.appendChild(listItem); checkboxes.push(checkbox); @@ -868,13 +890,19 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(freeformTextarea), () => autoResize())); } - // Focus on the appropriate row when rendered (first checked row, or first row if none) - if (this._options.shouldAutoFocus !== false && listItems.length > 0) { - const initialFocusIndex = firstCheckedIndex >= 0 ? firstCheckedIndex : 0; - focusedIndex = initialFocusIndex; - this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(selectContainer), () => { - listItems[initialFocusIndex]?.focus(); - })); + // Focus on the appropriate row when rendered or textarea if it has content + if (this._options.shouldAutoFocus !== false) { + if (previousFreeform) { + this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(freeformTextarea), () => { + freeformTextarea.focus(); + })); + } else if (listItems.length > 0) { + const initialFocusIndex = firstCheckedIndex >= 0 ? firstCheckedIndex : 0; + focusedIndex = initialFocusIndex; + this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(selectContainer), () => { + listItems[initialFocusIndex]?.focus(); + })); + } } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 8ba0e1da646..98c05a0bc8c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -62,10 +62,14 @@ .chat-question-header-row { display: flex; justify-content: space-between; - align-items: flex-start; + align-items: center; gap: 8px; min-width: 0; - padding-bottom: 12px; + padding-bottom: 5px; + margin-left: -16px; + margin-right: -16px; + padding-left: 16px; + padding-right: 16px; border-bottom: 1px solid var(--vscode-chat-requestBorder); } @@ -100,7 +104,7 @@ display: flex; justify-content: space-between; align-items: center; - padding: 8px 16px; + padding: 4px 16px; border-top: 1px solid var(--vscode-chat-requestBorder); background: var(--vscode-chat-requestBackground); } @@ -172,7 +176,7 @@ display: flex; flex-direction: column; background: var(--vscode-chat-requestBackground); - padding: 12px 16px; + padding: 8px 16px 10px 16px; overflow: hidden; } @@ -214,7 +218,7 @@ .chat-question-list { display: flex; flex-direction: column; - gap: 0; + gap: 3px; outline: none; padding: 4px 0; } @@ -228,7 +232,7 @@ display: flex; align-items: center; gap: 8px; - padding: 6px 8px; + padding: 3px 8px; cursor: pointer; border-radius: 3px; user-select: none; @@ -238,7 +242,7 @@ background-color: var(--vscode-list-hoverBackground); } -.interactive-session .interactive-response .value { +.interactive-input-part .chat-question-carousel-widget-container .chat-question-input-container { .chat-question-list-item:focus:not(.selected), .chat-question-list:focus { outline: none; @@ -270,7 +274,7 @@ align-items: center; justify-content: center; min-width: 14px; - padding: 2px 4px; + padding: 0px 4px; border-style: solid; border-width: 1px; border-radius: 3px; @@ -285,7 +289,6 @@ } .chat-question-freeform-number { - margin-top: 4px; height: fit-content; } @@ -325,16 +328,6 @@ margin-right: 0; } -.chat-question-list-checkbox.monaco-custom-toggle.checked { - background-color: var(--vscode-button-background) !important; - border-color: var(--vscode-button-background) !important; - color: var(--vscode-button-foreground) !important; - align-content: center; -} - -.chat-question-list-checkbox.monaco-custom-toggle.checked .codicon { - color: var(--vscode-button-foreground) !important; -} /* Label in list item */ .chat-question-list-label { @@ -457,7 +450,7 @@ margin-left: 8px; display: flex; flex-direction: row; - align-items: flex-start; + align-items: center; gap: 8px; } @@ -468,9 +461,9 @@ .chat-question-freeform-textarea { width: 100%; - min-height: 32px; + min-height: 24px; max-height: 200px; - padding: 6px 8px; + padding: 3px 8px; border: 1px solid var(--vscode-input-border, var(--vscode-chat-requestBorder)); background-color: var(--vscode-input-background); color: var(--vscode-input-foreground); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index d442181f223..1b15146851d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -1967,46 +1967,101 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { - // Mark the carousel as used and store the answers - const answersRecord = answers ? Object.fromEntries(answers) : undefined; - if (answersRecord) { - carousel.data = answersRecord; - } - carousel.isUsed = true; - - // Notify the extension about the carousel answers to resolve the deferred promise - if (isResponseVM(context.element) && carousel.resolveId) { - this.chatService.notifyQuestionCarouselAnswer(context.element.requestId, carousel.resolveId, answersRecord); - } - - // Remove from pending carousels - this.removeCarouselFromTracking(context, part); + const handleSubmit = async (answers: Map | undefined, part: ChatQuestionCarouselPart) => { + // Mark the carousel as used and store the answers + const answersRecord = answers ? Object.fromEntries(answers) : undefined; + if (answersRecord) { + carousel.data = answersRecord; } + carousel.isUsed = true; + + // Notify the extension about the carousel answers to resolve the deferred promise + if (isResponseVM(context.element) && carousel.resolveId) { + this.chatService.notifyQuestionCarouselAnswer(context.element.requestId, carousel.resolveId, answersRecord); + } + + // Remove from pending carousels + this.removeCarouselFromTracking(context, part); + + // Clear from input part (always clear on submit, no response check needed) + widget?.input.clearQuestionCarousel(); + }; + + // If carousel is already used or response is complete/canceled, render summary inline in the list + const responseIsComplete = isResponseVM(context.element) && context.element.isComplete; + const inputPartHasCarousel = widget?.input.questionCarousel !== undefined; + + if (carousel.isUsed || responseIsComplete) { + // Clear the carousel from input part when response completes (stopped/canceled) + // Only clear if this response's carousel is currently displayed (pass responseId) + if (responseIsComplete && inputPartHasCarousel && responseId) { + widget?.input.clearQuestionCarousel(responseId); + } + + const part = this.instantiationService.createInstance(ChatQuestionCarouselPart, carousel, context, { + shouldAutoFocus: false, + onSubmit: async (answers) => handleSubmit(answers, part) + }); + return part; + } + + // Render the active carousel in the input part (above the input box) + const part = widget?.input.renderQuestionCarousel(carousel, context, { + shouldAutoFocus, + onSubmit: async (answers) => handleSubmit(answers, part!) }); + // If we couldn't render in the input part, fall back to inline rendering + if (!part) { + const fallbackPart = this.instantiationService.createInstance(ChatQuestionCarouselPart, carousel, context, { + shouldAutoFocus, + onSubmit: async (answers) => handleSubmit(answers, fallbackPart) + }); + return fallbackPart; + } + // If global auto-approve (yolo mode) is enabled, skip with defaults immediately if (!carousel.isUsed && this.configService.getValue(ChatConfiguration.GlobalAutoApprove)) { part.skip(); } // Track the carousel for auto-skip when user submits a new message + // Only add tracking if not already tracked (prevents duplicate tracking on re-render) if (isResponseVM(context.element) && carousel.allowSkip && !carousel.isUsed) { let carousels = this.pendingQuestionCarousels.get(context.element.sessionResource); if (!carousels) { carousels = new Set(); this.pendingQuestionCarousels.set(context.element.sessionResource, carousels); } - carousels.add(part); + if (!carousels.has(part)) { + carousels.add(part); - // Clean up when the part is disposed - part.addDisposable({ dispose: () => this.removeCarouselFromTracking(context, part) }); + // Clean up when the part is disposed + part.addDisposable({ dispose: () => this.removeCarouselFromTracking(context, part) }); + } } - return part; + // Return a placeholder that will re-render as a summary when the carousel is used or response is complete/stopped + return this.renderNoContent((other, _followingContent, element) => { + // Re-render (return false) if: + // - carousel was used/submitted + // - response is complete (stopped) + if (carousel.isUsed || (isResponseVM(element) && element.isComplete)) { + return false; + } + // Use resolveId for comparison instead of object identity to handle re-rendering during scrolling + if (other.kind === 'questionCarousel') { + const otherCarousel = other as IChatQuestionCarousel; + // Compare by resolveId if available, otherwise fall back to object identity + if (carousel.resolveId && otherCarousel.resolveId) { + return carousel.resolveId === otherCarousel.resolveId; + } + return other === carousel; + } + return false; + }); } private _notifyOnQuestionCarousel(context: IChatContentPartRenderContext, carousel: IChatQuestionCarousel): void { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 137ade79af9..dd00cc4c589 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -85,14 +85,14 @@ import { IChatViewTitleActionContext } from '../../../common/actions/chatActions import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { ChatRequestVariableSet, IChatRequestVariableEntry, isElementVariableEntry, isImageVariableEntry, isNotebookOutputVariableEntry, isPasteVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry, isSCMHistoryItemChangeRangeVariableEntry, isSCMHistoryItemChangeVariableEntry, isSCMHistoryItemVariableEntry, isStringVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; import { ChatMode, IChatMode, IChatModeService } from '../../../common/chatModes.js'; -import { IChatFollowup, IChatService, IChatSessionContext } from '../../../common/chatService/chatService.js'; +import { IChatFollowup, IChatQuestionCarousel, IChatService, IChatSessionContext } from '../../../common/chatService/chatService.js'; import { agentOptionId, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsService, isIChatSessionFileChange2, localChatSessionType } from '../../../common/chatSessionsService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, validateChatMode } from '../../../common/constants.js'; import { IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../common/languageModels.js'; import { IChatModelInputState, IChatRequestModeInfo, IInputModel } from '../../../common/model/chatModel.js'; import { getChatSessionType } from '../../../common/model/chatUri.js'; -import { IChatResponseViewModel } from '../../../common/model/chatViewModel.js'; +import { IChatResponseViewModel, isResponseVM } from '../../../common/model/chatViewModel.js'; import { IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; import { ChatHistoryNavigator } from '../../../common/widget/chatWidgetHistoryService.js'; @@ -110,6 +110,8 @@ import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../c import { SearchableOptionPickerActionItem } from '../../chatSessions/searchableOptionPickerActionItem.js'; import { IChatContextService } from '../../contextContrib/chatContextService.js'; import { IDisposableReference } from '../chatContentParts/chatCollections.js'; +import { ChatQuestionCarouselPart, IChatQuestionCarouselOptions } from '../chatContentParts/chatQuestionCarouselPart.js'; +import { IChatContentPartRenderContext } from '../chatContentParts/chatContentParts.js'; import { CollapsibleListPool, IChatCollapsibleListItem } from '../chatContentParts/chatReferencesContentPart.js'; import { ChatTodoListWidget } from '../chatContentParts/chatTodoListWidget.js'; import { ChatDragAndDrop } from '../chatDragAndDrop.js'; @@ -203,6 +205,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private _workingSetCollapsed = observableValue('chatInputPart.workingSetCollapsed', true); private readonly _chatInputTodoListWidget = this._register(new MutableDisposable()); + private readonly _chatQuestionCarouselWidget = this._register(new MutableDisposable()); + private readonly _chatQuestionCarouselDisposables = this._register(new DisposableStore()); + private _currentQuestionCarouselResponseId: string | undefined; private readonly _chatEditingTodosDisposables = this._register(new DisposableStore()); private _lastEditingSessionResource: URI | undefined; @@ -283,6 +288,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private chatEditingSessionWidgetContainer!: HTMLElement; private chatInputTodoListWidgetContainer!: HTMLElement; + private chatQuestionCarouselContainer!: HTMLElement; private chatInputWidgetsContainer!: HTMLElement; private readonly _widgetController = this._register(new MutableDisposable()); @@ -1733,12 +1739,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.refreshChatSessionPickers(); this.tryUpdateWidgetController(); this.updateContextUsageWidget(); + this.clearQuestionCarousel(); })); let elements; if (this.options.renderStyle === 'compact') { elements = dom.h('.interactive-input-part', [ dom.h('.interactive-input-and-edit-session', [ + dom.h('.chat-question-carousel-widget-container@chatQuestionCarouselContainer'), dom.h('.chat-input-widgets-container@chatInputWidgetsContainer'), dom.h('.chat-todo-list-widget-container@chatInputTodoListWidgetContainer'), dom.h('.chat-editing-session@chatEditingSessionWidgetContainer'), @@ -1758,6 +1766,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge ]); } else { elements = dom.h('.interactive-input-part', [ + dom.h('.chat-question-carousel-widget-container@chatQuestionCarouselContainer'), dom.h('.interactive-input-followups@followupsContainer'), dom.h('.chat-input-widgets-container@chatInputWidgetsContainer'), dom.h('.chat-todo-list-widget-container@chatInputTodoListWidgetContainer'), @@ -1795,6 +1804,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const attachmentToolbarContainer = elements.attachmentToolbar; this.chatEditingSessionWidgetContainer = elements.chatEditingSessionWidgetContainer; this.chatInputTodoListWidgetContainer = elements.chatInputTodoListWidgetContainer; + this.chatQuestionCarouselContainer = elements.chatQuestionCarouselContainer; this.chatInputWidgetsContainer = elements.chatInputWidgetsContainer; this.contextUsageWidgetContainer = elements.contextUsageWidgetContainer; @@ -2400,6 +2410,48 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._chatInputTodoListWidget.value?.clear(sessionResource, force); } + renderQuestionCarousel(carousel: IChatQuestionCarousel, context: IChatContentPartRenderContext, options: IChatQuestionCarouselOptions): ChatQuestionCarouselPart { + + if (this._chatQuestionCarouselWidget.value) { + const existingCarousel = this._chatQuestionCarouselWidget.value; + const existingResolveId = existingCarousel.carousel.resolveId; + if (existingResolveId && carousel.resolveId && existingResolveId === carousel.resolveId) { + return existingCarousel; + } + this.clearQuestionCarousel(); + } + + // track the response id and session + this._currentQuestionCarouselResponseId = isResponseVM(context.element) ? context.element.requestId : undefined; + + const part = this._chatQuestionCarouselDisposables.add( + this.instantiationService.createInstance(ChatQuestionCarouselPart, carousel, context, options) + ); + this._chatQuestionCarouselWidget.value = part; + + dom.clearNode(this.chatQuestionCarouselContainer); + dom.append(this.chatQuestionCarouselContainer, part.domNode); + + return part; + } + + clearQuestionCarousel(responseId?: string): void { + if (responseId && this._currentQuestionCarouselResponseId !== responseId) { + return; + } + this._chatQuestionCarouselDisposables.clear(); + this._chatQuestionCarouselWidget.clear(); + this._currentQuestionCarouselResponseId = undefined; + dom.clearNode(this.chatQuestionCarouselContainer); + } + get questionCarouselResponseId(): string | undefined { + return this._currentQuestionCarouselResponseId; + } + + get questionCarousel(): ChatQuestionCarouselPart | undefined { + return this._chatQuestionCarouselWidget.value; + } + setWorkingSetCollapsed(collapsed: boolean): void { this._workingSetCollapsed.set(collapsed, undefined); } 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 d94ccd6dbac..b8f69ea3084 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -1053,6 +1053,23 @@ have to be updated for changes to the rules above, or to support more deeply nes position: relative; } +/* question carousel - this is above edits and todos */ +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container { + width: 100%; + position: relative; +} + +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container:empty { + display: none; +} + +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container .chat-question-carousel-container { + margin: 0px; + border: 1px solid var(--vscode-input-border, transparent); + background-color: var(--vscode-editor-background); + border-radius: 4px; +} + /* Chat Todo List Widget Container - mirrors chat-editing-session styling */ .interactive-session .interactive-input-part > .chat-todo-list-widget-container { margin-bottom: -4px; diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 056abf3a60f..a1dc0664560 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -1354,7 +1354,7 @@ interface ISerializableChatResponseData { timeSpentWaiting?: number; } -export type SerializedChatResponsePart = IMarkdownString | IChatResponseProgressFileTreeData | IChatContentInlineReference | IChatAgentMarkdownContentWithVulnerability | IChatThinkingPart | IChatProgressResponseContentSerialized; +export type SerializedChatResponsePart = IMarkdownString | IChatResponseProgressFileTreeData | IChatContentInlineReference | IChatAgentMarkdownContentWithVulnerability | IChatThinkingPart | IChatProgressResponseContentSerialized | IChatQuestionCarousel; export interface ISerializableChatRequestData extends ISerializableChatResponseData { requestId: string; diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts index 1850b900f63..4cffb9f84e7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts @@ -253,7 +253,7 @@ suite('ChatQuestionCarouselPart', () => { // Use dedicated class selector for stability const nextButton = widget.domNode.querySelector('.chat-question-nav-next') as HTMLElement; assert.ok(nextButton, 'Next button should exist'); - assert.strictEqual(nextButton.title, 'Submit', 'Next button should have Submit title on last question'); + assert.strictEqual(nextButton.getAttribute('aria-label'), 'Submit', 'Next button should have Submit aria-label on last question'); }); });