Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
68 changes: 67 additions & 1 deletion src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -740,6 +741,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
* by screen readers
*/
private readonly _announcedToolProgressKeys = new Set<string>();
private readonly requestExpansionState = new Map<string, boolean>();

constructor(
editorOptions: ChatEditorOptions,
Expand Down Expand Up @@ -912,6 +914,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
if (!isRequestVM(element)
|| element.isSystemInitiated
|| !!element.confirmation
|| (this.getRequestExpansionState(element) === false && element.id !== this.viewModel?.editing?.id)
|| !this.requestHasStickyScrollContent(element)) {
return undefined;
}
Expand Down Expand Up @@ -951,6 +954,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
}

updateViewModel(viewModel: IChatViewModel | undefined): void {
if (!isEqual(this.viewModel?.sessionResource, viewModel?.sessionResource)) {
this.requestExpansionState.clear();
}
this.viewModel = viewModel;
this._announcedToolProgressKeys.clear();
this._notifiedQuestionCarousels.clear();
Expand Down Expand Up @@ -1355,7 +1361,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch

// Clear pending-related classes and drag handle from previous renders
// Do this before element-type checks to ensure dividers also get cleaned up
templateData.rowContainer.classList.remove('pending-item', 'pending-divider', 'pending-request', 'chat-pending-dragging', 'terminal-command-request', 'chat-system-notification-response');
templateData.rowContainer.classList.remove('pending-item', 'pending-divider', 'pending-request', 'chat-pending-dragging', 'chat-request-collapsed', 'terminal-command-request', 'chat-system-notification-response');
templateData.dragHandle?.remove();
templateData.dragHandle = undefined;
delete templateData.rowContainer.dataset.pendingRequestId;
Expand Down Expand Up @@ -2336,6 +2342,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
stickyScrollSourcePart.addDisposable(this.registerSynchronizedRequestBubbleHover(element.id, templateData, templateData.stickyScrollSource));
}

if (!isStickyScrollRow && !element.confirmation && element.id !== this.viewModel?.editing?.id && (element.pendingKind || this.getRequestExpansionState(element) !== undefined)) {
this.renderRequestCollapseControl(element, templateData);
}

if (!isStickyScrollRow && !element.pendingKind && !element.confirmation && this.rendererOptions.renderStyle !== 'minimal' && templateData.value.childElementCount > 0) {
const timestamp = renderChatRequestTimestamp(templateData.requestTimestampContainer, element.requestTimestamp);
if (timestamp?.hoverText) {
Expand Down Expand Up @@ -2374,6 +2384,62 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
}
}

private getRequestExpansionState(element: IChatRequestViewModel): boolean | undefined {
let expanded = this.requestExpansionState.get(element.id);
// A combined steering request inherits any explicit collapse choice from
// its pending messages. Consume those choices so later toggles take effect.
for (const pendingId of element.pendingRequestIds ?? []) {
if (pendingId === element.id) {
continue;
}
const pendingExpanded = this.requestExpansionState.get(pendingId);
if (pendingExpanded !== undefined) {
expanded = expanded === undefined ? pendingExpanded : expanded && pendingExpanded;
this.requestExpansionState.delete(pendingId);
}
}
if (expanded !== undefined) {
this.requestExpansionState.set(element.id, expanded);
}
return expanded;
}

private renderRequestCollapseControl(element: IChatRequestViewModel, templateData: IChatListItemTemplate): void {
const contentElements = Array.from(templateData.value.children).filter(dom.isHTMLElement).map(element => ({ 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');
Comment on lines +2411 to +2412

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 49af945: added a localized paragraph to the existing Chat Accessibility Help explaining Tab/Shift+Tab navigation, Enter/Space activation, and independent queued-message collapse states. Scoped ESLint and hygiene checks pass.

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);
Comment on lines +2435 to +2437

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 49af945. The real dequeue path now preserves the pending request ID. Merged steering requests also carry their source IDs into the model/view model, so a collapse choice from any source transfers to the combined prompt; later expansion survives rerenders.

Added service regressions using the public queue/dequeue APIs, including automatic queued/steering dispatch, merged steering, slash commands and retries, and blocked troubleshoot requests. The renderer tests now complement those service tests instead of being the only evidence for the transition. All 101 ChatService tests and 70 renderer tests pass (one existing renderer test remains skipped).

update();
this.fireItemHeightChange(templateData);
}));
}

private getRequestMarkdown(element: IChatRequestViewModel, explicitFileOrImageVariables = element.variables.filter(isExplicitFileOrImageVariableEntry)): string | undefined {
const markdown = isChatFollowup(element.message)
? element.message.message
Expand Down
32 changes: 31 additions & 1 deletion src/vs/workbench/contrib/chat/browser/widget/media/chat.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -1492,6 +1492,11 @@ export class ChatService extends Disposable implements IChatService {
const requestType = commandPart ? 'slashCommand' : 'string';

const responseCreated = new DeferredPromise<IChatResponseModel>();
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) {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down
7 changes: 7 additions & 0 deletions src/vs/workbench/contrib/chat/common/model/chatModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ export function getRestoredChatRequestSource(request: Pick<IChatRequestModel, 'r

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;
Expand Down Expand Up @@ -409,6 +411,7 @@ export interface IChatRequestModelParameters {
isCompleteAddedRequest?: boolean;
modelId?: string;
restoredId?: string;
pendingRequestIds?: readonly string[];
editedFileEvents?: IChatAgentEditedFileEvent[];
userSelectedTools?: UserSelectedTools;
isSystemInitiated?: boolean;
Expand All @@ -424,6 +427,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;
Expand Down Expand Up @@ -511,6 +515,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;
Expand Down Expand Up @@ -3189,6 +3194,7 @@ export class ChatModel extends Disposable implements IChatModel {
origin?: IChatRequestOrigin,
isRequestHiddenFromTranscript?: boolean,
requestSource?: ChatRequestSource,
pendingRequestIds?: readonly string[],
): ChatRequestModel {
const editedFileEvents = [...this.currentEditedFileEvents.values()];
this.currentEditedFileEvents.clear();
Expand All @@ -3199,6 +3205,7 @@ export class ChatModel extends Disposable implements IChatModel {
: undefined;
const request = new ChatRequestModel({
restoredId: id,
pendingRequestIds,
session: this,
message,
variableData,
Expand Down
5 changes: 5 additions & 0 deletions src/vs/workbench/contrib/chat/common/model/chatViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -412,6 +413,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.
*/
Expand Down
Loading