diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts index 1510faf82ec9b..5154b6dcd2348 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts @@ -19,7 +19,7 @@ import { getPartByLocation } from '../../../../services/views/browser/viewsServi import { IWorkbenchLayoutService, Position } from '../../../../services/layout/browser/layoutService.js'; import { IAgentSessionsService } from './agentSessionsService.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; -import { ChatEditorInput, showClearEditingSessionConfirmation } from '../widgetHosts/editor/chatEditorInput.js'; +import { ChatEditorInput, shouldShowClearEditingSessionConfirmation } from '../widgetHosts/editor/chatEditorInput.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ChatConfiguration } from '../../common/constants.js'; @@ -33,6 +33,7 @@ import { ActiveEditorContext, AuxiliaryBarMaximizedContext } from '../../../../c import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; +import { coalesce } from '../../../../../base/common/arrays.js'; //#region Chat View @@ -381,24 +382,27 @@ abstract class BaseAgentSessionAction extends Action2 { const agentSessionsService = accessor.get(IAgentSessionsService); const viewsService = accessor.get(IViewsService); - let session: IAgentSession | undefined; + let sessions: IAgentSession[] = []; if (isMarshalledAgentSessionContext(context)) { - session = agentSessionsService.getSession(context.session.resource); - } else { - session = context; + sessions = coalesce((context.sessions ?? [context.session]).map(session => agentSessionsService.getSession(session.resource))); + } else if (context) { + sessions = [context]; } - if (!session) { + if (sessions.length === 0) { const chatView = viewsService.getActiveViewWithId(ChatViewId); - session = chatView?.getFocusedSessions().at(0); + const focused = chatView?.getFocusedSessions().at(0); + if (focused) { + sessions = [focused]; + } } - if (session) { - await this.runWithSession(session, accessor); + if (sessions.length > 0) { + await this.runWithSessions(sessions, accessor); } } - abstract runWithSession(session: IAgentSession, accessor: ServicesAccessor): Promise | void; + abstract runWithSessions(sessions: IAgentSession[], accessor: ServicesAccessor): Promise | void; } export class MarkAgentSessionUnreadAction extends BaseAgentSessionAction { @@ -419,8 +423,10 @@ export class MarkAgentSessionUnreadAction extends BaseAgentSessionAction { }); } - runWithSession(session: IAgentSession): void { - session.setRead(false); + runWithSessions(sessions: IAgentSession[]): void { + for (const session of sessions) { + session.setRead(false); + } } } @@ -442,8 +448,10 @@ export class MarkAgentSessionReadAction extends BaseAgentSessionAction { }); } - runWithSession(session: IAgentSession): void { - session.setRead(true); + runWithSessions(sessions: IAgentSession[]): void { + for (const session of sessions) { + session.setRead(true); + } } } @@ -477,20 +485,37 @@ export class ArchiveAgentSessionAction extends BaseAgentSessionAction { }); } - async runWithSession(session: IAgentSession, accessor: ServicesAccessor): Promise { + async runWithSessions(sessions: IAgentSession[], accessor: ServicesAccessor): Promise { const chatService = accessor.get(IChatService); - const chatModel = chatService.getSession(session.resource); const dialogService = accessor.get(IDialogService); - if (chatModel && !await showClearEditingSessionConfirmation(chatModel, dialogService, { - isArchiveAction: true, - titleOverride: localize('archiveSession', "Archive chat with pending edits?"), - messageOverride: localize('archiveSessionDescription', "You have pending changes in this chat session.") - })) { - return; + // Count sessions with pending changes + let sessionsWithPendingChangesCount = 0; + for (const session of sessions) { + const chatModel = chatService.getSession(session.resource); + if (chatModel && shouldShowClearEditingSessionConfirmation(chatModel, { isArchiveAction: true })) { + sessionsWithPendingChangesCount++; + } + } + + // If there are sessions with pending changes, ask for confirmation once + if (sessionsWithPendingChangesCount > 0) { + const confirmed = await dialogService.confirm({ + message: sessionsWithPendingChangesCount === 1 + ? localize('archiveSessionWithPendingEdits', "One session has pending edits. Are you sure you want to archive?") + : localize('archiveSessionsWithPendingEdits', "{0} sessions have pending edits. Are you sure you want to archive?", sessionsWithPendingChangesCount), + primaryButton: localize('archiveSession.archive', "Archive") + }); + + if (!confirmed.confirmed) { + return; + } } - session.setArchived(true); + // Archive all sessions + for (const session of sessions) { + session.setArchived(true); + } } } @@ -526,8 +551,10 @@ export class UnarchiveAgentSessionAction extends BaseAgentSessionAction { }); } - runWithSession(session: IAgentSession): void { - session.setArchived(false); + runWithSessions(sessions: IAgentSession[]): void { + for (const session of sessions) { + session.setArchived(false); + } } } @@ -537,6 +564,7 @@ export class RenameAgentSessionAction extends BaseAgentSessionAction { super({ id: AGENT_SESSION_RENAME_ACTION_ID, title: localize2('rename', "Rename..."), + precondition: ChatContextKeys.hasMultipleAgentSessionsSelected.negate(), keybinding: { primary: KeyCode.F2, mac: { @@ -557,7 +585,12 @@ export class RenameAgentSessionAction extends BaseAgentSessionAction { }); } - async runWithSession(session: IAgentSession, accessor: ServicesAccessor): Promise { + async runWithSessions(sessions: IAgentSession[], accessor: ServicesAccessor): Promise { + const session = sessions.at(0); + if (!session) { + return; + } + const quickInputService = accessor.get(IQuickInputService); const chatService = accessor.get(IChatService); @@ -583,13 +616,19 @@ export class DeleteAgentSessionAction extends BaseAgentSessionAction { }); } - async runWithSession(session: IAgentSession, accessor: ServicesAccessor): Promise { + async runWithSessions(sessions: IAgentSession[], accessor: ServicesAccessor): Promise { + if (sessions.length === 0) { + return; + } + const chatService = accessor.get(IChatService); const dialogService = accessor.get(IDialogService); const widgetService = accessor.get(IChatWidgetService); const confirmed = await dialogService.confirm({ - message: localize('deleteSession.confirm', "Are you sure you want to delete this chat session?"), + message: sessions.length === 1 + ? localize('deleteSession.confirm', "Are you sure you want to delete this chat session?") + : localize('deleteSessions.confirm', "Are you sure you want to delete {0} chat sessions?", sessions.length), detail: localize('deleteSession.detail', "This action cannot be undone."), primaryButton: localize('deleteSession.delete', "Delete") }); @@ -598,11 +637,14 @@ export class DeleteAgentSessionAction extends BaseAgentSessionAction { return; } - // Clear chat widget - await widgetService.getWidgetBySessionResource(session.resource)?.clear(); + for (const session of sessions) { - // Remove from storage - await chatService.removeHistoryEntry(session.resource); + // Clear chat widget + await widgetService.getWidgetBySessionResource(session.resource)?.clear(); + + // Remove from storage + await chatService.removeHistoryEntry(session.resource); + } } } @@ -651,15 +693,18 @@ export class DeleteAllLocalSessionsAction extends Action2 { abstract class BaseOpenAgentSessionAction extends BaseAgentSessionAction { - async runWithSession(session: IAgentSession, accessor: ServicesAccessor): Promise { + async runWithSessions(sessions: IAgentSession[], accessor: ServicesAccessor): Promise { const chatWidgetService = accessor.get(IChatWidgetService); - const uri = session.resource; + const targetGroup = this.getTargetGroup(); + for (const session of sessions) { + const uri = session.resource; - await chatWidgetService.openSession(uri, this.getTargetGroup(), { - ...this.getOptions(), - pinned: true - }); + await chatWidgetService.openSession(uri, targetGroup, { + ...this.getOptions(), + pinned: true + }); + } } protected abstract getTargetGroup(): PreferredGroup; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index f99a9b8de6cb6..3ca1f18b2875c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -70,6 +70,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo private focusedAgentSessionArchivedContextKey: IContextKey; private focusedAgentSessionReadContextKey: IContextKey; private focusedAgentSessionTypeContextKey: IContextKey; + private hasMultipleAgentSessionsSelectedContextKey: IContextKey; constructor( private readonly container: HTMLElement, @@ -89,6 +90,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo this.focusedAgentSessionArchivedContextKey = ChatContextKeys.isArchivedAgentSession.bindTo(this.contextKeyService); this.focusedAgentSessionReadContextKey = ChatContextKeys.isReadAgentSession.bindTo(this.contextKeyService); this.focusedAgentSessionTypeContextKey = ChatContextKeys.agentSessionType.bindTo(this.contextKeyService); + this.hasMultipleAgentSessionsSelectedContextKey = ChatContextKeys.hasMultipleAgentSessionsSelected.bindTo(this.contextKeyService); this.createList(this.container); @@ -143,7 +145,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo dnd: this.instantiationService.createInstance(AgentSessionsDragAndDrop), identityProvider: new AgentSessionsIdentityProvider(), horizontalScrolling: false, - multipleSelectionSupport: false, + multipleSelectionSupport: true, findWidgetEnabled: true, defaultFindMode: TreeFindMode.Filter, keyboardNavigationLabelProvider: new AgentSessionsKeyboardNavigationLabelProvider(), @@ -183,7 +185,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo } })); - this._register(Event.any(list.onDidChangeFocus, model.onDidChangeSessions)(() => { + this._register(Event.any(list.onDidChangeFocus, list.onDidChangeSelection, model.onDidChangeSessions)(() => { const focused = list.getFocus().at(0); if (focused && isAgentSession(focused)) { this.focusedAgentSessionArchivedContextKey.set(focused.isArchived()); @@ -194,6 +196,9 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo this.focusedAgentSessionReadContextKey.reset(); this.focusedAgentSessionTypeContextKey.reset(); } + + const selection = list.getSelection().filter(isAgentSession); + this.hasMultipleAgentSessionsSelectedContextKey.set(selection.length > 1); })); } @@ -250,11 +255,17 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo const menu = this.menuService.createMenu(MenuId.AgentSessionsContext, this.contextKeyService.createOverlay(contextOverlay)); - const marshalledSession: IMarshalledAgentSessionContext = { session, $mid: MarshalledId.AgentSessionContext }; + const selection = this.sessionsList?.getSelection().filter(isAgentSession) ?? []; + const marshalledContext: IMarshalledAgentSessionContext = { + session, + sessions: selection.length > 1 && selection.includes(session) ? selection : [session], + $mid: MarshalledId.AgentSessionContext + }; + this.contextMenuService.showContextMenu({ - getActions: () => Separator.join(...menu.getActions({ arg: marshalledSession, shouldForwardArgs: true }).map(([, actions]) => actions)), + getActions: () => Separator.join(...menu.getActions({ arg: marshalledContext, shouldForwardArgs: true }).map(([, actions]) => actions)), getAnchor: () => anchor, - getActionsContext: () => marshalledSession, + getActionsContext: () => marshalledContext, }); menu.dispose(); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index 6196447cb8d69..29b7f2d871495 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -165,7 +165,9 @@ export function isAgentSessionSection(obj: unknown): obj is IAgentSessionSection export interface IMarshalledAgentSessionContext { readonly $mid: MarshalledId.AgentSessionContext; + readonly session: IAgentSession; + readonly sessions: IAgentSession[]; // support for multi-selection } export function isMarshalledAgentSessionContext(thing: unknown): thing is IMarshalledAgentSessionContext { @@ -370,7 +372,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode providerType: chatSessionType, providerLabel, resource: session.resource, - label: session.label, + label: session.label.split('\n')[0], // protect against weird multi-line labels that break our layout description: session.description, icon, badge: session.badge, diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index ccec61f72f86f..66a6558bf955c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -21,6 +21,8 @@ import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; import { localize } from '../../../../../../nls.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { Lazy } from '../../../../../../base/common/lazy.js'; +import { IDisposable } from '../../../../../../base/common/lifecycle.js'; import { autorun } from '../../../../../../base/common/observable.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { IChatMarkdownAnchorService } from './chatMarkdownAnchorService.js'; @@ -86,6 +88,13 @@ function extractTitleFromThinkingContent(content: string): string | undefined { return headerMatch ? headerMatch[1] : undefined; } +interface ILazyItem { + lazy: Lazy<{ domNode: HTMLElement; disposable?: IDisposable }>; + toolInvocationId?: string; + toolInvocationOrMarkdown?: IChatToolInvocation | IChatToolInvocationSerialized | IChatMarkdownContent; + originalParent?: HTMLElement; +} + export class ChatThinkingContentPart extends ChatCollapsibleContentPart implements IChatContentPart { public readonly codeblocks: undefined; public readonly codeblocksPartId: undefined; @@ -103,15 +112,17 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen private extractedTitles: string[] = []; private toolInvocationCount: number = 0; private appendedItemCount: number = 0; - private streamingCompleted: boolean = false; private isActive: boolean = true; private toolInvocations: (IChatToolInvocation | IChatToolInvocationSerialized)[] = []; private singleItemInfo: { element: HTMLElement; originalParent: HTMLElement; originalNextSibling: Node | null } | undefined; + private lazyItems: ILazyItem[] = []; + private hasExpandedOnce: boolean = false; constructor( content: IChatThinkingPart, context: IChatContentPartRenderContext, private readonly chatContentMarkdownRenderer: IMarkdownRenderer, + private streamingCompleted: boolean, @IInstantiationService private readonly instantiationService: IInstantiationService, @IConfigurationService private readonly configurationService: IConfigurationService, @IChatMarkdownAnchorService private readonly chatMarkdownAnchorService: IChatMarkdownAnchorService, @@ -141,11 +152,10 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen if (configuredMode === ThinkingDisplayMode.Collapsed) { this.setExpanded(false); + } else if (configuredMode === ThinkingDisplayMode.CollapsedPreview) { + // Start expanded if still in progress + this.setExpanded(!this.element.isComplete); } else { - this.setExpanded(true); - } - - if (this.fixedScrollingMode) { this.setExpanded(false); } @@ -173,6 +183,17 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen } })); + // Materialize lazy items when first expanded + this._register(autorun(r => { + if (this._isExpanded.read(r) && !this.hasExpandedOnce && this.lazyItems.length > 0) { + this.hasExpandedOnce = true; + for (const item of this.lazyItems) { + this.materializeLazyItem(item); + } + this._onDidChangeHeight.fire(); + } + })); + if (this._collapseButton && !this.streamingCompleted && !this.element.isComplete) { this._collapseButton.icon = ThemeIcon.modify(Codicon.loading, 'spin'); } @@ -204,7 +225,10 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen // @TODO: @justschen Convert to template for each setting? protected override initContent(): HTMLElement { this.wrapper = $('.chat-used-context-list.chat-thinking-collapsible'); - this.wrapper.classList.add('chat-thinking-streaming'); + if (!this.streamingCompleted) { + this.wrapper.classList.add('chat-thinking-streaming'); + } + if (this.currentThinkingValue) { this.textContainer = $('.chat-thinking-item.markdown-content'); this.wrapper.appendChild(this.textContainer); @@ -508,13 +532,93 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.updateDropdownClickability(); } - public appendItem(content: HTMLElement, toolInvocationId?: string, toolInvocationOrMarkdown?: IChatToolInvocation | IChatToolInvocationSerialized | IChatMarkdownContent, originalParent?: HTMLElement): void { + /** + * Appends a tool invocation or content item to the thinking group. + * The factory is called lazily - only when the thinking section is expanded. + * If already expanded, the factory is called immediately. + */ + public appendItem( + factory: () => { domNode: HTMLElement; disposable?: IDisposable }, + toolInvocationId?: string, + toolInvocationOrMarkdown?: IChatToolInvocation | IChatToolInvocationSerialized | IChatMarkdownContent, + originalParent?: HTMLElement + ): void { + // Track tool invocation metadata immediately (for title generation) + this.trackToolMetadata(toolInvocationId, toolInvocationOrMarkdown); + this.appendedItemCount++; + + // If expanded or has been expanded once, render immediately + if (this.isExpanded() || this.hasExpandedOnce || (this.fixedScrollingMode && !this.streamingCompleted)) { + const result = factory(); + this.appendItemToDOM(result.domNode, toolInvocationId, toolInvocationOrMarkdown, originalParent); + if (result.disposable) { + this._register(result.disposable); + } + } else { + // Defer rendering until expanded + const item: ILazyItem = { + lazy: new Lazy(factory), + toolInvocationId, + toolInvocationOrMarkdown, + originalParent + }; + this.lazyItems.push(item); + } + + this.updateDropdownClickability(); + } + + private trackToolMetadata( + toolInvocationId?: string, + toolInvocationOrMarkdown?: IChatToolInvocation | IChatToolInvocationSerialized | IChatMarkdownContent + ): void { + if (!toolInvocationId) { + return; + } + + this.toolInvocationCount++; + let toolCallLabel: string; + + const isToolInvocation = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized'); + if (isToolInvocation && toolInvocationOrMarkdown.invocationMessage) { + const message = typeof toolInvocationOrMarkdown.invocationMessage === 'string' ? toolInvocationOrMarkdown.invocationMessage : toolInvocationOrMarkdown.invocationMessage.value; + toolCallLabel = message; + + this.toolInvocations.push(toolInvocationOrMarkdown); + } else if (toolInvocationOrMarkdown?.kind === 'markdownContent') { + const codeblockInfo = extractCodeblockUrisFromText(toolInvocationOrMarkdown.content.value); + if (codeblockInfo?.uri) { + const filename = basename(codeblockInfo.uri); + toolCallLabel = localize('chat.thinking.editedFile', 'Edited {0}', filename); + } else { + toolCallLabel = localize('chat.thinking.editingFile', 'Edited file'); + } + } else { + toolCallLabel = `Invoked \`${toolInvocationId}\``; + } + + // Add tool call to extracted titles for LLM title generation + if (!this.extractedTitles.includes(toolCallLabel)) { + this.extractedTitles.push(toolCallLabel); + } + + if (!this.fixedScrollingMode && !this._isExpanded.get()) { + this.setTitle(toolCallLabel); + } + } + + private appendItemToDOM( + content: HTMLElement, + toolInvocationId?: string, + toolInvocationOrMarkdown?: IChatToolInvocation | IChatToolInvocationSerialized | IChatMarkdownContent, + originalParent?: HTMLElement + ): void { if (!content.hasChildNodes() || content.textContent?.trim() === '') { return; } - // save the first item info for potential restoration later - if (this.appendedItemCount === 0 && originalParent) { + // Save the first item info for potential restoration later + if (this.appendedItemCount === 1 && originalParent) { this.singleItemInfo = { element: content, originalParent, @@ -524,8 +628,6 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.singleItemInfo = undefined; } - this.appendedItemCount++; - const itemWrapper = $('.chat-thinking-tool-wrapper'); const isMarkdownEdit = toolInvocationOrMarkdown?.kind === 'markdownContent'; const isTerminalTool = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') && toolInvocationOrMarkdown.toolSpecificData?.kind === 'terminal'; @@ -546,45 +648,27 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen itemWrapper.appendChild(content); this.wrapper.appendChild(itemWrapper); - if (toolInvocationId) { - this.toolInvocationCount++; - let toolCallLabel: string; - - const isToolInvocation = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized'); - if (isToolInvocation && toolInvocationOrMarkdown.invocationMessage) { - const message = typeof toolInvocationOrMarkdown.invocationMessage === 'string' ? toolInvocationOrMarkdown.invocationMessage : toolInvocationOrMarkdown.invocationMessage.value; - toolCallLabel = message; - - this.toolInvocations.push(toolInvocationOrMarkdown); - } else if (toolInvocationOrMarkdown?.kind === 'markdownContent') { - const codeblockInfo = extractCodeblockUrisFromText(toolInvocationOrMarkdown.content.value); - if (codeblockInfo?.uri) { - const filename = basename(codeblockInfo.uri); - toolCallLabel = localize('chat.thinking.editedFile', 'Edited {0}', filename); - } else { - toolCallLabel = localize('chat.thinking.editingFile', 'Edited file'); - } - } else { - toolCallLabel = `Invoked \`${toolInvocationId}\``; - } - - // Add tool call to extracted titles for LLM title generation - if (!this.extractedTitles.includes(toolCallLabel)) { - this.extractedTitles.push(toolCallLabel); - } - if (!this.fixedScrollingMode && !this._isExpanded.get()) { - this.setTitle(toolCallLabel); - } - } if (this.fixedScrollingMode && this.wrapper) { this.wrapper.scrollTop = this.wrapper.scrollHeight; } - this.updateDropdownClickability(); + } + + private materializeLazyItem(item: ILazyItem): void { + if (item.lazy.hasValue) { + return; // Already materialized + } + + const result = item.lazy.value; + this.appendItemToDOM(result.domNode, item.toolInvocationId, item.toolInvocationOrMarkdown, item.originalParent); + + if (result.disposable) { + this._register(result.disposable); + } } // makes a new text container. when we update, we now update this container. - public setupThinkingContainer(content: IChatThinkingPart, context: IChatContentPartRenderContext) { + public setupThinkingContainer(content: IChatThinkingPart) { // Avoid creating new containers after disposal if (this._store.isDisposed) { return; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 76d10acb2ad92..db61991dc8bfc 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -96,6 +96,7 @@ import { ChatEditorOptions } from './chatOptions.js'; import { ChatCodeBlockContentProvider, CodeBlockPart } from './chatContentParts/codeBlockPart.js'; import { autorun, observableValue } from '../../../../../base/common/observable.js'; import { RunSubagentTool } from '../../common/tools/builtinTools/runSubagentTool.js'; +import { isEqual } from '../../../../../base/common/resources.js'; const $ = dom.$; @@ -190,6 +191,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer()); readonly onDidChangeItemHeight: Event = this._onDidChangeItemHeight.event; + private readonly _onDidUpdateViewModel = this._register(new Emitter()); + private readonly _editorPool: EditorPool; private readonly _toolEditorPool: EditorPool; private readonly _diffEditorPool: DiffEditorPool; @@ -303,6 +306,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { + if (!template.currentElement || !this.viewModel?.sessionResource || !isEqual(template.currentElement.sessionResource, this.viewModel.sessionResource)) { + this.clearRenderedParts(template); + } + })); + templateDisposables.add(dom.addDisposableListener(disabledOverlay, dom.EventType.CLICK, e => { if (!this.viewModel?.editing) { return; @@ -1558,7 +1568,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer other.kind === content.kind && other.id === content.id); } - private renderNoContent(equals: (otherContent: IChatRendererContent) => boolean): IChatContentPart { + private renderNoContent(equals: (other: IChatRendererContent, followingContent: IChatRendererContent[], element: ChatTreeItem) => boolean): IChatContentPart { return { dispose: () => { }, domNode: undefined, @@ -1664,33 +1674,54 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this._currentLayoutWidth.get(), this._toolInvocationCodeBlockCollection, this._announcedToolProgressKeys, codeBlockStartIndex); - part.addDisposable(part.onDidChangeHeight(() => { - this.updateItemHeight(templateData); - })); - this.handleRenderedCodeblocks(context.element, part, codeBlockStartIndex); - const subagentId = toolInvocation.toolId === RunSubagentTool.Id ? toolInvocation.toolCallId : toolInvocation.subAgentInvocationId; + // Factory that creates the tool invocation part with all necessary setup + let lazilyCreatedPart: ChatToolInvocationPart | undefined = undefined; + const createToolPart = (): { domNode: HTMLElement; disposable: ChatToolInvocationPart } => { + lazilyCreatedPart = this.instantiationService.createInstance(ChatToolInvocationPart, toolInvocation, context, this.chatContentMarkdownRenderer, this._contentReferencesListPool, this._toolEditorPool, () => this._currentLayoutWidth.get(), this._toolInvocationCodeBlockCollection, this._announcedToolProgressKeys, codeBlockStartIndex); + lazilyCreatedPart.addDisposable(lazilyCreatedPart.onDidChangeHeight(() => { + this.updateItemHeight(templateData); + })); + this.handleRenderedCodeblocks(context.element, lazilyCreatedPart, codeBlockStartIndex); + + // watch for streaming -> confirmation transition to finalize thinking + if (toolInvocation.kind === 'toolInvocation' && IChatToolInvocation.isStreaming(toolInvocation)) { + let wasStreaming = true; + lazilyCreatedPart.addDisposable(autorun(reader => { + const state = toolInvocation.state.read(reader); + if (wasStreaming && state.type !== IChatToolInvocation.StateKind.Streaming) { + wasStreaming = false; + if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation) { + if (lazilyCreatedPart!.domNode) { + const wrapper = lazilyCreatedPart!.domNode.parentElement; + if (wrapper?.classList.contains('chat-thinking-tool-wrapper')) { + wrapper.remove(); + } + templateData.value.appendChild(lazilyCreatedPart!.domNode); + } + this.finalizeCurrentThinkingPart(context, templateData); + } + } + })); + } - // Handle subagent tool grouping - group them together similar to thinking blocks - if (subagentId && isResponseVM(context.element) && part?.domNode && toolInvocation.presentation !== 'hidden') { - return this.handleSubagentToolGrouping(toolInvocation, part, subagentId, context, templateData); - } + return { domNode: lazilyCreatedPart.domNode, disposable: lazilyCreatedPart }; + }; // handling for when we want to put tool invocations inside a thinking part const collapsedToolsMode = this.configService.getValue('chat.agent.thinking.collapsedTools'); if (isResponseVM(context.element) && collapsedToolsMode !== CollapsedToolsDisplayMode.Off) { + const lastThinking = this.getLastThinkingPart(templateData.renderedParts); // create thinking part if it doesn't exist yet - const lastThinking = this.getLastThinkingPart(templateData.renderedParts); - if (!lastThinking && part?.domNode && toolInvocation.presentation !== 'hidden' && this.shouldPinPart(toolInvocation, context.element) && collapsedToolsMode === CollapsedToolsDisplayMode.Always) { + if (!lastThinking && toolInvocation.presentation !== 'hidden' && this.shouldPinPart(toolInvocation, context.element) && collapsedToolsMode === CollapsedToolsDisplayMode.Always) { const thinkingPart = this.renderThinkingPart({ kind: 'thinking', }, context, templateData); if (thinkingPart instanceof ChatThinkingContentPart) { - thinkingPart.appendItem(part?.domNode, toolInvocation.toolId, toolInvocation, templateData.value); - thinkingPart.addDisposable(part); + // Append using factory - thinking part decides whether to render lazily + thinkingPart.appendItem(createToolPart, toolInvocation.toolId, toolInvocation, templateData.value); thinkingPart.addDisposable(thinkingPart.onDidChangeHeight(() => { this.updateItemHeight(templateData); })); @@ -1700,36 +1731,28 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer confirmation transition to finalize thinking - if (toolInvocation.kind === 'toolInvocation' && IChatToolInvocation.isStreaming(toolInvocation)) { - let wasStreaming = true; - part.addDisposable(autorun(reader => { - const state = toolInvocation.state.read(reader); - if (wasStreaming && state.type !== IChatToolInvocation.StateKind.Streaming) { - wasStreaming = false; - if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation) { - if (part.domNode) { - const wrapper = part.domNode.parentElement; - if (wrapper?.classList.contains('chat-thinking-tool-wrapper')) { - wrapper.remove(); - } - templateData.value.appendChild(part.domNode); - } - this.finalizeCurrentThinkingPart(context, templateData); - } - } - })); - } + if (lastThinking && toolInvocation.presentation !== 'hidden') { + // Append using factory - thinking part decides whether to render lazily + lastThinking.appendItem(createToolPart, toolInvocation.toolId, toolInvocation, templateData.value); + return this.renderNoContent((other, followingContent, element) => lazilyCreatedPart ? + lazilyCreatedPart.hasSameContent(other, followingContent, element) : + toolInvocation.kind === other.kind); } } else { this.finalizeCurrentThinkingPart(context, templateData); } } + // For cases not handled above (subagent grouping, no thinking part, etc.), create the part now + const { domNode, disposable: part } = createToolPart(); + + const subagentId = toolInvocation.toolId === RunSubagentTool.Id ? toolInvocation.toolCallId : toolInvocation.subAgentInvocationId; + + // Handle subagent tool grouping - group them together similar to thinking blocks + if (subagentId && isResponseVM(context.element) && domNode && toolInvocation.presentation !== 'hidden') { + return this.handleSubagentToolGrouping(toolInvocation, part, subagentId, context, templateData); + } + return part; } @@ -1874,8 +1897,14 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { + // Factory wrapping already-created markdown part + thinkingPart.appendItem( + () => ({ domNode: markdownPart.domNode, disposable: markdownPart }), + markdownPart.codeblocksPartId, + markdown, + templateData.value + ); + thinkingPart.addDisposable(thinkingPart.onDidChangeHeight(() => { this.updateItemHeight(templateData); })); } @@ -1885,7 +1914,13 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer ({ domNode: markdownPart.domNode, disposable: markdownPart }), + markdownPart.codeblocksPartId, + markdown, + templateData.value + ); } } else if (!this.shouldPinPart(markdown, context.element) && !isFinalAnswerPart) { this.finalizeCurrentThinkingPart(context, templateData); @@ -1913,10 +1948,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this.updateItemHeight(templateData))); lastPart = itemPart; } @@ -1927,10 +1962,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this.updateItemHeight(templateData))); return part; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 9aa2abdfe711b..09879aec3a6c4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -1217,10 +1217,6 @@ export class ChatWidget extends Disposable implements IChatWidget { } else { this.input.renderFollowups(undefined, undefined); } - - if (this.bodyDimension) { - this.layout(this.bodyDimension.height, this.bodyDimension.width); - } } private renderChatSuggestNextWidget(): void { @@ -1570,6 +1566,12 @@ export class ChatWidget extends Disposable implements IChatWidget { if (this.tree.hasElement(e.element) && this.visible) { this.tree.updateElementHeight(e.element, e.height); } + + // If the second-to-last item's height changed, update the last item's min height + const secondToLastItem = this.viewModel?.getItems().at(-2); + if (e.element.id === secondToLastItem?.id) { + this.updateLastItemMinHeight(); + } })); this._register(this.tree.onDidFocus(() => { this._onDidFocus.fire(); @@ -1923,6 +1925,10 @@ export class ChatWidget extends Disposable implements IChatWidget { })); this._register(autorun(reader => { this.input.inputPartHeight.read(reader); + if (!this.renderer) { + // This is set up before the list/renderer are created + return; + } const editedRequest = this.renderer.getTemplateDataForRequestId(this.viewModel?.editing?.id); if (isRequestVM(editedRequest?.currentElement) && this.viewModel?.editing) { @@ -2010,6 +2016,7 @@ export class ChatWidget extends Disposable implements IChatWidget { // Pass input model reference to input part for state syncing this.inputPart.setInputModel(model.inputModel, model.getRequests().length === 0); + this.renderer.updateViewModel(this.viewModel); if (this._lockedAgent) { let placeholder = this.chatSessionsService.getInputPlaceholderForSessionType(this._lockedAgent.id); @@ -2088,7 +2095,6 @@ export class ChatWidget extends Disposable implements IChatWidget { this.scrollToEnd(); } - this.renderer.updateViewModel(this.viewModel); this.updateChatInputContext(); this.input.renderChatTodoListWidget(this.viewModel.sessionResource); } @@ -2418,10 +2424,30 @@ export class ChatWidget extends Disposable implements IChatWidget { this.tree.domFocus(); } + private previousLastItemMinHeight: number = 0; + + private updateLastItemMinHeight(): void { + const contentHeight = this.bodyDimension ? Math.max(0, this.bodyDimension.height - this.inputPart.inputPartHeight.get() - this.chatSuggestNextWidget.height) : 0; + if (this.viewOptions.renderStyle === 'compact' || this.viewOptions.renderStyle === 'minimal') { + this.listContainer.style.removeProperty('--chat-current-response-min-height'); + } else { + const secondToLastItem = this.viewModel?.getItems().at(-2); + const secondToLastItemHeight = Math.min(secondToLastItem?.currentRenderedHeight ?? 150, 150); + const lastItemMinHeight = Math.max(contentHeight - (secondToLastItemHeight + 10), 0); + this.listContainer.style.setProperty('--chat-current-response-min-height', lastItemMinHeight + 'px'); + if (lastItemMinHeight !== this.previousLastItemMinHeight) { + this.previousLastItemMinHeight = lastItemMinHeight; + const lastItem = this.viewModel?.getItems().at(-1); + if (lastItem && this.visible && this.tree.hasElement(lastItem)) { + this.tree.updateElementHeight(lastItem, undefined); + } + } + } + } + layout(height: number, width: number): void { width = Math.min(width, this.viewOptions.renderStyle === 'minimal' ? width : 950); // no min width of inline chat - const heightUpdated = this.bodyDimension && this.bodyDimension.height !== height; this.bodyDimension = new dom.Dimension(width, height); if (this.viewModel?.editing) { @@ -2436,14 +2462,7 @@ export class ChatWidget extends Disposable implements IChatWidget { const lastItem = this.viewModel?.getItems().at(-1); const contentHeight = Math.max(0, height - inputHeight - chatSuggestNextWidgetHeight); - if (this.viewOptions.renderStyle === 'compact' || this.viewOptions.renderStyle === 'minimal') { - this.listContainer.style.removeProperty('--chat-current-response-min-height'); - } else { - this.listContainer.style.setProperty('--chat-current-response-min-height', contentHeight * .75 + 'px'); - if (heightUpdated && lastItem && this.visible && this.tree.hasElement(lastItem)) { - this.tree.updateElementHeight(lastItem, undefined); - } - } + this.updateLastItemMinHeight(); this.tree.layout(contentHeight, width); this.welcomeMessageContainer.style.height = `${contentHeight}px`; 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 b8339e70596c5..2e46db95e90d4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -1566,7 +1566,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .interactive-input-part { margin: 0px 16px; - padding: 4px 0 12px 0px; + padding: 4px 0 8px 0px; display: flex; flex-direction: column; gap: 4px; diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 736f368942072..7da745d17d204 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -104,6 +104,7 @@ export namespace ChatContextKeys { export const agentSessionSection = new RawContextKey('agentSessionSection', '', { type: 'string', description: localize('agentSessionSection', "The section of the current agent session section item.") }); export const isArchivedAgentSession = new RawContextKey('agentSessionIsArchived', false, { type: 'boolean', description: localize('agentSessionIsArchived', "True when the agent session item is archived.") }); export const isReadAgentSession = new RawContextKey('agentSessionIsRead', false, { type: 'boolean', description: localize('agentSessionIsRead', "True when the agent session item is read.") }); + export const hasMultipleAgentSessionsSelected = new RawContextKey('agentSessionHasMultipleSelected', false, { type: 'boolean', description: localize('agentSessionHasMultipleSelected', "True when multiple agent sessions are selected.") }); export const hasAgentSessionChanges = new RawContextKey('agentSessionHasChanges', false, { type: 'boolean', description: localize('agentSessionHasChanges', "True when the current agent session item has changes.") }); export const isKatexMathElement = new RawContextKey('chatIsKatexMathElement', false, { type: 'boolean', description: localize('chatIsKatexMathElement', "True when focusing a KaTeX math element.") });