diff --git a/extensions/vscode-api-tests/package.json b/extensions/vscode-api-tests/package.json index 5c308453ec507..525f619542003 100644 --- a/extensions/vscode-api-tests/package.json +++ b/extensions/vscode-api-tests/package.json @@ -26,6 +26,7 @@ "fsChunks", "interactive", "languageStatusText", + "mcpServerDefinitions", "nativeWindowHandle", "notebookDeprecated", "notebookLiveShare", diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index 36848442d368b..6dbacac2bd6ed 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -2064,12 +2064,9 @@ export class DisposableResizeObserver extends Disposable { this._register(toDisposable(() => this.observer.disconnect())); } - observe(target: Element, options?: ResizeObserverOptions): void { + observe(target: Element, options?: ResizeObserverOptions): IDisposable { this.observer.observe(target, options); - } - - unobserve(target: Element): void { - this.observer.unobserve(target); + return toDisposable(() => this.observer.unobserve(target)); } } diff --git a/src/vs/base/browser/markdownRenderer.ts b/src/vs/base/browser/markdownRenderer.ts index e3f20d96726b4..b5006737fb646 100644 --- a/src/vs/base/browser/markdownRenderer.ts +++ b/src/vs/base/browser/markdownRenderer.ts @@ -600,6 +600,7 @@ function getDomSanitizerConfig(mdStrConfig: MdStrConfig, options: MarkdownSaniti Schemas.vscodeRemote, Schemas.vscodeRemoteResource, Schemas.vscodeNotebookCell, + Schemas.vscodeChatPrompt, // For links that are handled entirely by the action handler Schemas.internal, ]; diff --git a/src/vs/editor/contrib/floatingMenu/browser/floatingMenu.css b/src/vs/editor/contrib/floatingMenu/browser/floatingMenu.css index 930c962b888c3..5221366ed32d2 100644 --- a/src/vs/editor/contrib/floatingMenu/browser/floatingMenu.css +++ b/src/vs/editor/contrib/floatingMenu/browser/floatingMenu.css @@ -4,41 +4,48 @@ *--------------------------------------------------------------------------------------------*/ .floating-menu-overlay-widget { - padding: 0px; - color: var(--vscode-button-foreground); - background-color: var(--vscode-button-background); - border-radius: 2px; + padding: 2px 4px; + color: var(--vscode-foreground); + background-color: var(--vscode-editorWidget-background); + border-radius: 6px; border: 1px solid var(--vscode-contrastBorder); display: flex; align-items: center; + justify-content: center; + gap: 4px; z-index: 10; box-shadow: 0 2px 8px var(--vscode-widget-shadow); overflow: hidden; - .action-item > .action-label { - padding: 5px; - font-size: 12px; - border-radius: 2px; + .actions-container { + gap: 4px; } - .action-item > .action-label.codicon, .action-item .codicon { - color: var(--vscode-button-foreground); + .action-item > .action-label { + padding: 4px 6px; + font-size: 11px; + line-height: 14px; + border-radius: 4px; } .action-item > .action-label.codicon:not(.separator) { - padding-top: 6px; - padding-bottom: 6px; + color: var(--vscode-foreground); + width: 22px; + height: 22px; + padding: 0; + font-size: 16px; + line-height: 22px; + display: flex; + align-items: center; + justify-content: center; } - .action-item:first-child > .action-label { - padding-left: 7px; - } - - .action-item:last-child > .action-label { - padding-right: 7px; + .action-item.primary > .action-label { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); } - .action-item .action-label.separator { - background-color: var(--vscode-button-separator); + .action-item.primary > .action-label:hover { + background-color: var(--vscode-button-hoverBackground) !important; } } diff --git a/src/vs/editor/contrib/floatingMenu/browser/floatingMenu.ts b/src/vs/editor/contrib/floatingMenu/browser/floatingMenu.ts index 56f8117ef61d7..a8af1c1b93d7a 100644 --- a/src/vs/editor/contrib/floatingMenu/browser/floatingMenu.ts +++ b/src/vs/editor/contrib/floatingMenu/browser/floatingMenu.ts @@ -5,7 +5,7 @@ import { h } from '../../../../base/browser/dom.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { autorun, constObservable, observableFromEvent } from '../../../../base/common/observable.js'; +import { autorun, constObservable, derived, observableFromEvent } from '../../../../base/common/observable.js'; import { MenuEntryActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { IMenuService, MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js'; @@ -29,11 +29,35 @@ export class FloatingEditorToolbar extends Disposable implements IEditorContribu const editorObs = this._register(observableCodeEditor(editor)); const menu = this._register(menuService.createMenu(MenuId.EditorContent, editor.contextKeyService)); - const menuIsEmptyObs = observableFromEvent(this, menu.onDidChange, () => menu.getActions().length === 0); + const menuActionsObs = observableFromEvent(this, menu.onDidChange, () => menu.getActions()); + const menuPrimaryActionIdObs = derived(reader => { + const menuActions = menuActionsObs.read(reader); + if (menuActions.length === 0) { + return undefined; + } + + // Navigation group + const navigationGroup = menuActions + .find((group) => group[0] === 'navigation'); + + // First action in navigation group + if (navigationGroup && navigationGroup[1].length > 0) { + return navigationGroup[1][0].id; + } + + // Fallback to first group/action + for (const [, actions] of menuActions) { + if (actions.length > 0) { + return actions[0].id; + } + } + + return undefined; + }); this._register(autorun(reader => { - const menuIsEmpty = menuIsEmptyObs.read(reader); - if (menuIsEmpty) { + const menuPrimaryActionId = menuPrimaryActionIdObs.read(reader); + if (!menuPrimaryActionId) { return; } @@ -41,7 +65,7 @@ export class FloatingEditorToolbar extends Disposable implements IEditorContribu // Set height explicitly to ensure that the floating menu element // is rendered in the lower right corner at the correct position. - container.root.style.height = '28px'; + container.root.style.height = '26px'; // Toolbar const toolbar = instantiationService.createInstance(MenuWorkbenchToolBar, container.root, MenuId.EditorContent, { @@ -50,15 +74,24 @@ export class FloatingEditorToolbar extends Disposable implements IEditorContribu return undefined; } - const keybinding = keybindingService.lookupKeybinding(action.id); - if (!keybinding) { - return undefined; - } - return instantiationService.createInstance(class extends MenuEntryActionViewItem { + override render(container: HTMLElement): void { + super.render(container); + + // Highlight primary action + if (action.id === menuPrimaryActionId) { + this.element?.classList.add('primary'); + } + } + protected override updateLabel(): void { + const keybinding = keybindingService.lookupKeybinding(action.id); + const keybindingLabel = keybinding ? keybinding.getLabel() : undefined; + if (this.options.label && this.label) { - this.label.textContent = `${this._commandAction.label} (${keybinding.getLabel()})`; + this.label.textContent = keybindingLabel + ? `${this._commandAction.label} (${keybindingLabel})` + : this._commandAction.label; } } }, action, { ...options, keybindingNotRenderedWithLabel: true }); diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index a33540a913d4d..2fc8c36ed6969 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -9,7 +9,7 @@ import { IListAccessibilityProvider, List } from '../../../base/browser/ui/list/ import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { Codicon } from '../../../base/common/codicons.js'; import { ResolvedKeybinding } from '../../../base/common/keybindings.js'; -import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { Disposable, MutableDisposable } from '../../../base/common/lifecycle.js'; import { OS } from '../../../base/common/platform.js'; import { ThemeIcon } from '../../../base/common/themables.js'; import './actionWidget.css'; @@ -246,7 +246,7 @@ export class ActionList extends Disposable { private readonly cts = this._register(new CancellationTokenSource()); - private hover: { index: number; hover: IHoverWidget } | undefined; + private _hover = this._register(new MutableDisposable()); constructor( user: string, @@ -322,9 +322,6 @@ export class ActionList extends Disposable { this._register(this._list.onDidChangeFocus(() => this.onFocus())); this._register(this._list.onDidChangeSelection(e => this.onListSelection(e))); - // Ensure hover is hidden when ActionList is disposed - this._register(toDisposable(() => this.hideHover())); - this._allMenuItems = items; this._list.splice(0, this._list.length, this._allMenuItems); @@ -340,7 +337,7 @@ export class ActionList extends Disposable { hide(didCancel?: boolean): void { this._delegate.onHide(didCancel); this.cts.cancel(); - this.hideHover(); + this._hover.clear(); this._contextViewService.hideContextView(); } @@ -420,15 +417,6 @@ export class ActionList extends Disposable { } } - private hideHover() { - if (this.hover) { - if (!this.hover.hover.isDisposed) { - this.hover.hover.dispose(); - } - this.hover = undefined; - } - } - private onFocus() { const focused = this._list.getFocus(); if (focused.length === 0) { @@ -448,13 +436,7 @@ export class ActionList extends Disposable { } private _showHoverForElement(element: IActionListItem, index: number): void { - // Hide any existing hover when moving to a different item - if (this.hover) { - if (this.hover.index === index && !this.hover.hover.isDisposed) { - return; - } - this.hideHover(); - } + let newHover: IHoverWidget | undefined; // Show hover if the element has hover content if (element.hover?.content && this.focusCondition(element)) { @@ -463,7 +445,7 @@ export class ActionList extends Disposable { const rowElement = this._getRowElement(index); if (rowElement) { const markdown = element.hover.content ? new MarkdownString(element.hover.content) : undefined; - const hover = this._hoverService.showInstantHover({ + newHover = this._hoverService.showDelayedHover({ content: markdown ?? '', target: rowElement, additionalClasses: ['action-widget-hover'], @@ -474,10 +456,11 @@ export class ActionList extends Disposable { appearance: { showPointer: true, }, - }); - this.hover = hover ? { index, hover } : undefined; + }, { groupId: `actionListHover` }); } } + + this._hover.value = newHover; } private async onListHover(e: IListMouseEvent>) { diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index d9bb1d8628116..cf9ff526955b6 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -290,6 +290,9 @@ const _allApiProposals = { markdownAlertSyntax: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.markdownAlertSyntax.d.ts', }, + mcpServerDefinitions: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.mcpServerDefinitions.d.ts', + }, mcpToolDefinitions: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.mcpToolDefinitions.d.ts', }, diff --git a/src/vs/workbench/api/browser/mainThreadMcp.ts b/src/vs/workbench/api/browser/mainThreadMcp.ts index e0b0fe9410bb8..f09b6b867fe31 100644 --- a/src/vs/workbench/api/browser/mainThreadMcp.ts +++ b/src/vs/workbench/api/browser/mainThreadMcp.ts @@ -4,11 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import { mapFindFirst } from '../../../base/common/arraysFind.js'; -import { disposableTimeout } from '../../../base/common/async.js'; +import { disposableTimeout, RunOnceScheduler } from '../../../base/common/async.js'; import { CancellationError } from '../../../base/common/errors.js'; import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; -import { ISettableObservable, observableValue } from '../../../base/common/observable.js'; +import { autorun, ISettableObservable, observableValue } from '../../../base/common/observable.js'; import Severity from '../../../base/common/severity.js'; import { URI } from '../../../base/common/uri.js'; import * as nls from '../../../nls.js'; @@ -98,6 +98,36 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { return launch; }, })); + + // Subscribe to MCP server definition changes and notify ext host + const onDidChangeMcpServerDefinitionsTrigger = this._register(new RunOnceScheduler(() => this._publishServerDefinitions(), 500)); + this._register(autorun(reader => { + const collections = this._mcpRegistry.collections.read(reader); + // Read all server definitions to track changes + for (const collection of collections) { + collection.serverDefinitions.read(reader); + } + // Notify ext host that definitions changed (it will re-fetch if needed) + if (!onDidChangeMcpServerDefinitionsTrigger.isScheduled()) { + onDidChangeMcpServerDefinitionsTrigger.schedule(); + } + })); + + onDidChangeMcpServerDefinitionsTrigger.schedule(); + } + + private _publishServerDefinitions() { + const collections = this._mcpRegistry.collections.get(); + const allServers: McpServerDefinition.Serialized[] = []; + + for (const collection of collections) { + const servers = collection.serverDefinitions.get(); + for (const server of servers) { + allServers.push(McpServerDefinition.toSerialized(server)); + } + } + + this._proxy.$onDidChangeMcpServerDefinitions(allServers); } $upsertMcpCollection(collection: McpCollectionDefinition.FromExtHost, serversDto: McpServerDefinition.Serialized[]): void { diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 38849ed2a4aec..61bda2cda0d92 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1630,6 +1630,14 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I registerMcpServerDefinitionProvider(id, provider) { return extHostMcp.registerMcpConfigurationProvider(extension, id, provider); }, + onDidChangeMcpServerDefinitions: (...args) => { + checkProposedApiEnabled(extension, 'mcpServerDefinitions'); + return _asExtensionEvent(extHostMcp.onDidChangeMcpServerDefinitions)(...args); + }, + get mcpServerDefinitions() { + checkProposedApiEnabled(extension, 'mcpServerDefinitions'); + return extHostMcp.mcpServerDefinitions; + }, onDidChangeChatRequestTools(...args) { checkProposedApiEnabled(extension, 'chatParticipantAdditions'); return _asExtensionEvent(extHostChatAgents2.onDidChangeChatRequestTools)(...args); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 498dc1cd16271..6945985fc2c3e 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3163,6 +3163,8 @@ export interface IStartMcpOptions { errorOnUserInteraction?: boolean; } + + export interface ExtHostMcpShape { $substituteVariables(workspaceFolder: UriComponents | undefined, value: McpServerLaunch.Serialized): Promise; $resolveMcpLaunch(collectionId: string, label: string): Promise; @@ -3170,6 +3172,7 @@ export interface ExtHostMcpShape { $stopMcp(id: number): void; $sendMessage(id: number, message: string): void; $waitForInitialCollectionProviders(): Promise; + $onDidChangeMcpServerDefinitions(servers: McpServerDefinition.Serialized[]): void; } export interface IMcpAuthenticationDetails { diff --git a/src/vs/workbench/api/common/extHostApiCommands.ts b/src/vs/workbench/api/common/extHostApiCommands.ts index 12fe6f307c7e2..a04bbd0507564 100644 --- a/src/vs/workbench/api/common/extHostApiCommands.ts +++ b/src/vs/workbench/api/common/extHostApiCommands.ts @@ -6,7 +6,7 @@ import { isFalsyOrEmpty } from '../../../base/common/arrays.js'; import { VSBuffer } from '../../../base/common/buffer.js'; import { Schemas, matchesSomeScheme } from '../../../base/common/network.js'; -import { URI, UriComponents } from '../../../base/common/uri.js'; +import { URI } from '../../../base/common/uri.js'; import { IPosition } from '../../../editor/common/core/position.js'; import { IRange } from '../../../editor/common/core/range.js'; import { ISelection } from '../../../editor/common/core/selection.js'; @@ -23,6 +23,7 @@ import { TransientCellMetadata, TransientDocumentMetadata } from '../../contrib/ import * as search from '../../contrib/search/common/search.js'; import type * as vscode from 'vscode'; import { PromptsType } from '../../contrib/chat/common/promptSyntax/promptTypes.js'; +import type { IExtensionPromptFileResult } from '../../contrib/chat/common/promptSyntax/chatPromptFilesContribution.js'; //#region --- NEW world @@ -560,7 +561,7 @@ const newCommands: ApiCommand[] = [ new ApiCommand( 'vscode.extensionPromptFileProvider', '_listExtensionPromptFiles', 'Get all extension-contributed prompt files (custom agents, instructions, and prompt files).', [], - new ApiCommandResult<{ uri: UriComponents; type: PromptsType }[], { uri: vscode.Uri; type: PromptsType }[]>( + new ApiCommandResult( 'A promise that resolves to an array of objects containing uri and type.', (value) => { if (!value) { diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 6c32e53750444..a55f22386940e 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -574,20 +574,42 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS } // Convert ChatResourceDescriptor to IPromptFileResource format - return resources?.map(r => this.convertChatResourceDescriptorToPromptFileResource(r.resource, providerData.extension.identifier.value)); + return resources?.map(r => this.convertChatResourceDescriptorToPromptFileResource(r.resource, providerData.extension.identifier.value, type)); } /** * Creates a virtual URI for a prompt file. + * Format varies by type: + * - Skills: /${extensionId}/skills/${id}/SKILL.md + * - Agents: /${extensionId}/agents/${id}.agent.md + * - Instructions: /${extensionId}/instructions/${id}.instructions.md + * - Prompts: /${extensionId}/prompts/${id}.prompt.md */ - createVirtualPromptUri(id: string, extensionId: string): URI { + createVirtualPromptUri(id: string, extensionId: string, type: PromptsType): URI { + let path: string; + switch (type) { + case PromptsType.skill: + path = `/${extensionId}/skills/${id}/SKILL.md`; + break; + case PromptsType.agent: + path = `/${extensionId}/agents/${id}.agent.md`; + break; + case PromptsType.instructions: + path = `/${extensionId}/instructions/${id}.instructions.md`; + break; + case PromptsType.prompt: + path = `/${extensionId}/prompts/${id}.prompt.md`; + break; + default: + throw new Error(`Unsupported PromptsType: ${type}`); + } return URI.from({ scheme: Schemas.vscodeChatPrompt, - path: `/${extensionId}/${id}` + path }); } - convertChatResourceDescriptorToPromptFileResource(resource: vscode.ChatResourceDescriptor | vscode.ChatResourceUriDescriptor, extensionId: string): IPromptFileResource { + convertChatResourceDescriptorToPromptFileResource(resource: vscode.ChatResourceDescriptor, extensionId: string, type: PromptsType): IPromptFileResource { if (URI.isUri(resource)) { // Plain URI return { uri: resource }; @@ -595,7 +617,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS // { id, content } return { content: resource.content, - uri: this.createVirtualPromptUri(resource.id, extensionId), + uri: this.createVirtualPromptUri(resource.id, extensionId, type), isEditable: undefined }; } else if ('uri' in resource && URI.isUri(resource.uri)) { diff --git a/src/vs/workbench/api/common/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index f59cbb25ec58c..9970d36434945 100644 --- a/src/vs/workbench/api/common/extHostLanguageModelTools.ts +++ b/src/vs/workbench/api/common/extHostLanguageModelTools.ts @@ -20,6 +20,7 @@ import { Dto, SerializableObjectWithBuffers } from '../../services/extensions/co import { ExtHostLanguageModelToolsShape, IMainContext, IToolDataDto, MainContext, MainThreadLanguageModelToolsShape } from './extHost.protocol.js'; import { ExtHostLanguageModels } from './extHostLanguageModels.js'; import * as typeConvert from './extHostTypeConverters.js'; +import { URI } from '../../../base/common/uri.js'; class Tool { @@ -184,6 +185,7 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape options.chatRequestId = dto.chatRequestId; options.chatInteractionId = dto.chatInteractionId; options.chatSessionId = dto.context?.sessionId; + options.chatSessionResource = URI.revive(dto.context?.sessionResource); options.subAgentInvocationId = dto.subAgentInvocationId; } @@ -262,6 +264,7 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape rawInput: context.rawInput, chatRequestId: context.chatRequestId, chatSessionId: context.chatSessionId, + chatSessionResource: context.chatSessionResource, chatInteractionId: context.chatInteractionId }; @@ -285,6 +288,7 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape input: context.parameters, chatRequestId: context.chatRequestId, chatSessionId: context.chatSessionId, + chatSessionResource: context.chatSessionResource, chatInteractionId: context.chatInteractionId }; if (item.tool.prepareInvocation) { diff --git a/src/vs/workbench/api/common/extHostMcp.ts b/src/vs/workbench/api/common/extHostMcp.ts index 5079b4f9c8fdb..e148c5d062853 100644 --- a/src/vs/workbench/api/common/extHostMcp.ts +++ b/src/vs/workbench/api/common/extHostMcp.ts @@ -7,6 +7,7 @@ import * as vscode from 'vscode'; import { DeferredPromise, raceCancellationError, Sequencer, timeout } from '../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { CancellationError } from '../../../base/common/errors.js'; +import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { AUTH_SCOPE_SEPARATOR, fetchAuthorizationServerMetadata, fetchResourceMetadata, getDefaultMetadataForUrl, IAuthorizationProtectedResourceMetadata, IAuthorizationServerMetadata, parseWWWAuthenticateHeader, scopesMatch } from '../../../base/common/oauth.js'; import { SSEParser } from '../../../base/common/sseParser.js'; @@ -33,6 +34,12 @@ export const IExtHostMpcService = createDecorator('IExtHostM export interface IExtHostMpcService extends ExtHostMcpShape { registerMcpConfigurationProvider(extension: IExtensionDescription, id: string, provider: vscode.McpServerDefinitionProvider): IDisposable; + + /** Event that fires when the set of MCP server definitions changes. */ + readonly onDidChangeMcpServerDefinitions: Event; + + /** Returns all MCP server definitions known to the editor. */ + readonly mcpServerDefinitions: readonly vscode.McpServerDefinition[]; } const serverDataValidation = vObj({ @@ -65,6 +72,11 @@ export class ExtHostMcpService extends Disposable implements IExtHostMpcService servers: vscode.McpServerDefinition[]; }>(); + // MCP server definitions synced from main thread + private readonly _onDidChangeMcpServerDefinitions = this._register(new Emitter()); + readonly onDidChangeMcpServerDefinitions: Event = this._onDidChangeMcpServerDefinitions.event; + private _mcpServerDefinitions: readonly vscode.McpServerDefinition[] = []; + constructor( @IExtHostRpcService extHostRpc: IExtHostRpcService, @ILogService protected readonly _logService: ILogService, @@ -76,6 +88,17 @@ export class ExtHostMcpService extends Disposable implements IExtHostMpcService this._proxy = extHostRpc.getProxy(MainContext.MainThreadMcp); } + /** Returns all MCP server definitions known to the editor. */ + get mcpServerDefinitions(): readonly vscode.McpServerDefinition[] { + return this._mcpServerDefinitions; + } + + /** Called by main thread to notify that MCP server definitions have changed. */ + $onDidChangeMcpServerDefinitions(servers: McpServerDefinition.Serialized[]): void { + this._mcpServerDefinitions = servers.map(dto => Convert.McpServerDefinition.to(dto)); + this._onDidChangeMcpServerDefinitions.fire(); + } + $startMcp(id: number, opts: IStartMcpOptions): void { this._startMcp(id, McpServerLaunch.fromSerialized(opts.launch), opts.defaultCwd && URI.revive(opts.defaultCwd), opts.errorOnUserInteraction); } diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index c22c4e08f7749..b49df82b3bb8b 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -50,7 +50,7 @@ import { IToolInvocationContext, IToolResult, IToolResultInputOutputDetails, ITo import * as chatProvider from '../../contrib/chat/common/languageModels.js'; import { IChatMessageDataPart, IChatResponseDataPart, IChatResponsePromptTsxPart, IChatResponseTextPart } from '../../contrib/chat/common/languageModels.js'; import { DebugTreeItemCollapsibleState, IDebugVisualizationTreeItem } from '../../contrib/debug/common/debug.js'; -import { McpServerLaunch, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js'; +import { McpServerDefinition as McpServerDefinitionType, McpServerLaunch, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js'; import * as notebooks from '../../contrib/notebook/common/notebookCommon.js'; import { CellEditType } from '../../contrib/notebook/common/notebookCommon.js'; import { ICellRange } from '../../contrib/notebook/common/notebookRange.js'; @@ -3148,6 +3148,7 @@ export namespace ChatAgentRequest { enableCommandDetection: request.enableCommandDetection ?? true, isParticipantDetected: request.isParticipantDetected ?? false, sessionId, + sessionResource: request.sessionResource, references: variableReferences .map(v => ChatPromptReference.to(v, diagnostics, logService)) .filter(isDefined), @@ -3768,6 +3769,31 @@ export namespace McpServerDefinition { } ); } + + /** Converts from the IPC DTO to the API type. */ + export function to(dto: McpServerDefinitionType.Serialized): vscode.McpServerDefinition { + const launch = McpServerLaunch.fromSerialized(dto.launch); + if (launch.type === McpServerTransportType.HTTP) { + return new types.McpHttpServerDefinition( + dto.label, + launch.uri, + Object.fromEntries(launch.headers), + dto.cacheNonce === '$$NONE' ? undefined : dto.cacheNonce, + ); + } else { + const result = new types.McpStdioServerDefinition( + dto.label, + launch.command, + [...launch.args], + Object.fromEntries(Object.entries(launch.env).map(([key, value]) => [key, value === null ? null : String(value)])), + dto.cacheNonce === '$$NONE' ? undefined : dto.cacheNonce, + ); + if (launch.cwd) { + result.cwd = URI.file(launch.cwd); + } + return result; + } + } } export namespace SourceControlInputBoxValidationType { diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 9e19f40e259ed..1efc19c563c6b 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3906,6 +3906,6 @@ export class PromptFileChatResource implements vscode.PromptFileChatResource { @es5ClassCompat export class SkillChatResource implements vscode.SkillChatResource { - constructor(public readonly resource: vscode.ChatResourceUriDescriptor) { } + constructor(public readonly resource: vscode.ChatResourceDescriptor) { } } //#endregion diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 3604b8b75eb5d..2fbb832ecced7 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -467,9 +467,11 @@ export class BrowserEditor extends EditorPane { if (this._model) { this.group.pinEditor(this.input); // pin editor on navigation - const scheme = URL.parse(url)?.protocol; - if (!scheme) { - // If no scheme provided, default to http (to support localhost etc -- sites will generally upgrade to https) + // Special case localhost URLs (e.g., "localhost:3000") to add http:// + if (/^localhost(:|\/|$)/i.test(url)) { + url = 'http://' + url; + } else if (!URL.parse(url)?.protocol) { + // If no scheme provided, default to http (sites will generally upgrade to https) url = 'http://' + url; } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts index 6b8991df48f83..e4ee6bdb3f010 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -145,18 +145,19 @@ class AddElementToChatAction extends Action2 { static readonly ID = 'workbench.action.browser.addElementToChat'; constructor() { + const enabled = ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('config.chat.sendElementsToChat.enabled', true)); super({ id: AddElementToChatAction.ID, title: localize2('browser.addElementToChatAction', 'Add Element to Chat'), icon: Codicon.inspect, - f1: true, - precondition: ChatContextKeys.enabled, + f1: false, + precondition: enabled, toggled: CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, menu: { id: MenuId.BrowserActionsToolbar, group: 'actions', order: 1, - when: ChatContextKeys.enabled + when: enabled }, keybinding: [{ when: BROWSER_EDITOR_ACTIVE, diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 1fac6850829e1..d85ecf4321b6b 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../../base/common/codicons.js'; +import { hash } from '../../../../../base/common/hash.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { basename } from '../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; @@ -27,6 +28,7 @@ import { IChatService } from '../../common/chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, } from '../../common/constants.js'; import { ILanguageModelChatMetadata } from '../../common/languageModels.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; +import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { IChatSessionsService } from '../../common/chatSessionsService.js'; import { IChatWidget, IChatWidgetService } from '../chat.js'; import { getAgentSessionProvider, AgentSessionProviders } from '../agentSessions/agentSessions.js'; @@ -325,9 +327,18 @@ class ToggleChatModeAction extends Action2 { const toolsCount = switchToMode.customTools?.get()?.length ?? 0; const handoffsCount = switchToMode.handOffs?.get()?.length ?? 0; + // Hash names for user/workspace modes to only instrument non-user agent names + const getModeNameForTelemetry = (mode: IChatMode): string => { + const modeStorage = mode.source?.storage; + if (modeStorage === PromptsStorage.local || modeStorage === PromptsStorage.user) { + return String(hash(mode.name.get())); + } + return mode.name.get(); + }; + telemetryService.publicLog2('chat.modeChange', { - fromMode: currentMode.name.get(), - mode: switchToMode.name.get(), + fromMode: getModeNameForTelemetry(currentMode), + mode: getModeNameForTelemetry(switchToMode), requestCount: requestCount, storage, extensionId, diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatGettingStarted.ts b/src/vs/workbench/contrib/chat/browser/actions/chatGettingStarted.ts index 663a8f4054dc8..d78a88982b2f0 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatGettingStarted.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatGettingStarted.ts @@ -12,6 +12,7 @@ import { IExtensionManagementService, InstallOperation } from '../../../../../pl import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IDefaultChatAgent } from '../../../../../base/common/product.js'; import { IChatWidgetService } from '../chat.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; export class ChatGettingStartedContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.chatGettingStarted'; @@ -25,6 +26,7 @@ export class ChatGettingStartedContribution extends Disposable implements IWorkb @IExtensionManagementService private readonly extensionManagementService: IExtensionManagementService, @IStorageService private readonly storageService: IStorageService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); @@ -63,8 +65,12 @@ export class ChatGettingStartedContribution extends Disposable implements IWorkb private async onDidInstallChat() { - // Open Chat view - this.chatWidgetService.revealWidget(); + // Don't reveal if user prefers the agent sessions welcome page + const startupEditor = this.configurationService.getValue('workbench.startupEditor'); + if (startupEditor !== 'agentSessionsWelcomePage') { + // Open Chat view + this.chatWidgetService.revealWidget(); + } // Only do this once this.storageService.store(ChatGettingStartedContribution.hideWelcomeView, true, StorageScope.APPLICATION, StorageTarget.MACHINE); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index 4cc9edf8ac918..474f1a2530ac9 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -86,6 +86,21 @@ export function getAgentCanContinueIn(provider: AgentSessionProviders): boolean } } +export function getAgentSessionProviderDescription(provider: AgentSessionProviders): string { + switch (provider) { + case AgentSessionProviders.Local: + return localize('chat.session.providerDescription.local', "Run tasks within VS Code chat. The agent iterates via chat and works interactively to implement changes on your main workspace."); + case AgentSessionProviders.Background: + return localize('chat.session.providerDescription.background', "Delegate tasks to a background agent running locally on your machine. The agent iterates via chat and works asynchronously in a Git worktree to implement changes isolated from your main workspace using the GitHub Copilot CLI."); + case AgentSessionProviders.Cloud: + return localize('chat.session.providerDescription.cloud', "Delegate tasks to the GitHub Copilot coding agent. The agent iterates via chat and works asynchronously in the cloud to implement changes and pull requests as needed."); + case AgentSessionProviders.Claude: + return localize('chat.session.providerDescription.claude', "Delegate tasks to the Claude SDK running locally on your machine. The agent iterates via chat and works asynchronously to implement changes."); + case AgentSessionProviders.Codex: + return localize('chat.session.providerDescription.codex', "Opens a new Codex session in the editor. Codex sessions can be managed from the chat sessions view."); + } +} + export enum AgentSessionsViewerOrientation { Stacked = 1, SideBySide, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index fad1044c603bf..23970c73a7ada 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -523,9 +523,15 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode // No previous state, just add it if (!state) { + const isInProgress = isSessionInProgressStatus(status); + let inProgressTime: number | undefined; + if (isInProgress) { + inProgressTime = Date.now(); + this.logger.logIfTrace(`[agent sessions] Setting inProgressTime for session ${session.resource.toString()} to ${inProgressTime} (status: ${status})`); + } this.mapSessionToState.set(session.resource, { status, - inProgressTime: isSessionInProgressStatus(status) ? Date.now() : undefined, // this is not accurate but best effort + inProgressTime, }); } @@ -567,6 +573,8 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode } } + this.logger.logIfTrace(`[agent sessions] Resolved session ${session.resource.toString()} with timings: created=${created}, lastRequestStarted=${lastRequestStarted}, lastRequestEnded=${lastRequestEnded}`); + sessions.set(session.resource, this.toAgentSession({ providerType: chatSessionType, providerLabel, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts index cc74b7234b548..15182db4d4c95 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts @@ -108,3 +108,23 @@ export class ToggleAgentStatusAction extends ToggleTitleBarConfigAction { } //#endregion + +//#region Toggle Unified Agents Bar + +export class ToggleUnifiedAgentsBarAction extends ToggleTitleBarConfigAction { + constructor() { + super( + ChatConfiguration.UnifiedAgentsBar, + localize('toggle.unifiedAgentsBar', 'Unified Agents Bar'), + localize('toggle.unifiedAgentsBarDescription', "Toggle Unified Agents Bar, replacing the classic command center search box."), 7, + ContextKeyExpr.and( + ChatContextKeys.enabled, + IsCompactTitleBarContext.negate(), + ChatContextKeys.supported, + ContextKeyExpr.has(`config.${ChatConfiguration.AgentStatusEnabled}`) + ) + ); + } +} + +//#endregion diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionService.ts index e798dc3ce8ac6..2755033c63134 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionService.ts @@ -262,7 +262,7 @@ export class AgentSessionProjectionService extends Disposable implements IAgentS this.layoutService.mainContainer.classList.add('agent-session-projection-active'); // Update the agent status to show session mode - this.agentTitleBarStatusService.enterSessionMode(session.resource.toString(), session.label); + this.agentTitleBarStatusService.enterSessionMode(session.resource, session.label); if (!wasActive) { this._onDidChangeProjectionMode.fire(true); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts index 153e27cfde74e..5a167c4584e03 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts @@ -6,13 +6,14 @@ import { registerSingleton, InstantiationType } from '../../../../../../platform/instantiation/common/extensions.js'; import { MenuId, MenuRegistry, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; import { IAgentSessionProjectionService, AgentSessionProjectionService, AgentSessionProjectionOpenerContribution } from './agentSessionProjectionService.js'; -import { EnterAgentSessionProjectionAction, ExitAgentSessionProjectionAction, ToggleAgentStatusAction } from './agentSessionProjectionActions.js'; +import { EnterAgentSessionProjectionAction, ExitAgentSessionProjectionAction, ToggleAgentStatusAction, ToggleUnifiedAgentsBarAction } from './agentSessionProjectionActions.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../common/contributions.js'; import { AgentTitleBarStatusRendering } from './agentTitleBarStatusWidget.js'; import { AgentTitleBarStatusService, IAgentTitleBarStatusService } from './agentTitleBarStatusService.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { localize } from '../../../../../../nls.js'; import { ContextKeyExpr } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { ProductQualityContext } from '../../../../../../platform/contextkey/common/contextkeys.js'; import { ChatConfiguration } from '../../../common/constants.js'; // #region Agent Session Projection & Status @@ -20,6 +21,7 @@ import { ChatConfiguration } from '../../../common/constants.js'; registerAction2(EnterAgentSessionProjectionAction); registerAction2(ExitAgentSessionProjectionAction); registerAction2(ToggleAgentStatusAction); +registerAction2(ToggleUnifiedAgentsBarAction); registerSingleton(IAgentSessionProjectionService, AgentSessionProjectionService, InstantiationType.Delayed); registerSingleton(IAgentTitleBarStatusService, AgentTitleBarStatusService, InstantiationType.Delayed); @@ -43,6 +45,21 @@ MenuRegistry.appendMenuItem(MenuId.AgentsTitleBarControlMenu, { title: localize('openChat', "Open Chat"), }, when: ContextKeyExpr.has(`config.${ChatConfiguration.AgentStatusEnabled}`), + order: 1 +}); + +// Toggle for Unified Agents Bar (Insiders only) +MenuRegistry.appendMenuItem(MenuId.AgentsTitleBarControlMenu, { + command: { + id: `toggle.${ChatConfiguration.UnifiedAgentsBar}`, + title: localize('toggleUnifiedAgentsBar', "Unified Agents Bar"), + toggled: ContextKeyExpr.has(`config.${ChatConfiguration.UnifiedAgentsBar}`), + }, + when: ContextKeyExpr.and( + ContextKeyExpr.has(`config.${ChatConfiguration.AgentStatusEnabled}`), + ProductQualityContext.notEqualsTo('stable') + ), + order: 10 }); //#endregion diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusService.ts index 0c8389b40607c..f6a1c08ad1d74 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusService.ts @@ -5,6 +5,7 @@ import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../../base/common/uri.js'; import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js'; //#region Agent Status Mode @@ -17,7 +18,7 @@ export enum AgentStatusMode { } export interface IAgentStatusSessionInfo { - readonly sessionId: string; + readonly sessionResource: URI; readonly title: string; } @@ -52,7 +53,7 @@ export interface IAgentTitleBarStatusService { * Enter session mode, showing the session title and escape button. * Used by Agent Session Projection when entering a focused session view. */ - enterSessionMode(sessionId: string, title: string): void; + enterSessionMode(sessionResource: URI, title: string): void; /** * Exit session mode, returning to the default mode with workspace name and stats. @@ -88,8 +89,8 @@ export class AgentTitleBarStatusService extends Disposable implements IAgentTitl private readonly _onDidChangeSessionInfo = this._register(new Emitter()); readonly onDidChangeSessionInfo = this._onDidChangeSessionInfo.event; - enterSessionMode(sessionId: string, title: string): void { - const newInfo: IAgentStatusSessionInfo = { sessionId, title }; + enterSessionMode(sessionResource: URI, title: string): void { + const newInfo: IAgentStatusSessionInfo = { sessionResource, title }; const modeChanged = this._mode !== AgentStatusMode.Session; this._mode = AgentStatusMode.Session; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts index bb4bdaf63863f..0d1ced311e970 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts @@ -18,7 +18,7 @@ import { ExitAgentSessionProjectionAction } from './agentSessionProjectionAction import { IAgentSessionsService } from '../agentSessionsService.js'; import { AgentSessionStatus, IAgentSession, isSessionInProgressStatus } from '../agentSessionsModel.js'; import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../../../base/browser/ui/actionbar/actionViewItems.js'; -import { IAction, SubmenuAction } from '../../../../../../base/common/actions.js'; +import { IAction, SubmenuAction, toAction } from '../../../../../../base/common/actions.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IBrowserWorkbenchEnvironmentService } from '../../../../../services/environment/browser/environmentService.js'; @@ -29,9 +29,10 @@ import { Schemas } from '../../../../../../base/common/network.js'; import { renderAsPlaintext } from '../../../../../../base/browser/markdownRenderer.js'; import { openSession } from '../agentSessionsOpener.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; -import { IMenuService, MenuId, SubmenuItemAction } from '../../../../../../platform/actions/common/actions.js'; +import { IMenuService, MenuId, MenuItemAction, SubmenuItemAction } from '../../../../../../platform/actions/common/actions.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { HiddenItemStrategy, WorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js'; +import { DropdownWithPrimaryActionViewItem } from '../../../../../../platform/actions/browser/dropdownWithPrimaryActionViewItem.js'; import { createActionViewItem } from '../../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { FocusAgentSessionsAction } from '../agentSessionsActions.js'; @@ -42,9 +43,13 @@ import { mainWindow } from '../../../../../../base/browser/window.js'; import { LayoutSettings } from '../../../../../services/layout/browser/layoutService.js'; import { ChatConfiguration } from '../../../common/constants.js'; -// Action triggered when clicking the main pill - change this to modify the primary action -const ACTION_ID = 'workbench.action.quickchat.toggle'; -const SEARCH_BUTTON_ACITON_ID = 'workbench.action.quickOpenWithModes'; +// Action IDs +const QUICK_CHAT_ACTION_ID = 'workbench.action.quickchat.toggle'; +const TOGGLE_CHAT_ACTION_ID = 'workbench.action.chat.toggle'; +const QUICK_OPEN_ACTION_ID = 'workbench.action.quickOpenWithModes'; + +// Storage key for filter state +const FILTER_STORAGE_KEY = 'agentSessions.filterExcludes.agentsessionsviewerfiltersubmenu'; const NLS_EXTENSION_HOST = localize('devExtensionWindowTitlePrefix', "[Extension Development Host]"); const TITLE_DIRTY = '\u25cf '; @@ -60,8 +65,6 @@ const TITLE_DIRTY = '\u25cf '; */ export class AgentTitleBarStatusWidget extends BaseActionViewItem { - private static readonly _quickOpenCommandId = 'workbench.action.quickOpenWithModes'; - private _container: HTMLElement | undefined; private readonly _dynamicDisposables = this._register(new DisposableStore()); @@ -71,9 +74,15 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { /** Cached render state to avoid unnecessary DOM rebuilds */ private _lastRenderState: string | undefined; + /** Guard to prevent re-entrant rendering */ + private _isRendering = false; + /** Reusable menu for CommandCenterCenter items (e.g., debug toolbar) */ private readonly _commandCenterMenu; + /** Menu for ChatTitleBarMenu items (same as chat controls dropdown) */ + private readonly _chatTitleBarMenu; + constructor( action: IAction, options: IBaseActionViewItemOptions | undefined, @@ -91,12 +100,16 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { @IMenuService private readonly menuService: IMenuService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IStorageService private readonly storageService: IStorageService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(undefined, action, options); // Create menu for CommandCenterCenter to get items like debug toolbar this._commandCenterMenu = this._register(this.menuService.createMenu(MenuId.CommandCenterCenter, this.contextKeyService)); + // Create menu for ChatTitleBarMenu to show in sparkle section dropdown + this._chatTitleBarMenu = this._register(this.menuService.createMenu(MenuId.ChatTitleBarMenu, this.contextKeyService)); + // Re-render when control mode or session info changes this._register(this.agentTitleBarStatusService.onDidChangeMode(() => { this._render(); @@ -128,6 +141,19 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { this._lastRenderState = undefined; // Force re-render this._render(); })); + + // Re-render when storage changes (e.g., filter state changes from sessions view) + this._register(this.storageService.onDidChangeValue(StorageScope.PROFILE, 'agentSessions.filterExcludes.agentsessionsviewerfiltersubmenu', this._store)(() => { + this._render(); + })); + + // Re-render when enhanced setting changes + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ChatConfiguration.UnifiedAgentsBar)) { + this._lastRenderState = undefined; // Force re-render + this._render(); + } + })); } override render(container: HTMLElement): void { @@ -144,57 +170,78 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { return; } - // Compute current render state to avoid unnecessary DOM rebuilds - const mode = this.agentTitleBarStatusService.mode; - const sessionInfo = this.agentTitleBarStatusService.sessionInfo; - const { activeSessions, unreadSessions, attentionNeededSessions } = this._getSessionStats(); - - // Get attention session info for state computation - const attentionSession = attentionNeededSessions.length > 0 - ? [...attentionNeededSessions].sort((a, b) => { - const timeA = a.timing.lastRequestStarted ?? a.timing.created; - const timeB = b.timing.lastRequestStarted ?? b.timing.created; - return timeB - timeA; - })[0] - : undefined; - - const attentionText = attentionSession?.description - ? (typeof attentionSession.description === 'string' - ? attentionSession.description - : renderAsPlaintext(attentionSession.description)) - : attentionSession?.label; - - const label = this._getLabel(); - - // Build state key for comparison - const stateKey = JSON.stringify({ - mode, - sessionTitle: sessionInfo?.title, - activeCount: activeSessions.length, - unreadCount: unreadSessions.length, - attentionCount: attentionNeededSessions.length, - attentionText, - label, - }); - - // Skip re-render if state hasn't changed - if (this._lastRenderState === stateKey) { + if (this._isRendering) { return; } - this._lastRenderState = stateKey; + this._isRendering = true; + + try { + // Compute current render state to avoid unnecessary DOM rebuilds + const mode = this.agentTitleBarStatusService.mode; + const sessionInfo = this.agentTitleBarStatusService.sessionInfo; + const { activeSessions, unreadSessions, attentionNeededSessions } = this._getSessionStats(); + + // Get attention session info for state computation + const attentionSession = attentionNeededSessions.length > 0 + ? [...attentionNeededSessions].sort((a, b) => { + const timeA = a.timing.lastRequestStarted ?? a.timing.created; + const timeB = b.timing.lastRequestStarted ?? b.timing.created; + return timeB - timeA; + })[0] + : undefined; + + const attentionText = attentionSession?.description + ? (typeof attentionSession.description === 'string' + ? attentionSession.description + : renderAsPlaintext(attentionSession.description)) + : attentionSession?.label; + + const label = this._getLabel(); + + // Get current filter state for state key + const { isFilteredToUnread, isFilteredToInProgress } = this._getCurrentFilterState(); + + // Check if enhanced mode is enabled + const isEnhanced = this.configurationService.getValue(ChatConfiguration.UnifiedAgentsBar) === true; + + // Build state key for comparison + const stateKey = JSON.stringify({ + mode, + sessionTitle: sessionInfo?.title, + activeCount: activeSessions.length, + unreadCount: unreadSessions.length, + attentionCount: attentionNeededSessions.length, + attentionText, + label, + isFilteredToUnread, + isFilteredToInProgress, + isEnhanced, + }); + + // Skip re-render if state hasn't changed + if (this._lastRenderState === stateKey) { + return; + } + this._lastRenderState = stateKey; - // Clear existing content - reset(this._container); + // Clear existing content + reset(this._container); - // Clear previous disposables for dynamic content - this._dynamicDisposables.clear(); + // Clear previous disposables for dynamic content + this._dynamicDisposables.clear(); - if (this.agentTitleBarStatusService.mode === AgentStatusMode.Session) { - // Agent Session Projection mode - show session title + close button - this._renderSessionMode(this._dynamicDisposables); - } else { - // Default mode - show copilot pill with optional in-progress indicator - this._renderChatInputMode(this._dynamicDisposables); + if (this.agentTitleBarStatusService.mode === AgentStatusMode.Session) { + // Agent Session Projection mode - show session title + close button + this._renderSessionMode(this._dynamicDisposables); + } else if (isEnhanced) { + // Enhanced mode - show full pill with label + status badge + this._renderChatInputMode(this._dynamicDisposables); + } else { + // Basic mode - show only the status badge (sparkle + unread/active counts) + this._renderBadgeOnlyMode(this._dynamicDisposables); + } + } finally { + this._isRendering = false; } } @@ -313,7 +360,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { if (this._displayedSession) { return localize('openSessionTooltip', "Open session: {0}", this._displayedSession.label); } - const kbForTooltip = this.keybindingService.lookupKeybinding(ACTION_ID)?.getLabel(); + const kbForTooltip = this.keybindingService.lookupKeybinding(QUICK_CHAT_ACTION_ID)?.getLabel(); return kbForTooltip ? localize('askTooltip', "Open Quick Chat ({0})", kbForTooltip) : localize('askTooltip2', "Open Quick Chat"); @@ -375,6 +422,21 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { this._renderStatusBadge(disposables, activeSessions, unreadSessions); } + /** + * Render badge-only mode - just the status badge without the full pill. + * Used when Agent Status is enabled but Enhanced Agent Status is not. + */ + private _renderBadgeOnlyMode(disposables: DisposableStore): void { + if (!this._container) { + return; + } + + const { activeSessions, unreadSessions } = this._getSessionStats(); + + // Status badge only - no pill, no command center toolbar + this._renderStatusBadge(disposables, activeSessions, unreadSessions); + } + // #endregion // #region Reusable Components @@ -394,7 +456,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { for (const [, actions] of this._commandCenterMenu.getActions({ shouldForwardArgs: true })) { for (const action of actions) { // Filter out the quick open action - we provide our own search UI - if (action.id === AgentTitleBarStatusWidget._quickOpenCommandId) { + if (action.id === QUICK_OPEN_ACTION_ID) { continue; } // For submenus (like debug toolbar), add the submenu actions @@ -450,7 +512,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Setup hover const hoverDelegate = getDefaultHoverDelegate('mouse'); - const searchKb = this.keybindingService.lookupKeybinding(SEARCH_BUTTON_ACITON_ID)?.getLabel(); + const searchKb = this.keybindingService.lookupKeybinding(QUICK_OPEN_ACTION_ID)?.getLabel(); const searchTooltip = searchKb ? localize('openQuickOpenTooltip', "Go to File ({0})", searchKb) : localize('openQuickOpenTooltip2', "Go to File"); @@ -460,7 +522,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { disposables.add(addDisposableListener(searchButton, EventType.CLICK, (e) => { e.preventDefault(); e.stopPropagation(); - this.commandService.executeCommand(SEARCH_BUTTON_ACITON_ID); + this.commandService.executeCommand(QUICK_OPEN_ACTION_ID); })); // Keyboard handler @@ -468,15 +530,15 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); - this.commandService.executeCommand(SEARCH_BUTTON_ACITON_ID); + this.commandService.executeCommand(QUICK_OPEN_ACTION_ID); } })); } /** * Render the status badge showing in-progress and/or unread session counts. - * Shows split UI with both indicators when both types exist. - * When no notifications, shows a chat sparkle icon. + * Shows split UI with sparkle icon on left, then unread and active indicators. + * Always renders the sparkle icon section. */ private _renderStatusBadge(disposables: DisposableStore, activeSessions: IAgentSession[], unreadSessions: IAgentSession[]): void { if (!this._container) { @@ -485,7 +547,6 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { const hasActiveSessions = activeSessions.length > 0; const hasUnreadSessions = unreadSessions.length > 0; - const hasContent = hasActiveSessions || hasUnreadSessions; // Auto-clear filter if the filtered category becomes empty this._clearFilterIfCategoryEmpty(hasUnreadSessions, hasActiveSessions); @@ -493,15 +554,52 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { const badge = $('div.agent-status-badge'); this._container.appendChild(badge); - // When no notifications, hide the badge - if (!hasContent) { - badge.classList.add('empty'); - return; + // Sparkle dropdown button section (always visible on left) - proper button with dropdown menu + const sparkleContainer = $('span.agent-status-badge-section.sparkle'); + badge.appendChild(sparkleContainer); + + // Get menu actions for dropdown + const menuActions: IAction[] = []; + for (const [, actions] of this._chatTitleBarMenu.getActions({ shouldForwardArgs: true })) { + menuActions.push(...actions); } + // Create primary action (toggle chat) + const primaryAction = this.instantiationService.createInstance(MenuItemAction, { + id: TOGGLE_CHAT_ACTION_ID, + title: localize('toggleChat', "Toggle Chat"), + icon: Codicon.chatSparkle, + }, undefined, undefined, undefined, undefined); + + // Create dropdown action (empty label prevents default tooltip - we have our own hover) + const dropdownAction = toAction({ + id: 'agentStatus.sparkle.dropdown', + label: '', + run() { } + }); + + // Create the dropdown with primary action button + const sparkleDropdown = this.instantiationService.createInstance( + DropdownWithPrimaryActionViewItem, + primaryAction, + dropdownAction, + menuActions, + 'agent-status-sparkle-dropdown', + { skipTelemetry: true } + ); + sparkleDropdown.render(sparkleContainer); + disposables.add(sparkleDropdown); + + // Hover delegate for status sections + const hoverDelegate = getDefaultHoverDelegate('mouse'); + // Unread section (blue dot + count) if (hasUnreadSessions) { + const { isFilteredToUnread } = this._getCurrentFilterState(); const unreadSection = $('span.agent-status-badge-section.unread'); + if (isFilteredToUnread) { + unreadSection.classList.add('filtered'); + } unreadSection.setAttribute('role', 'button'); unreadSection.tabIndex = 0; const unreadIcon = $('span.agent-status-icon'); @@ -525,11 +623,21 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { this._openSessionsWithFilter('unread'); } })); + + // Hover tooltip for unread section + const unreadTooltip = unreadSessions.length === 1 + ? localize('unreadSessionsTooltip1', "{0} unread session", unreadSessions.length) + : localize('unreadSessionsTooltip', "{0} unread sessions", unreadSessions.length); + disposables.add(this.hoverService.setupManagedHover(hoverDelegate, unreadSection, unreadTooltip)); } // In-progress section (session-in-progress icon + count) if (hasActiveSessions) { + const { isFilteredToInProgress } = this._getCurrentFilterState(); const activeSection = $('span.agent-status-badge-section.active'); + if (isFilteredToInProgress) { + activeSection.classList.add('filtered'); + } activeSection.setAttribute('role', 'button'); activeSection.tabIndex = 0; const runningIcon = $('span.agent-status-icon'); @@ -553,24 +661,14 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { this._openSessionsWithFilter('inProgress'); } })); + + // Hover tooltip for active section + const activeTooltip = activeSessions.length === 1 + ? localize('activeSessionsTooltip1', "{0} session in progress", activeSessions.length) + : localize('activeSessionsTooltip', "{0} sessions in progress", activeSessions.length); + disposables.add(this.hoverService.setupManagedHover(hoverDelegate, activeSection, activeTooltip)); } - // Setup hover with combined tooltip - const hoverDelegate = getDefaultHoverDelegate('mouse'); - disposables.add(this.hoverService.setupManagedHover(hoverDelegate, badge, () => { - const parts: string[] = []; - if (hasUnreadSessions) { - parts.push(unreadSessions.length === 1 - ? localize('unreadSessionsTooltip1', "{0} unread session", unreadSessions.length) - : localize('unreadSessionsTooltip', "{0} unread sessions", unreadSessions.length)); - } - if (hasActiveSessions) { - parts.push(activeSessions.length === 1 - ? localize('activeSessionsTooltip1', "{0} session in progress", activeSessions.length) - : localize('activeSessionsTooltip', "{0} sessions in progress", activeSessions.length)); - } - return parts.join(', '); - })); } /** @@ -578,107 +676,99 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { * For example, if filtered to "unread" but no unread sessions exist, clear the filter. */ private _clearFilterIfCategoryEmpty(hasUnreadSessions: boolean, hasActiveSessions: boolean): void { - const FILTER_STORAGE_KEY = 'agentSessions.filterExcludes.agentsessionsviewerfiltersubmenu'; - - const currentFilterStr = this.storageService.get(FILTER_STORAGE_KEY, StorageScope.PROFILE); - if (!currentFilterStr) { - return; - } + const { isFilteredToUnread, isFilteredToInProgress } = this._getCurrentFilterState(); - let currentFilter: { providers: string[]; states: AgentSessionStatus[]; archived: boolean; read: boolean } | undefined; - try { - currentFilter = JSON.parse(currentFilterStr); - } catch { - return; + // Clear filter if filtered category is now empty + if ((isFilteredToUnread && !hasUnreadSessions) || (isFilteredToInProgress && !hasActiveSessions)) { + this._clearFilter(); } + } - if (!currentFilter) { - return; + /** + * Get the current filter state from storage. + */ + private _getCurrentFilterState(): { isFilteredToUnread: boolean; isFilteredToInProgress: boolean } { + const filter = this._getStoredFilter(); + if (!filter) { + return { isFilteredToUnread: false, isFilteredToInProgress: false }; } // Detect if filtered to unread (read=true excludes read sessions, leaving only unread) - const isFilteredToUnread = currentFilter.read === true && currentFilter.states.length === 0; + const isFilteredToUnread = filter.read === true && filter.states.length === 0; // Detect if filtered to in-progress (2 excluded states = Completed + Failed) - const isFilteredToInProgress = currentFilter.states?.length === 2 && currentFilter.read === false; + const isFilteredToInProgress = filter.states?.length === 2 && filter.read === false; - // Clear filter if filtered category is now empty - if ((isFilteredToUnread && !hasUnreadSessions) || (isFilteredToInProgress && !hasActiveSessions)) { - const clearedFilter = { - providers: [], - states: [], - archived: true, - read: false - }; - this.storageService.store(FILTER_STORAGE_KEY, JSON.stringify(clearedFilter), StorageScope.PROFILE, StorageTarget.USER); + return { isFilteredToUnread, isFilteredToInProgress }; + } + + /** + * Get the stored filter object from storage. + */ + private _getStoredFilter(): { providers: string[]; states: AgentSessionStatus[]; archived: boolean; read: boolean } | undefined { + const filterStr = this.storageService.get(FILTER_STORAGE_KEY, StorageScope.PROFILE); + if (!filterStr) { + return undefined; + } + try { + return JSON.parse(filterStr); + } catch { + return undefined; } } + /** + * Store a filter object to storage. + */ + private _storeFilter(filter: { providers: string[]; states: AgentSessionStatus[]; archived: boolean; read: boolean }): void { + this.storageService.store(FILTER_STORAGE_KEY, JSON.stringify(filter), StorageScope.PROFILE, StorageTarget.USER); + } + + /** + * Clear all filters (reset to default). + */ + private _clearFilter(): void { + this._storeFilter({ + providers: [], + states: [], + archived: true, + read: false + }); + } + /** * Opens the agent sessions view with a specific filter applied, or clears filter if already applied. * @param filterType 'unread' to show only unread sessions, 'inProgress' to show only in-progress sessions */ private _openSessionsWithFilter(filterType: 'unread' | 'inProgress'): void { - const FILTER_STORAGE_KEY = 'agentSessions.filterExcludes.agentsessionsviewerfiltersubmenu'; - - // Check current filter to see if we should toggle off - const currentFilterStr = this.storageService.get(FILTER_STORAGE_KEY, StorageScope.PROFILE); - let currentFilter: { providers: string[]; states: AgentSessionStatus[]; archived: boolean; read: boolean } | undefined; - if (currentFilterStr) { - try { - currentFilter = JSON.parse(currentFilterStr); - } catch { - // Ignore parse errors - } - } - - // Determine if the current filter matches what we're clicking - const isCurrentlyFilteredToUnread = currentFilter?.read === true && currentFilter.states.length === 0; - const isCurrentlyFilteredToInProgress = currentFilter?.states?.length === 2 && currentFilter.read === false; - - // Build filter excludes based on filter type - let excludes: { providers: string[]; states: AgentSessionStatus[]; archived: boolean; read: boolean }; + const { isFilteredToUnread, isFilteredToInProgress } = this._getCurrentFilterState(); + // Toggle filter based on current state if (filterType === 'unread') { - if (isCurrentlyFilteredToUnread) { - // Toggle off - clear all filters - excludes = { - providers: [], - states: [], - archived: true, - read: false - }; + if (isFilteredToUnread) { + this._clearFilter(); } else { // Exclude read sessions to show only unread - excludes = { + this._storeFilter({ providers: [], states: [], archived: true, - read: true // exclude read sessions - }; + read: true + }); } } else { - if (isCurrentlyFilteredToInProgress) { - // Toggle off - clear all filters - excludes = { - providers: [], - states: [], - archived: true, - read: false - }; + if (isFilteredToInProgress) { + this._clearFilter(); } else { // Exclude Completed and Failed to show InProgress and NeedsInput - excludes = { + this._storeFilter({ providers: [], states: [AgentSessionStatus.Completed, AgentSessionStatus.Failed], archived: true, read: false - }; + }); } } - // Store the filter - this.storageService.store(FILTER_STORAGE_KEY, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); - // Open the sessions view this.commandService.executeCommand(FocusAgentSessionsAction.id); } @@ -732,7 +822,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { if (this._displayedSession) { this.instantiationService.invokeFunction(openSession, this._displayedSession); } else { - this.commandService.executeCommand(ACTION_ID); + this.commandService.executeCommand(QUICK_CHAT_ACTION_ID); } } @@ -834,7 +924,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { * Provides custom rendering for the agent status in the command center. * Uses IActionViewItemService to render a custom AgentStatusWidget * for the AgentsControlMenu submenu. - * Also adds a CSS class to the workbench when agent status is enabled. + * Also adds CSS classes to the workbench based on settings. */ export class AgentTitleBarStatusRendering extends Disposable implements IWorkbenchContribution { @@ -854,20 +944,28 @@ export class AgentTitleBarStatusRendering extends Disposable implements IWorkben return instantiationService.createInstance(AgentTitleBarStatusWidget, action, options); }, undefined)); - // Add/remove CSS class on workbench based on setting - // Also force enable command center when agent status is enabled + // Add/remove CSS classes on workbench based on settings + // Force enable command center and disable chat controls when agent status is enabled const updateClass = () => { const enabled = configurationService.getValue(ChatConfiguration.AgentStatusEnabled) === true; + const enhanced = configurationService.getValue(ChatConfiguration.UnifiedAgentsBar) === true; + mainWindow.document.body.classList.toggle('agent-status-enabled', enabled); + mainWindow.document.body.classList.toggle('unified-agents-bar', enabled && enhanced); // Force enable command center when agent status is enabled if (enabled && configurationService.getValue(LayoutSettings.COMMAND_CENTER) !== true) { configurationService.updateValue(LayoutSettings.COMMAND_CENTER, true); } + + // Turn off chat controls when agent status is enabled (they would be duplicates) + if (enabled && configurationService.getValue('chat.commandCenter.enabled') === true) { + configurationService.updateValue('chat.commandCenter.enabled', false); + } }; updateClass(); this._register(configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ChatConfiguration.AgentStatusEnabled)) { + if (e.affectsConfiguration(ChatConfiguration.AgentStatusEnabled) || e.affectsConfiguration(ChatConfiguration.UnifiedAgentsBar)) { updateClass(); } })); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css index e4af6e88de495..0dfed92e7b61a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css @@ -3,30 +3,56 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* Hide command center search box when agent status enabled */ -.agent-status-enabled .command-center .action-item.command-center-center { +/* + * Command Center Integration + * Hide default search box when enhanced agent status replaces it. + */ +.unified-agents-bar .command-center .action-item.command-center-center { display: none !important; } -/* Give agent status same width as search box */ -.agent-status-enabled .command-center .action-item.agent-status-container { +.agent-status-enabled .command-center { + overflow: visible !important; +} + +/* + * Enhanced mode layout - full-width pill replacing command center search. + */ +.unified-agents-bar .command-center .action-item.agent-status-container { width: 38vw; max-width: 600px; display: flex; flex-direction: row; flex-wrap: nowrap; align-items: center; - justify-content: center; + justify-content: flex-start; +} + +/* + * Badge-only mode layout - compact badge next to command center. + */ +.agent-status-enabled:not(.unified-agents-bar) .command-center .action-item.agent-status-container { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + overflow: visible; } +/* + * Container - holds pill and/or badge. + * Right padding reserves space for badge chevron expansion. + */ .agent-status-container { display: flex; flex-direction: row; flex-wrap: nowrap; align-items: center; - justify-content: center; + justify-content: flex-start; gap: 4px; + padding-right: 22px; -webkit-app-region: no-drag; + overflow: visible; } /* Pill - shared styles */ @@ -283,26 +309,27 @@ align-items: center; } -/* Status badge (separate rectangle on right of pill) */ +/* + * Status Badge + * Split UI showing: [Sparkle + Chevron] | [Unread] | [Active] + * Expands rightward when chevron appears on hover. + */ .agent-status-badge { display: flex; align-items: center; gap: 0; height: 22px; border-radius: 6px; - overflow: hidden; - background-color: color-mix(in srgb, var(--vscode-progressBar-background) 15%, transparent); - border: 1px solid color-mix(in srgb, var(--vscode-progressBar-background) 50%, transparent); + background-color: var(--vscode-quickInput-background); + border: 1px solid var(--vscode-commandCenter-border, transparent); flex-shrink: 0; -webkit-app-region: no-drag; + position: relative; + overflow: visible; + margin-left: auto; } -/* Empty badge - completely hidden */ -.agent-status-badge.empty { - display: none; -} - -/* Badge section (for split UI) */ +/* Badge sections - clickable segments with hover states */ .agent-status-badge-section { display: flex; align-items: center; @@ -310,9 +337,29 @@ padding: 0 8px; height: 100%; position: relative; + cursor: pointer; +} + +.agent-status-badge-section:hover { + background-color: var(--vscode-chat-requestBubbleBackground); +} + +.agent-status-badge-section:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; } -/* Separator between sections */ +/* Active filter state - highlighted when filter is applied */ +.agent-status-badge-section.filtered { + background-color: var(--vscode-inputOption-activeBackground); +} + +.agent-status-badge-section.filtered:hover { + background-color: var(--vscode-inputOption-activeBackground); + filter: brightness(1.1); +} + +/* Vertical separator between badge sections */ .agent-status-badge-section + .agent-status-badge-section::before { content: ''; position: absolute; @@ -320,10 +367,95 @@ top: 4px; bottom: 4px; width: 1px; - background-color: color-mix(in srgb, var(--vscode-progressBar-background) 40%, transparent); + background-color: var(--vscode-commandCenter-border, rgba(128, 128, 128, 0.35)); +} + +/* Sparkle section - primary chat action with expandable chevron */ +.agent-status-badge-section.sparkle { + color: var(--vscode-foreground); + gap: 0; + padding: 0; +} + +/* Disable hover on sparkle section itself since children handle it */ +.agent-status-badge-section.sparkle:hover { + background-color: transparent; +} + +/* Dropdown button inside sparkle section */ +.agent-status-badge-section.sparkle .monaco-dropdown-with-primary { + display: flex; + align-items: center; + height: 100%; +} + +.agent-status-badge-section.sparkle .action-container { + display: flex; + align-items: center; + justify-content: center; + padding: 0 6px; + height: 100%; +} + +.agent-status-badge-section.sparkle .action-container:hover { + background-color: var(--vscode-chat-requestBubbleBackground); +} + +.agent-status-badge-section.sparkle .action-container:focus-within { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.agent-status-badge-section.sparkle .action-container .action-label { + display: flex; + align-items: center; + justify-content: center; + background: none !important; + width: 16px; + height: 16px; +} + +/* + * Chevron dropdown - slides out from sparkle section on hover. + * Badge expands rightward into reserved container padding. + */ +.agent-status-badge-section.sparkle .dropdown-action-container { + display: flex; + align-items: center; + justify-content: center; + width: 0; + height: 100%; + overflow: hidden; + transition: width 0.15s ease-out 0.3s; /* Linger 300ms before collapsing */ +} + +.agent-status-badge-section.sparkle:hover .dropdown-action-container, +.agent-status-badge-section.sparkle .dropdown-action-container:hover { + width: 22px; + transition: width 0.15s ease-out 0.1s; +} + +.agent-status-badge-section.sparkle .dropdown-action-container:hover { + background-color: var(--vscode-chat-requestBubbleBackground); +} + +.agent-status-badge-section.sparkle .dropdown-action-container:focus-within { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.agent-status-badge-section.sparkle .dropdown-action-container .action-label { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + font-size: 18px; + background-position: center !important; + background-size: 18px !important; } -/* Unread section styling */ +/* Unread section - blue dot indicator with count */ .agent-status-badge-section.unread { color: var(--vscode-foreground); } @@ -333,7 +465,7 @@ color: var(--vscode-notificationsInfoIcon-foreground); } -/* Active/in-progress section styling */ +/* Active section - in-progress indicator with count */ .agent-status-badge-section.active { color: var(--vscode-foreground); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts index 8b2b3947efb44..985cc88e9593c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts @@ -15,6 +15,7 @@ import { IChatModel } from '../../common/model/chatModel.js'; import { convertLegacyChatSessionTiming, IChatDetail, IChatService, ResponseModelState } from '../../common/chatService/chatService.js'; import { ChatSessionStatus, IChatSessionItem, IChatSessionItemProvider, IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; interface IChatSessionItemWithProvider extends IChatSessionItem { readonly provider: IChatSessionItemProvider; @@ -35,6 +36,7 @@ export class LocalAgentsSessionsProvider extends Disposable implements IChatSess constructor( @IChatService private readonly chatService: IChatService, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @ILogService private readonly logService: ILogService, ) { super(); @@ -126,10 +128,12 @@ export class LocalAgentsSessionsProvider extends Disposable implements IChatSess private modelToStatus(model: IChatModel): ChatSessionStatus | undefined { if (model.requestInProgress.get()) { + this.logService.trace(`[agent sessions] Session ${model.sessionResource.toString()} request is in progress.`); return ChatSessionStatus.InProgress; } const lastRequest = model.getRequests().at(-1); + this.logService.trace(`[agent sessions] Session ${model.sessionResource.toString()} last request response: state ${lastRequest?.response?.state}, isComplete ${lastRequest?.response?.isComplete}, isCanceled ${lastRequest?.response?.isCanceled}, error: ${lastRequest?.response?.result?.errorDetails?.message}.`); if (lastRequest?.response) { if (lastRequest.response.state === ResponseModelState.NeedsInput) { return ChatSessionStatus.NeedsInput; diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 7f2c4473ab125..d0ad59d06c11c 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -95,6 +95,7 @@ import { ChatMarkdownAnchorService, IChatMarkdownAnchorService } from './widget/ import { ChatContextPickService, IChatContextPickService } from './attachments/chatContextPickService.js'; import { ChatInputBoxContentProvider } from './widget/input/editor/chatEditorInputContentProvider.js'; import { ChatPromptContentProvider } from './promptSyntax/chatPromptContentProvider.js'; +import './promptSyntax/chatPromptFileSystemProvider.js'; import { ChatEditingEditorAccessibility } from './chatEditing/chatEditingEditorAccessibility.js'; import { registerChatEditorActions } from './chatEditing/chatEditingEditorActions.js'; import { ChatEditingEditorContextKeys } from './chatEditing/chatEditingEditorContextKeys.js'; @@ -194,7 +195,13 @@ configurationRegistry.registerConfiguration({ }, [ChatConfiguration.AgentStatusEnabled]: { type: 'boolean', - markdownDescription: nls.localize('chat.agentsControl.enabled', "Controls whether the Agent Status is shown in the title bar command center, replacing the search box with quick access to chat sessions. Enabling this setting will automatically enable {0}.", '`#window.commandCenter#`'), + markdownDescription: nls.localize('chat.agentsControl.enabled', "Controls whether the Agent Status indicator is shown in the title bar command center. Enabling this setting will automatically enable {0}.", '`#window.commandCenter#`'), + default: false, + tags: ['experimental'] + }, + [ChatConfiguration.UnifiedAgentsBar]: { + type: 'boolean', + markdownDescription: nls.localize('chat.unifiedAgentsBar.enabled', "When enabled alongside {0}, replaces the command center search box with a unified chat and search widget.", '`#chat.agentsControl.enabled#`'), default: false, tags: ['experimental'] }, @@ -202,7 +209,7 @@ configurationRegistry.registerConfiguration({ type: 'boolean', markdownDescription: nls.localize('chat.agentSessionProjection.enabled', "Controls whether Agent Session Projection mode is enabled for reviewing agent sessions in a focused workspace."), default: false, - tags: ['experimental'] + tags: ['experimental'], }, 'chat.implicitContext.enabled': { type: 'object', @@ -811,7 +818,7 @@ configurationRegistry.registerConfiguration({ }, [ChatConfiguration.ThinkingStyle]: { type: 'string', - default: 'collapsedPreview', + default: 'fixedScrolling', enum: ['collapsed', 'collapsedPreview', 'fixedScrolling'], enumDescriptions: [ nls.localize('chat.agent.thinkingMode.collapsed', "Thinking parts will be collapsed by default."), diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/chatPromptFileSystemProvider.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/chatPromptFileSystemProvider.ts new file mode 100644 index 0000000000000..daee1c2600615 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/chatPromptFileSystemProvider.ts @@ -0,0 +1,119 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../../base/common/event.js'; +import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { IFileDeleteOptions, IFileOverwriteOptions, FileSystemProviderCapabilities, FileType, IFileWriteOptions, IFileSystemProvider, IFileSystemProviderWithFileReadWriteCapability, IStat, IWatchOptions, createFileSystemProviderError, FileSystemProviderErrorCode, IFileService } from '../../../../../platform/files/common/files.js'; +import { IChatPromptContentStore } from '../../common/promptSyntax/chatPromptContentStore.js'; +import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../../common/contributions.js'; + +/** + * File system provider for virtual chat prompt files created with inline content. + * These URIs have the scheme 'vscode-chat-prompt' and retrieve their content + * from the {@link IChatPromptContentStore} which maintains an in-memory map + * of content indexed by URI. + * + * This enables external extensions to use VS Code's file system API to read + * these virtual prompt files. + */ +export class ChatPromptFileSystemProvider implements IFileSystemProvider, IFileSystemProviderWithFileReadWriteCapability { + + get capabilities() { + return FileSystemProviderCapabilities.FileReadWrite | FileSystemProviderCapabilities.Readonly; + } + + constructor( + private readonly chatPromptContentStore: IChatPromptContentStore + ) { } + + //#region Supported File Operations + + async stat(resource: URI): Promise { + const content = this.chatPromptContentStore.getContent(resource); + if (content === undefined) { + throw createFileSystemProviderError('file not found', FileSystemProviderErrorCode.FileNotFound); + } + + const size = VSBuffer.fromString(content).byteLength; + + return { + type: FileType.File, + ctime: 0, + mtime: 0, + size + }; + } + + async readFile(resource: URI): Promise { + const content = this.chatPromptContentStore.getContent(resource); + if (content === undefined) { + throw createFileSystemProviderError('file not found', FileSystemProviderErrorCode.FileNotFound); + } + + return VSBuffer.fromString(content).buffer; + } + + //#endregion + + //#region Unsupported File Operations + + readonly onDidChangeCapabilities = Event.None; + readonly onDidChangeFile = Event.None; + + async writeFile(resource: URI, content: Uint8Array, opts: IFileWriteOptions): Promise { + throw createFileSystemProviderError('not allowed', FileSystemProviderErrorCode.NoPermissions); + } + + async mkdir(resource: URI): Promise { + throw createFileSystemProviderError('not allowed', FileSystemProviderErrorCode.NoPermissions); + } + + async readdir(resource: URI): Promise<[string, FileType][]> { + return []; + } + + async rename(from: URI, to: URI, opts: IFileOverwriteOptions): Promise { + throw createFileSystemProviderError('not allowed', FileSystemProviderErrorCode.NoPermissions); + } + + async delete(resource: URI, opts: IFileDeleteOptions): Promise { + throw createFileSystemProviderError('not allowed', FileSystemProviderErrorCode.NoPermissions); + } + + watch(resource: URI, opts: IWatchOptions): IDisposable { + return Disposable.None; + } + + //#endregion +} + +/** + * Workbench contribution that registers the chat prompt file system provider. + */ +export class ChatPromptFileSystemProviderContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.chatPromptFileSystemProvider'; + + constructor( + @IFileService fileService: IFileService, + @IChatPromptContentStore chatPromptContentStore: IChatPromptContentStore + ) { + super(); + + this._register(fileService.registerProvider( + Schemas.vscodeChatPrompt, + new ChatPromptFileSystemProvider(chatPromptContentStore) + )); + } +} + +registerWorkbenchContribution2( + ChatPromptFileSystemProviderContribution.ID, + ChatPromptFileSystemProviderContribution, + WorkbenchPhase.Eventually +); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatChangesSummaryPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatChangesSummaryPart.ts index ca58382b9975a..7dcf175a3f470 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatChangesSummaryPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatChangesSummaryPart.ts @@ -8,7 +8,6 @@ import { $ } from '../../../../../../base/browser/dom.js'; import { ButtonWithIcon } from '../../../../../../base/browser/ui/button/button.js'; import { IListRenderer, IListVirtualDelegate } from '../../../../../../base/browser/ui/list/list.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { Emitter } from '../../../../../../base/common/event.js'; import { Iterable } from '../../../../../../base/common/iterator.js'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { autorun, IObservable } from '../../../../../../base/common/observable.js'; @@ -41,9 +40,6 @@ export class ChatCheckpointFileChangesSummaryContentPart extends Disposable impl public readonly ELEMENT_HEIGHT = 22; public readonly MAX_ITEMS_SHOWN = 6; - private readonly _onDidChangeHeight = this._register(new Emitter()); - public readonly onDidChangeHeight = this._onDidChangeHeight.event; - private readonly diffsBetweenRequests = new Map>(); private fileChangesDiffsObservable: IObservable; @@ -101,7 +97,6 @@ export class ChatCheckpointFileChangesSummaryContentPart extends Disposable impl const setExpansionState = () => { viewListButton.icon = this.isCollapsed ? Codicon.chevronRight : Codicon.chevronDown; this.domNode.classList.toggle('chat-file-changes-collapsed', this.isCollapsed); - this._onDidChangeHeight.fire(); }; setExpansionState(); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleContentPart.ts index 69311635b2c95..95456c9137439 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleContentPart.ts @@ -7,7 +7,6 @@ import { $ } from '../../../../../../base/browser/dom.js'; import { ButtonWithIcon } from '../../../../../../base/browser/ui/button/button.js'; import { HoverStyle } from '../../../../../../base/browser/ui/hover/hover.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { Emitter } from '../../../../../../base/common/event.js'; import { IMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { autorun, IObservable, observableValue } from '../../../../../../base/common/observable.js'; @@ -28,9 +27,6 @@ export abstract class ChatCollapsibleContentPart extends Disposable implements I private _domNode?: HTMLElement; private readonly _renderedTitleWithWidgets = this._register(new MutableDisposable()); - protected readonly _onDidChangeHeight = this._register(new Emitter()); - public readonly onDidChangeHeight = this._onDidChangeHeight.event; - protected readonly hasFollowingContent: boolean; protected _isExpanded = observableValue(this, false); protected _collapseButton: ButtonWithIcon | undefined; @@ -100,12 +96,6 @@ export abstract class ChatCollapsibleContentPart extends Disposable implements I collapseButton.icon = this._overrideIcon.read(r) ?? (expanded ? Codicon.chevronDown : Codicon.chevronRight); this._domNode?.classList.toggle('chat-used-context-collapsed', !expanded); this.updateAriaLabel(collapseButton.element, typeof referencesLabel === 'string' ? referencesLabel : referencesLabel.value, expanded); - - if (this._domNode?.isConnected) { - queueMicrotask(() => { - this._onDidChangeHeight.fire(); - }); - } })); const content = this.initContent(); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleMarkdownContentPart.ts index 1d4d8b9095dfe..c7f48ae40241d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleMarkdownContentPart.ts @@ -37,9 +37,7 @@ export class ChatCollapsibleMarkdownContentPart extends ChatCollapsibleContentPa if (this.markdownContent) { this.contentElement = $('.chat-collapsible-markdown-body'); - const rendered = this._register(this.chatContentMarkdownRenderer.render(new MarkdownString(this.markdownContent), { - asyncRenderCallback: () => this._onDidChangeHeight.fire(), - })); + const rendered = this._register(this.chatContentMarkdownRenderer.render(new MarkdownString(this.markdownContent))); this.contentElement.appendChild(rendered.element); wrapper.appendChild(this.contentElement); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationContentPart.ts index 69d2b040db4d3..07ec74d925f3b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationContentPart.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Emitter } from '../../../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../../../base/common/lifecycle.js'; import { localize } from '../../../../../../nls.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -17,9 +16,6 @@ import { IChatContentPart, IChatContentPartRenderContext } from './chatContentPa export class ChatConfirmationContentPart extends Disposable implements IChatContentPart { public readonly domNode: HTMLElement; - private readonly _onDidChangeHeight = this._register(new Emitter()); - public readonly onDidChangeHeight = this._onDidChangeHeight.event; - constructor( confirmation: IChatConfirmation, context: IChatContentPartRenderContext, @@ -43,8 +39,6 @@ export class ChatConfirmationContentPart extends Disposable implements IChatCont const confirmationWidget = this._register(this.instantiationService.createInstance(SimpleChatConfirmationWidget, context, { title: confirmation.title, buttons, message: confirmation.message })); confirmationWidget.setShowButtons(!confirmation.isUsed); - this._register(confirmationWidget.onDidChangeHeight(() => this._onDidChangeHeight.fire())); - this._register(confirmationWidget.onDidClick(async e => { if (isResponseVM(element)) { const prompt = `${e.label}: "${confirmation.title}"`; @@ -63,7 +57,6 @@ export class ChatConfirmationContentPart extends Disposable implements IChatCont if (await this.chatService.sendRequest(element.sessionResource, prompt, options)) { confirmation.isUsed = true; confirmationWidget.setShowButtons(false); - this._onDidChangeHeight.fire(); } } })); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationWidget.ts index d0fea5112929b..b58a0d67dc364 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationWidget.ts @@ -423,7 +423,6 @@ abstract class BaseChatConfirmationWidget extends Disposable { } satisfies IChatMarkdownContentPartOptions, )); renderFileWidgets(part.domNode, this.instantiationService, this.chatMarkdownAnchorService, this._store); - this._register(part.onDidChangeHeight(() => this._onDidChangeHeight.fire())); this.markdownContentPart.value = part; element = part.domNode; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatElicitationContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatElicitationContentPart.ts index 3f57d9627afd5..0018491307d4b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatElicitationContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatElicitationContentPart.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Emitter } from '../../../../../../base/common/event.js'; import { IMarkdownString, isMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { autorun } from '../../../../../../base/common/observable.js'; @@ -22,9 +21,6 @@ import { IAction } from '../../../../../../base/common/actions.js'; export class ChatElicitationContentPart extends Disposable implements IChatContentPart { public readonly domNode: HTMLElement; - private readonly _onDidChangeHeight = this._register(new Emitter()); - public readonly onDidChangeHeight = this._onDidChangeHeight.event; - private readonly _confirmWidget: ChatConfirmationWidget; public get codeblocks() { @@ -88,8 +84,6 @@ export class ChatElicitationContentPart extends Disposable implements IChatConte this._confirmWidget = confirmationWidget; confirmationWidget.setShowButtons(elicitation.kind === 'elicitation2' && elicitation.state.get() === ElicitationState.Pending); - this._register(confirmationWidget.onDidChangeHeight(() => this._onDidChangeHeight.fire())); - this._register(confirmationWidget.onDidClick(async e => { if (elicitation.kind !== 'elicitation2') { return; @@ -111,8 +105,6 @@ export class ChatElicitationContentPart extends Disposable implements IChatConte confirmationWidget.setShowButtons(false); confirmationWidget.updateMessage(this.getMessageToRender(elicitation)); - - this._onDidChangeHeight.fire(); })); this.domNode = confirmationWidget.domNode; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatErrorConfirmationPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatErrorConfirmationPart.ts index f9dea3c88e32d..fc89ccc8aa34b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatErrorConfirmationPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatErrorConfirmationPart.ts @@ -5,7 +5,6 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { Button, IButtonOptions } from '../../../../../../base/browser/ui/button/button.js'; -import { Emitter } from '../../../../../../base/common/event.js'; import { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable, IDisposable } from '../../../../../../base/common/lifecycle.js'; import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; @@ -22,9 +21,6 @@ const $ = dom.$; export class ChatErrorConfirmationContentPart extends Disposable implements IChatContentPart { public readonly domNode: HTMLElement; - private readonly _onDidChangeHeight = this._register(new Emitter()); - public readonly onDidChangeHeight = this._onDidChangeHeight.event; - constructor( kind: ChatErrorLevel, content: IMarkdownString, @@ -62,9 +58,7 @@ export class ChatErrorConfirmationContentPart extends Disposable implements ICha const widget = chatWidgetService.getWidgetBySessionResource(element.sessionResource); options.userSelectedModelId = widget?.input.currentLanguageModel; Object.assign(options, widget?.getModeRequestOptions()); - if (await chatService.sendRequest(element.sessionResource, prompt, options)) { - this._onDidChangeHeight.fire(); - } + await chatService.sendRequest(element.sessionResource, prompt, options); })); }); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatExtensionsContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatExtensionsContentPart.ts index 5576bf98c9dfa..8d101f1c2fbfe 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatExtensionsContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatExtensionsContentPart.ts @@ -5,7 +5,7 @@ import './media/chatExtensionsContent.css'; import * as dom from '../../../../../../base/browser/dom.js'; -import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Event } from '../../../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../../../base/common/lifecycle.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ExtensionsList, getExtensions } from '../../../../extensions/browser/extensionsViewer.js'; @@ -22,9 +22,6 @@ import { localize } from '../../../../../../nls.js'; export class ChatExtensionsContentPart extends Disposable implements IChatContentPart { public readonly domNode: HTMLElement; - private _onDidChangeHeight = this._register(new Emitter()); - public readonly onDidChangeHeight = this._onDidChangeHeight.event; - public get codeblocks(): IChatCodeBlockInfo[] { return []; } @@ -53,7 +50,6 @@ export class ChatExtensionsContentPart extends Disposable implements IChatConten } list.setModel(new PagedModel(extensions)); list.layout(); - this._onDidChangeHeight.fire(); }); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts index 6485bd8f9dc6d..d060546f9256d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts @@ -13,7 +13,6 @@ import { DomScrollableElement } from '../../../../../../base/browser/ui/scrollba import { coalesce } from '../../../../../../base/common/arrays.js'; import { findLast } from '../../../../../../base/common/arraysFind.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { Emitter } from '../../../../../../base/common/event.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { autorun, autorunSelfDisposable, derived } from '../../../../../../base/common/observable.js'; @@ -92,9 +91,6 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP private readonly allRefs: IDisposableReference[] = []; - private readonly _onDidChangeHeight = this._register(new Emitter()); - readonly onDidChangeHeight = this._onDidChangeHeight.event; - private readonly _codeblocks: IMarkdownPartCodeBlockInfo[] = []; public get codeblocks(): IChatCodeBlockInfo[] { return this._codeblocks; @@ -200,14 +196,12 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP dispose: () => diffPart.dispose() }; this.allRefs.push(ref); - this._register(diffPart.onDidChangeContentHeight(() => this._onDidChangeHeight.fire())); orderedDisposablesList.push(ref); return diffPart.element; } } if (languageId === 'vscode-extensions') { const chatExtensions = this._register(instantiationService.createInstance(ChatExtensionsContentPart, { kind: 'extensions', extensions: text.split(',') })); - this._register(chatExtensions.onDidChangeHeight(() => this._onDidChangeHeight.fire())); return chatExtensions.domNode; } const globalIndex = globalCodeBlockIndexStart++; @@ -249,10 +243,6 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP const ref = this.renderCodeBlock(codeBlockInfo, text, isCodeBlockComplete, currentWidth); this.allRefs.push(ref); - // Attach this after updating text/layout of the editor, so it should only be fired when the size updates later (horizontal scrollbar, wrapping) - // not during a renderElement OR a progressive render (when we will be firing this event anyway at the end of the render) - this._register(ref.object.onDidChangeContentHeight(() => this._onDidChangeHeight.fire())); - const ownerMarkdownPartId = this.codeblocksPartId; const info: IMarkdownPartCodeBlockInfo = new class implements IMarkdownPartCodeBlockInfo { readonly ownerMarkdownPartId = ownerMarkdownPartId; @@ -284,7 +274,6 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP this.codeBlockModelCollection.update(codeBlockInfo.element.sessionResource, codeBlockInfo.element, codeBlockInfo.codeBlockIndex, { text, languageId: codeBlockInfo.languageId, isComplete: isCodeBlockComplete }).then((e) => { // Update the existing object's codemapperUri this._codeblocks[codeBlockInfo.codeBlockPartIndex].codemapperUri = e.codemapperUri; - this._onDidChangeHeight.fire(); }); } this.allRefs.push(ref); @@ -311,7 +300,6 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP return ref.object.element; } }, - asyncRenderCallback: () => this._onDidChangeHeight.fire(), markedOptions: markedOpts, markedExtensions, ...markdownRenderOptions, @@ -373,9 +361,6 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP console.error('Failed to load MarkedKatexSupport extension:', e); }).finally(() => { doRenderMarkdown(); - if (!this._store.isDisposed) { - this._onDidChangeHeight.fire(); - } }); } else { doRenderMarkdown(); @@ -401,13 +386,10 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP this.codeBlockModelCollection.update(data.element.sessionResource, data.element, data.codeBlockIndex, { text, languageId: data.languageId, isComplete }).then((e) => { // Update the existing object's codemapperUri this._codeblocks[data.codeBlockPartIndex].codemapperUri = e.codemapperUri; - this._onDidChangeHeight.fire(); }); } - editorInfo.render(data, currentWidth).then(() => { - this._onDidChangeHeight.fire(); - }); + editorInfo.render(data, currentWidth); return ref; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersInteractionContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersInteractionContentPart.ts index 9ef33d8fa8a86..3e7c9c9166709 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersInteractionContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersInteractionContentPart.ts @@ -6,7 +6,6 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { RunOnceScheduler } from '../../../../../../base/common/async.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { Emitter } from '../../../../../../base/common/event.js'; import { escapeMarkdownSyntaxTokens, createMarkdownCommandLink, MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; import { Disposable, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; @@ -28,8 +27,6 @@ import './media/chatMcpServersInteractionContent.css'; export class ChatMcpServersInteractionContentPart extends Disposable implements IChatContentPart { public readonly domNode: HTMLElement; - private readonly _onDidChangeHeight = this._register(new Emitter()); - public readonly onDidChangeHeight = this._onDidChangeHeight.event; private workingProgressPart: ChatProgressContentPart | undefined; private interactionContainer: HTMLElement | undefined; @@ -104,8 +101,6 @@ export class ChatMcpServersInteractionContentPart extends Disposable implements this.interactionContainer.remove(); this.interactionContainer = undefined; } - - this._onDidChangeHeight.fire(); } private createServerCommandLinks(servers: Array<{ id: string; label: string }>): string { @@ -146,8 +141,6 @@ export class ChatMcpServersInteractionContentPart extends Disposable implements )); this.domNode.appendChild(this.workingProgressPart.domNode); } - - this._onDidChangeHeight.fire(); } private renderInteractionRequired(serversRequiringInteraction: Array<{ id: string; label: string; errorMessage?: string }>): void { @@ -181,7 +174,6 @@ export class ChatMcpServersInteractionContentPart extends Disposable implements : localize('mcp.start.multiple', 'The MCP servers {0} may have new tools and require interaction to start. [Start them now?]({1})', links, '#start'); const str = new MarkdownString(content, { isTrusted: true }); const messageMd = this.interactionMd.value = this._markdownRendererService.render(str, { - asyncRenderCallback: () => this._onDidChangeHeight.fire(), actionHandler: (content) => { if (!content.startsWith('command:')) { this._start(startLink!); @@ -221,7 +213,6 @@ export class ChatMcpServersInteractionContentPart extends Disposable implements for (let i = 0; i < serversToStart.length; i++) { const serverInfo = serversToStart[i]; startLink.textContent = localize('mcp.starting', "Starting {0}...", serverInfo.label); - this._onDidChangeHeight.fire(); const server = this.mcpService.servers.get().find(s => s.definition.id === serverInfo.id); if (server) { @@ -242,8 +233,6 @@ export class ChatMcpServersInteractionContentPart extends Disposable implements startLink.style.pointerEvents = ''; startLink.style.opacity = ''; startLink.textContent = 'Start now?'; - } finally { - this._onDidChangeHeight.fire(); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMultiDiffContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMultiDiffContentPart.ts index b310d8a20f1c1..15ae799672829 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMultiDiffContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMultiDiffContentPart.ts @@ -7,7 +7,7 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { ButtonWithIcon } from '../../../../../../base/browser/ui/button/button.js'; import { IListRenderer, IListVirtualDelegate } from '../../../../../../base/browser/ui/list/list.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { MarshalledId } from '../../../../../../base/common/marshallingIds.js'; import { autorun, constObservable, IObservable, isObservable } from '../../../../../../base/common/observable.js'; @@ -48,9 +48,6 @@ const MAX_ITEMS_SHOWN = 6; export class ChatMultiDiffContentPart extends Disposable implements IChatContentPart { public readonly domNode: HTMLElement; - private readonly _onDidChangeHeight = this._register(new Emitter()); - public readonly onDidChangeHeight = this._onDidChangeHeight.event; - private list!: WorkbenchList; private isCollapsed: boolean = false; private readonly readOnly: boolean; @@ -93,7 +90,6 @@ export class ChatMultiDiffContentPart extends Disposable implements IChatContent const setExpansionState = () => { viewListButton.icon = this.isCollapsed ? Codicon.chevronRight : Codicon.chevronDown; this.domNode.classList.toggle('chat-file-changes-collapsed', this.isCollapsed); - this._onDidChangeHeight.fire(); }; setExpansionState(); @@ -150,7 +146,7 @@ export class ChatMultiDiffContentPart extends Disposable implements IChatContent $mid: MarshalledId.Uri }; - const toolbar = disposables.add(nestedInsta.createInstance( + disposables.add(nestedInsta.createInstance( MenuWorkbenchToolBar, buttonsContainer, MenuId.ChatMultiDiffContext, @@ -165,8 +161,6 @@ export class ChatMultiDiffContentPart extends Disposable implements IChatContent } )); - disposables.add(toolbar.onDidChangeMenuItems(() => this._onDidChangeHeight.fire())); - return disposables; } @@ -231,7 +225,6 @@ export class ChatMultiDiffContentPart extends Disposable implements IChatContent const height = Math.min(items.length, MAX_ITEMS_SHOWN) * ELEMENT_HEIGHT; this.list.layout(height); listContainer.style.height = `${height}px`; - this._onDidChangeHeight.fire(); })); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPullRequestContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPullRequestContentPart.ts index 200c92b97894b..bd557d60550f6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPullRequestContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatPullRequestContentPart.ts @@ -5,7 +5,6 @@ import './media/chatPullRequestContent.css'; import * as dom from '../../../../../../base/browser/dom.js'; -import { Emitter } from '../../../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../../../base/common/lifecycle.js'; import { IChatPullRequestContent } from '../../../common/chatService/chatService.js'; import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; @@ -21,9 +20,6 @@ import { renderAsPlaintext } from '../../../../../../base/browser/markdownRender export class ChatPullRequestContentPart extends Disposable implements IChatContentPart { public readonly domNode: HTMLElement; - private _onDidChangeHeight = this._register(new Emitter()); - public readonly onDidChangeHeight = this._onDidChangeHeight.event; - constructor( private readonly pullRequestContent: IChatPullRequestContent, @IOpenerService private readonly openerService: IOpenerService diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuotaExceededPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuotaExceededPart.ts index 3d51867e4a486..cb964b6db6e73 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuotaExceededPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuotaExceededPart.ts @@ -7,7 +7,6 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { Button } from '../../../../../../base/browser/ui/button/button.js'; import { WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../../../../base/common/actions.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { Emitter } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable, IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; @@ -41,9 +40,6 @@ export class ChatQuotaExceededPart extends Disposable implements IChatContentPar readonly domNode: HTMLElement; - private readonly _onDidChangeHeight = this._register(new Emitter()); - readonly onDidChangeHeight = this._onDidChangeHeight.event; - constructor( element: IChatResponseViewModel, private readonly content: IChatErrorDetailsPart, @@ -101,8 +97,6 @@ export class ChatQuotaExceededPart extends Disposable implements IChatContentPar retryButton.element.classList.add('chat-quota-error-secondary-button'); retryButton.label = localize('clickToContinue', "Click to Retry"); - this._onDidChangeHeight.fire(); - this._register(retryButton.onDidClick(() => { const widget = chatWidgetService.getWidgetBySessionResource(element.sessionResource); if (!widget) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts index a2ffe19a43a24..f23c0333600e3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../../base/browser/dom.js'; -import { $, AnimationFrameScheduler } from '../../../../../../base/browser/dom.js'; +import { $, AnimationFrameScheduler, DisposableResizeObserver } from '../../../../../../base/browser/dom.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { rcut } from '../../../../../../base/common/strings.js'; @@ -159,6 +159,10 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen // Scheduler for coalescing layout operations this.layoutScheduler = this._register(new AnimationFrameScheduler(this.domNode, () => this.performLayout())); + // Use ResizeObserver to trigger layout when wrapper content changes + const resizeObserver = this._register(new DisposableResizeObserver(() => this.layoutScheduler.schedule())); + this._register(resizeObserver.observe(this.wrapper)); + // Render the prompt section at the start if available (must be after wrapper is initialized) this.renderPromptSection(); @@ -221,7 +225,6 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen this.context, this.chatContentMarkdownRenderer )); - this._register(collapsiblePart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); // Wrap in a container for chain of thought line styling this.promptContainer = $('.chat-thinking-tool-wrapper.chat-subagent-section'); @@ -250,7 +253,6 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen this.finalizeTitle(); // Collapse when done this.setExpanded(false); - this._onDidChangeHeight.fire(); } public finalizeTitle(): void { @@ -360,7 +362,6 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen this.context, this.chatContentMarkdownRenderer )); - this._register(collapsiblePart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); // Wrap in a container for chain of thought line styling this.resultContainer = $('.chat-thinking-tool-wrapper.chat-subagent-section'); @@ -373,8 +374,6 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen if (this.wrapper.style.display === 'none') { this.wrapper.style.display = ''; } - - this._onDidChangeHeight.fire(); } /** @@ -425,21 +424,6 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen ); this._register(part); - this._register(part.onDidChangeHeight(() => { - this.layoutScheduler.schedule(); - this._onDidChangeHeight.fire(); - })); - - // Watch for tool completion to update height when label changes - if (toolInvocation.kind === 'toolInvocation') { - this._register(autorun(r => { - const state = toolInvocation.state.read(r); - if (state.type === IChatToolInvocation.StateKind.Completed) { - this._onDidChangeHeight.fire(); - } - })); - } - return part; } @@ -504,8 +488,6 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen this.pendingResultText = undefined; this.doRenderResultText(resultText); } - - this._onDidChangeHeight.fire(); } private performLayout(): void { @@ -522,8 +504,6 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen const scrollHeight = this.wrapper.scrollHeight; this.wrapper.scrollTop = scrollHeight; } - - this._onDidChangeHeight.fire(); } hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], _element: ChatTreeItem): boolean { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTaskContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTaskContentPart.ts index b96749b7cdffa..b112e4bd61678 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTaskContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTaskContentPart.ts @@ -4,19 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../../base/browser/dom.js'; -import { Event } from '../../../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../../../base/common/lifecycle.js'; -import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; -import { IChatProgressRenderableResponseContent } from '../../../common/model/chatModel.js'; +import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; import { IChatTask, IChatTaskSerialized } from '../../../common/chatService/chatService.js'; +import { IChatProgressRenderableResponseContent } from '../../../common/model/chatModel.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; import { ChatProgressContentPart } from './chatProgressContentPart.js'; import { ChatCollapsibleListContentPart, CollapsibleListPool } from './chatReferencesContentPart.js'; export class ChatTaskContentPart extends Disposable implements IChatContentPart { public readonly domNode: HTMLElement; - public readonly onDidChangeHeight: Event; private isSettled: boolean; @@ -34,7 +32,6 @@ export class ChatTaskContentPart extends Disposable implements IChatContentPart const refsPart = this._register(instantiationService.createInstance(ChatCollapsibleListContentPart, task.progress, task.content.value, context, contentReferencesListPool, undefined)); this.domNode = dom.$('.chat-progress-task'); this.domNode.appendChild(refsPart.domNode); - this.onDidChangeHeight = refsPart.onDidChangeHeight; } else { const isSettled = task.kind === 'progressTask' ? task.isSettled() : @@ -43,7 +40,6 @@ export class ChatTaskContentPart extends Disposable implements IChatContentPart const showSpinner = !isSettled && !context.element.isComplete; const progressPart = this._register(instantiationService.createInstance(ChatProgressContentPart, task, chatContentMarkdownRenderer, context, showSpinner, true, undefined, undefined)); this.domNode = progressPart.domNode; - this.onDidChangeHeight = Event.None; } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTextEditContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTextEditContentPart.ts index 959ef8b234731..4305cc54d7219 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTextEditContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTextEditContentPart.ts @@ -5,7 +5,7 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; -import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Event } from '../../../../../../base/common/event.js'; import { Disposable, IDisposable, IReference, RefCountedDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { isEqual } from '../../../../../../base/common/resources.js'; @@ -43,9 +43,6 @@ export class ChatTextEditContentPart extends Disposable implements IChatContentP public readonly domNode: HTMLElement; private readonly comparePart: IDisposableReference | undefined; - private readonly _onDidChangeHeight = this._register(new Emitter()); - public readonly onDidChangeHeight = this._onDidChangeHeight.event; - constructor( chatTextEdit: IChatTextEditGroup, context: IChatContentPartRenderContext, @@ -86,12 +83,6 @@ export class ChatTextEditContentPart extends Disposable implements IChatContentP this.comparePart = this._register(diffEditorPool.get()); - // Attach this after updating text/layout of the editor, so it should only be fired when the size updates later (horizontal scrollbar, wrapping) - // not during a renderElement OR a progressive render (when we will be firing this event anyway at the end of the render) - this._register(this.comparePart.object.onDidChangeContentHeight(() => { - this._onDidChangeHeight.fire(); - })); - const data: ICodeCompareBlockData = { element, edit: chatTextEdit, 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 66a6558bf955c..6ed7bee0fb879 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -5,6 +5,8 @@ import { $, clearNode, hide } from '../../../../../../base/browser/dom.js'; import { alert } from '../../../../../../base/browser/ui/aria/aria.js'; +import { DomScrollableElement } from '../../../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; import { IChatMarkdownContent, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized } from '../../../common/chatService/chatService.js'; import { IChatContentPartRenderContext, IChatContentPart } from './chatContentParts.js'; import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; @@ -22,6 +24,7 @@ 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 { Emitter } from '../../../../../../base/common/event.js'; import { IDisposable } from '../../../../../../base/common/lifecycle.js'; import { autorun } from '../../../../../../base/common/observable.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; @@ -64,7 +67,7 @@ export function getToolInvocationIcon(toolId: string): ThemeIcon { lowerToolId.includes('edit') || lowerToolId.includes('create') ) { - return Codicon.wand; + return Codicon.pencil; } if ( @@ -94,11 +97,15 @@ interface ILazyItem { toolInvocationOrMarkdown?: IChatToolInvocation | IChatToolInvocationSerialized | IChatMarkdownContent; originalParent?: HTMLElement; } +const THINKING_SCROLL_MAX_HEIGHT = 200; export class ChatThinkingContentPart extends ChatCollapsibleContentPart implements IChatContentPart { public readonly codeblocks: undefined; public readonly codeblocksPartId: undefined; + private readonly _onDidChangeHeight = this._register(new Emitter()); + public readonly onDidChangeHeight = this._onDidChangeHeight.event; + private id: string | undefined; private content: IChatThinkingPart; private currentThinkingValue: string; @@ -108,6 +115,8 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen private markdownResult: IRenderedMarkdown | undefined; private wrapper!: HTMLElement; private fixedScrollingMode: boolean = false; + private autoScrollEnabled: boolean = true; + private scrollableElement: DomScrollableElement | undefined; private lastExtractedTitle: string | undefined; private extractedTitles: string[] = []; private toolInvocationCount: number = 0; @@ -183,15 +192,16 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen } })); - // Materialize lazy items when first expanded this._register(autorun(r => { + // Materialize lazy items when first expanded 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(); } + // Fire when expanded/collapsed + this._onDidChangeHeight.fire(); })); if (this._collapseButton && !this.streamingCompleted && !this.element.isComplete) { @@ -234,10 +244,95 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.wrapper.appendChild(this.textContainer); this.renderMarkdown(this.currentThinkingValue); } + + // wrap content in scrollable element for fixed scrolling mode + if (this.fixedScrollingMode) { + this.scrollableElement = this._register(new DomScrollableElement(this.wrapper, { + vertical: ScrollbarVisibility.Auto, + horizontal: ScrollbarVisibility.Hidden, + handleMouseWheel: true, + alwaysConsumeMouseWheel: false + })); + this._register(this.scrollableElement.onScroll(e => this.handleScroll(e.scrollTop))); + + this._register(this._onDidChangeHeight.event(() => { + setTimeout(() => this.scrollToBottomIfEnabled(), 0); + })); + + setTimeout(() => this.scrollToBottomIfEnabled(), 0); + + this.updateDropdownClickability(); + return this.scrollableElement.getDomNode(); + } + this.updateDropdownClickability(); return this.wrapper; } + private handleScroll(scrollTop: number): void { + if (!this.scrollableElement) { + return; + } + + const scrollDimensions = this.scrollableElement.getScrollDimensions(); + const maxScrollTop = scrollDimensions.scrollHeight - scrollDimensions.height; + const isAtBottom = maxScrollTop <= 0 || scrollTop >= maxScrollTop - 10; + + if (isAtBottom) { + this.autoScrollEnabled = true; + } else { + this.autoScrollEnabled = false; + } + } + + private scrollToBottomIfEnabled(): void { + if (!this.scrollableElement || !this.autoScrollEnabled) { + return; + } + + const isCollapsed = this.domNode.classList.contains('chat-used-context-collapsed'); + if (!isCollapsed) { + return; + } + + const contentHeight = this.wrapper.scrollHeight; + const viewportHeight = Math.min(contentHeight, THINKING_SCROLL_MAX_HEIGHT); + + this.scrollableElement.setScrollDimensions({ + width: this.scrollableElement.getDomNode().clientWidth, + scrollWidth: this.wrapper.scrollWidth, + height: viewportHeight, + scrollHeight: contentHeight + }); + + if (contentHeight > viewportHeight) { + this.scrollableElement.setScrollPosition({ scrollTop: contentHeight - viewportHeight }); + } + } + + /** + * updates scroll dimensions when streaming is complete. + */ + private updateScrollDimensionsForCompletion(): void { + if (!this.scrollableElement || !this.fixedScrollingMode) { + return; + } + + const contentHeight = this.wrapper.scrollHeight; + const viewportHeight = Math.min(contentHeight, THINKING_SCROLL_MAX_HEIGHT); + + this.scrollableElement.setScrollDimensions({ + width: this.scrollableElement.getDomNode().clientWidth, + scrollWidth: this.wrapper.scrollWidth, + height: viewportHeight, + scrollHeight: contentHeight + }); + + if (contentHeight <= THINKING_SCROLL_MAX_HEIGHT) { + this.scrollableElement.setScrollPosition({ scrollTop: 0 }); + } + } + private renderMarkdown(content: string, reuseExisting?: boolean): void { // Guard against rendering after disposal to avoid leaking disposables if (this._store.isDisposed) { @@ -339,8 +434,8 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.currentThinkingValue = next; this.renderMarkdown(next, reuseExisting); - if (this.fixedScrollingMode && this.wrapper) { - this.wrapper.scrollTop = this.wrapper.scrollHeight; + if (this.fixedScrollingMode && this.scrollableElement) { + setTimeout(() => this.scrollToBottomIfEnabled(), 0); } const extractedTitle = extractTitleFromThinkingContent(raw); @@ -379,6 +474,10 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this._collapseButton.icon = Codicon.check; } + // Update scroll dimensions now that streaming is complete + // This removes unnecessary scrollbar when content fits + this.updateScrollDimensionsForCompletion(); + this.updateDropdownClickability(); if (this.content.generatedTitle) { @@ -443,12 +542,41 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen context = this.currentThinkingValue.substring(0, 1000); } - const prompt = `Summarize the following actions in 6-7 words using past tense. Be very concise - focus on the main action only. No subjects, quotes, or punctuation. - - Examples: - - "Preparing to create new page file, Read HomePage.tsx, Creating new TypeScript file" → "Created new page file" - - "Searching for files, Reading configuration, Analyzing dependencies" → "Analyzed project structure" - - "Invoked terminal command, Checked build output, Fixed errors" → "Ran build and fixed errors" + const prompt = `Summarize the following actions concisely (6-10 words) using past tense. Follow these rules strictly: + + GENERAL: + - The actions may include tool calls (file edits, reads, searches, terminal commands) AND non-tool reasoning/analysis + - Summarize ALL actions, not just tool calls. If there's reasoning or analysis without tool calls, summarize that too + - Examples of non-tool actions: "Analyzing code structure", "Planning implementation", "Reviewing dependencies" + + RULES FOR TOOL CALLS: + 1. If the SAME file was both edited AND read: Start with "Read and edited " + 2. If exactly ONE file was edited: Start with "Edited " (include actual filename) + 3. If exactly ONE file was read: Start with "Read " (include actual filename) + 4. If MULTIPLE files were edited: Start with "Edited X files" + 5. If MULTIPLE files were read: Start with "Read X files" + 6. If BOTH edits AND reads occurred on DIFFERENT files: Start with "Edited and read " if one each, otherwise "Edited X files and read Y files" + 7. For searches: Say "searched for " with the actual search term, NOT "searched for files" + 8. After the file info, you may add a brief summary of other actions (e.g., ran terminal, searched for X) if space permits + 9. NEVER say "1 file" - always use the actual filename when there's only one file + + EXAMPLES: + - "Read HomePage.tsx, Edited HomePage.tsx" → "Read and edited HomePage.tsx" + - "Edited HomePage.tsx" → "Edited HomePage.tsx" + - "Read config.json, Read package.json" → "Read 2 files" + - "Edited App.tsx, Read utils.ts" → "Edited App.tsx and read utils.ts" + - "Edited App.tsx, Read utils.ts, Read types.ts" → "Edited App.tsx and read 2 files" + - "Edited index.ts, Edited styles.css, Ran terminal command" → "Edited 2 files and ran command" + - "Read README.md, Searched for AuthService" → "Read README.md and searched for AuthService" + - "Searched for login, Searched for authentication" → "Searched for login and authentication" + - "Edited api.ts, Edited models.ts, Read schema.json" → "Edited 2 files and read schema.json" + - "Edited Button.tsx, Edited Button.css, Edited index.ts" → "Edited 3 files" + - "Searched codebase for error handling" → "Searched for error handling" + - "Grep search for useState, Read App.tsx" → "Read App.tsx and searched for useState" + - "Analyzing component architecture" → "Analyzed component architecture" + - "Planning refactor strategy, Read utils.ts" → "Planned refactor and read utils.ts" + + No quotes, no trailing punctuation. Never say "searched for files" - always include the actual search term. Actions: ${context}`; @@ -634,7 +762,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen let icon: ThemeIcon; if (isMarkdownEdit) { - icon = Codicon.wand; + icon = Codicon.pencil; } else if (isTerminalTool) { const terminalData = (toolInvocationOrMarkdown as IChatToolInvocation | IChatToolInvocationSerialized).toolSpecificData as { kind: 'terminal'; terminalCommandState?: { exitCode?: number } }; const exitCode = terminalData?.terminalCommandState?.exitCode; @@ -649,8 +777,8 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.wrapper.appendChild(itemWrapper); - if (this.fixedScrollingMode && this.wrapper) { - this.wrapper.scrollTop = this.wrapper.scrollHeight; + if (this.fixedScrollingMode && this.scrollableElement) { + setTimeout(() => this.scrollToBottomIfEnabled(), 0); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTodoListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTodoListWidget.ts index 57585c981b5dd..c58fdfc1206e0 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTodoListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTodoListWidget.ts @@ -8,16 +8,15 @@ import { Button } from '../../../../../../base/browser/ui/button/button.js'; import { IconLabel } from '../../../../../../base/browser/ui/iconLabel/iconLabel.js'; import { IListRenderer, IListVirtualDelegate } from '../../../../../../base/browser/ui/list/list.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { isEqual } from '../../../../../../base/common/resources.js'; +import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { WorkbenchList } from '../../../../../../platform/list/browser/listService.js'; -import { IChatTodoListService, IChatTodo } from '../../../common/tools/chatTodoListService.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; -import { URI } from '../../../../../../base/common/uri.js'; -import { isEqual } from '../../../../../../base/common/resources.js'; +import { IChatTodo, IChatTodoListService } from '../../../common/tools/chatTodoListService.js'; class TodoListDelegate implements IListVirtualDelegate { getHeight(element: IChatTodo): number { @@ -113,9 +112,6 @@ class TodoListRenderer implements IListRenderer { export class ChatTodoListWidget extends Disposable { public readonly domNode: HTMLElement; - private readonly _onDidChangeHeight = this._register(new Emitter()); - public readonly onDidChangeHeight: Event = this._onDidChangeHeight.event; - private _isExpanded: boolean = false; private _userManuallyExpanded: boolean = false; private expandoButton!: Button; @@ -150,7 +146,6 @@ export class ChatTodoListWidget extends Disposable { private hideWidget(): void { this.domNode.style.display = 'none'; - this._onDidChangeHeight.fire(); } private createChatTodoWidget(): HTMLElement { @@ -257,7 +252,6 @@ export class ChatTodoListWidget extends Disposable { this.domNode.classList.add('has-todos'); this.renderTodoList(todoList); this.domNode.style.display = 'block'; - this._onDidChangeHeight.fire(); } private renderTodoList(todoList: IChatTodo[]): void { @@ -313,7 +307,6 @@ export class ChatTodoListWidget extends Disposable { this.expandIcon.classList.add('codicon-chevron-right'); this.updateTitleElement(this.titleElement, todoList); - this._onDidChangeHeight.fire(); } } @@ -330,8 +323,6 @@ export class ChatTodoListWidget extends Disposable { const todoList = this.chatTodoListService.getTodos(this._currentSessionResource); this.updateTitleElement(this.titleElement, todoList); } - - this._onDidChangeHeight.fire(); } private clearAllTodos(): void { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolInputOutputContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolInputOutputContentPart.ts index 9f244f116f0ae..9426697b2a022 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolInputOutputContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolInputOutputContentPart.ts @@ -7,7 +7,6 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { ButtonWithIcon } from '../../../../../../base/browser/ui/button/button.js'; import { HoverStyle } from '../../../../../../base/browser/ui/hover/hover.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { Emitter } from '../../../../../../base/common/event.js'; import { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { autorun, ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; @@ -52,9 +51,6 @@ export interface IChatCollapsibleOutputData { } export class ChatCollapsibleInputOutputContentPart extends Disposable { - private readonly _onDidChangeHeight = this._register(new Emitter()); - public readonly onDidChangeHeight = this._onDidChangeHeight.event; - private _currentWidth: number = 0; private readonly _editorReferences: IDisposableReference[] = []; private readonly _titlePart: ChatQueryTitlePart; @@ -106,14 +102,12 @@ export class ChatCollapsibleInputOutputContentPart extends Disposable { this.domNode = container.root; container.root.appendChild(elements.root); - const titlePart = this._titlePart = this._register(_instantiationService.createInstance( + this._titlePart = this._register(_instantiationService.createInstance( ChatQueryTitlePart, titleEl.root, title, subtitle, )); - this._register(titlePart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); - const spacer = document.createElement('span'); spacer.style.flexGrow = '1'; @@ -144,7 +138,6 @@ export class ChatCollapsibleInputOutputContentPart extends Disposable { ? Codicon.check : ThemeIcon.modify(Codicon.loading, 'spin'); elements.root.classList.toggle('collapsed', !value); - this._onDidChangeHeight.fire(); })); const toggle = (e: Event) => { @@ -203,7 +196,6 @@ export class ChatCollapsibleInputOutputContentPart extends Disposable { output.parts, )); this._outputSubPart = outputSubPart; - this._register(outputSubPart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); contents.output.appendChild(outputSubPart.domNode); } @@ -223,7 +215,6 @@ export class ChatCollapsibleInputOutputContentPart extends Disposable { }; const editorReference = this._register(this.context.editorPool.get()); editorReference.object.render(data, this._currentWidth || 300); - this._register(editorReference.object.onDidChangeContentHeight(() => this._onDidChangeHeight.fire())); container.appendChild(editorReference.object.element); this._editorReferences.push(editorReference); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolOutputContentSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolOutputContentSubPart.ts index a468794b3a0c1..700882b896c64 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolOutputContentSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolOutputContentSubPart.ts @@ -5,7 +5,6 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { Emitter } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { basename, joinPath } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -40,9 +39,6 @@ import { ChatCollapsibleIOPart, IChatCollapsibleIOCodePart, IChatCollapsibleIODa * This is used by both ChatCollapsibleInputOutputContentPart and ChatToolPostExecuteConfirmationPart. */ export class ChatToolOutputContentSubPart extends Disposable { - private readonly _onDidChangeHeight = this._register(new Emitter()); - public readonly onDidChangeHeight = this._onDidChangeHeight.event; - private _currentWidth: number = 0; private readonly _editorReferences: IDisposableReference[] = []; public readonly domNode: HTMLElement; @@ -75,7 +71,12 @@ export class ChatToolOutputContentSubPart extends Disposable { for (let i = 0; i < this.parts.length; i++) { const part = this.parts[i]; if (part.kind === 'code') { - this.addCodeBlock(part, container); + // Collect adjacent code parts and combine their contents + const codeParts = [part]; + while (i + 1 < this.parts.length && this.parts[i + 1].kind === 'code') { + codeParts.push(this.parts[++i] as IChatCollapsibleIOCodePart); + } + this.addCodeBlock(codeParts, container); continue; } @@ -101,7 +102,7 @@ export class ChatToolOutputContentSubPart extends Disposable { dom.h('.chat-collapsible-io-resource-actions@actions'), ]); - this.fillInResourceGroup(parts, el.items, el.actions).then(() => this._onDidChangeHeight.fire()); + this.fillInResourceGroup(parts, el.items, el.actions); container.appendChild(el.root); return el.root; @@ -117,6 +118,10 @@ export class ChatToolOutputContentSubPart extends Disposable { } })); + if (this._store.isDisposed) { + return; + } + const attachments = this._register(this._instantiationService.createInstance( ChatAttachmentsContentPart, { @@ -153,30 +158,34 @@ export class ChatToolOutputContentSubPart extends Disposable { toolbar.context = { parts } satisfies IChatToolOutputResourceToolbarContext; } - private addCodeBlock(part: IChatCollapsibleIOCodePart, container: HTMLElement) { - if (part.title) { + private addCodeBlock(parts: IChatCollapsibleIOCodePart[], container: HTMLElement): void { + const firstPart = parts[0]; + if (firstPart.title) { const title = dom.$('div.chat-confirmation-widget-title'); - const renderedTitle = this._register(this._markdownRendererService.render(this.toMdString(part.title))); + const renderedTitle = this._register(this._markdownRendererService.render(this.toMdString(firstPart.title))); title.appendChild(renderedTitle.element); container.appendChild(title); } + // Combine text from all adjacent code parts + const combinedText = parts.map(p => p.textModel.getValue()).join('\n'); + firstPart.textModel.setValue(combinedText); + const data: ICodeBlockData = { - languageId: part.languageId, - textModel: Promise.resolve(part.textModel), - codeBlockIndex: part.codeBlockInfo.codeBlockIndex, + languageId: firstPart.languageId, + textModel: Promise.resolve(firstPart.textModel), + codeBlockIndex: firstPart.codeBlockInfo.codeBlockIndex, codeBlockPartIndex: 0, element: this.context.element, parentContextKeyService: this.contextKeyService, - renderOptions: part.options, + renderOptions: firstPart.options, chatSessionResource: this.context.element.sessionResource, }; const editorReference = this._register(this.context.editorPool.get()); editorReference.object.render(data, this._currentWidth || 300); - this._register(editorReference.object.onDidChangeContentHeight(() => this._onDidChangeHeight.fire())); container.appendChild(editorReference.object.element); this._editorReferences.push(editorReference); - this.codeblocks.push(part.codeBlockInfo); + this.codeblocks.push(firstPart.codeBlockInfo); } layout(width: number): void { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTreeContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTreeContentPart.ts index 598fe1f08679b..2efa11c044c64 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTreeContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTreeContentPart.ts @@ -9,7 +9,7 @@ import { ITreeCompressionDelegate } from '../../../../../../base/browser/ui/tree import { ICompressedTreeNode } from '../../../../../../base/browser/ui/tree/compressedObjectTreeModel.js'; import { ICompressibleTreeRenderer } from '../../../../../../base/browser/ui/tree/objectTree.js'; import { IAsyncDataSource, ITreeNode } from '../../../../../../base/browser/ui/tree/tree.js'; -import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../../../base/common/lifecycle.js'; import { localize } from '../../../../../../nls.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -31,9 +31,6 @@ const $ = dom.$; export class ChatTreeContentPart extends Disposable implements IChatContentPart { public readonly domNode: HTMLElement; - private readonly _onDidChangeHeight = this._register(new Emitter()); - public readonly onDidChangeHeight = this._onDidChangeHeight.event; - public readonly onDidFocus: Event; private tree: WorkbenchCompressibleAsyncDataTree; @@ -54,9 +51,6 @@ export class ChatTreeContentPart extends Disposable implements IChatContentPart this.openerService.open(e.element.uri); } })); - this._register(this.tree.onDidChangeCollapseState(() => { - this._onDidChangeHeight.fire(); - })); this._register(this.tree.onContextMenu((e) => { e.browserEvent.preventDefault(); e.browserEvent.stopPropagation(); @@ -65,7 +59,6 @@ export class ChatTreeContentPart extends Disposable implements IChatContentPart this.tree.setInput(data).then(() => { if (!ref.isStale()) { this.tree.layout(); - this._onDidChangeHeight.fire(); } }); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts index 35b7d57c59e78..45b20d570ee02 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/codeBlockPart.ts @@ -56,7 +56,7 @@ import { ServiceCollection } from '../../../../../../platform/instantiation/comm import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; import { ResourceLabel } from '../../../../../browser/labels.js'; -import { ResourceContextKey } from '../../../../../common/contextkeys.js'; +import { StaticResourceContextKey } from '../../../../../common/contextkeys.js'; import { AccessibilityVerbositySettingId } from '../../../../accessibility/browser/accessibilityConfiguration.js'; import { InspectEditorTokensController } from '../../../../codeEditor/browser/inspectEditorTokens/inspectEditorTokens.js'; import { MenuPreventer } from '../../../../codeEditor/browser/menuPreventer.js'; @@ -169,7 +169,7 @@ export class CodeBlockPart extends Disposable { private isDisposed = false; - private resourceContextKey: ResourceContextKey; + private resourceContextKey: StaticResourceContextKey; private get verticalPadding(): number { return this.currentCodeBlockData?.renderOptions?.verticalPadding ?? defaultCodeblockPadding; @@ -190,7 +190,7 @@ export class CodeBlockPart extends Disposable { super(); this.element = $('.interactive-result-code-block'); - this.resourceContextKey = this._register(instantiationService.createInstance(ResourceContextKey)); + this.resourceContextKey = instantiationService.createInstance(StaticResourceContextKey); this.contextKeyService = this._register(contextKeyService.createScoped(this.element)); const scopedInstantiationService = this._register(instantiationService.createChild(new ServiceCollection([IContextKeyService, this.contextKeyService]))); const editorElement = dom.append(this.element, $('.interactive-result-editor')); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css index 4d6484b28e590..c24a307a50fbb 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css @@ -191,6 +191,10 @@ .interactive-session .interactive-response .value .chat-thinking-fixed-mode { outline: none; + &.chat-used-context-collapsed > .monaco-scrollable-element:has(.chat-used-context-list.chat-thinking-collapsible:not(.chat-thinking-streaming)) { + display: none; + } + &.chat-used-context-collapsed .chat-used-context-list.chat-thinking-collapsible.chat-thinking-streaming { max-height: 200px; overflow: hidden; @@ -207,6 +211,10 @@ max-height: none; overflow: visible; } + + .chat-thinking-tool-wrapper .chat-used-context:not(.chat-used-context-collapsed) .chat-used-context-list { + display: block; + } } .editor-instance .interactive-session .interactive-response .value .chat-thinking-box .chat-thinking-item ::before { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts index 2d6e9558ff947..19312386303ef 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts @@ -105,7 +105,6 @@ export abstract class AbstractToolConfirmationSubPart extends BaseChatToolInvoca this.chatWidgetService.getWidgetBySessionResource(this.context.element.sessionResource)?.focusInput(); })); - this._register(confirmWidget.onDidChangeHeight(() => this._onDidChangeHeight.fire())); this._register(toDisposable(() => hasToolConfirmation.reset())); this.domNode = confirmWidget.domNode; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatExtensionsInstallToolSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatExtensionsInstallToolSubPart.ts index ce673417560e2..efbe69369f715 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatExtensionsInstallToolSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatExtensionsInstallToolSubPart.ts @@ -52,7 +52,6 @@ export class ExtensionsInstallConfirmationWidgetSubPart extends BaseChatToolInvo const extensionsContent = toolInvocation.toolSpecificData; this.domNode = dom.$(''); const chatExtensionsContentPart = this._register(instantiationService.createInstance(ChatExtensionsContentPart, extensionsContent)); - this._register(chatExtensionsContentPart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); dom.append(this.domNode, chatExtensionsContentPart.domNode); const state = toolInvocation.state.get(); @@ -90,7 +89,6 @@ export class ExtensionsInstallConfirmationWidgetSubPart extends BaseChatToolInvo } )); this._confirmWidget = confirmWidget; - this._register(confirmWidget.onDidChangeHeight(() => this._onDidChangeHeight.fire())); dom.append(this.domNode, confirmWidget.domNode); this._register(confirmWidget.onDidClick(button => { IChatToolInvocation.confirmWith(toolInvocation, button.data); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts index 0d7a1188a3a98..2bb403aedd5f3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts @@ -129,7 +129,6 @@ export class ChatInputOutputMarkdownProgressPart extends BaseChatToolInvocationS ChatInputOutputMarkdownProgressPart._expandedByDefault.get(toolInvocation) ?? false, )); this._codeblocks.push(...collapsibleListPart.codeblocks); - this._register(collapsibleListPart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); this._register(toDisposable(() => ChatInputOutputMarkdownProgressPart._expandedByDefault.set(toolInvocation, collapsibleListPart.expanded))); const progressObservable = toolInvocation.kind === 'toolInvocation' ? toolInvocation.state.map((s, r) => s.type === IChatToolInvocation.StateKind.Executing ? s.progress.read(r) : undefined) : undefined; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppSubPart.ts index a1414f7cf7957..98a2cef2f0787 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppSubPart.ts @@ -108,7 +108,6 @@ export class ChatMcpAppSubPart extends BaseChatToolInvocationSubPart { // Subscribe to model height changes this._register(this._model.onDidChangeHeight(() => { this._updateContainerHeight(); - this._onDidChangeHeight.fire(); })); this._register(context.onDidChangeVisibility(visible => { @@ -150,7 +149,6 @@ export class ChatMcpAppSubPart extends BaseChatToolInvocationSubPart { case 'loaded': { // Show the webview container container.style.display = ''; - this._onDidChangeHeight.fire(); break; } case 'error': { @@ -190,6 +188,5 @@ export class ChatMcpAppSubPart extends BaseChatToolInvocationSubPart { container.appendChild(errorNode); this._errorNode = errorNode; - this._onDidChangeHeight.fire(); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatResultListSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatResultListSubPart.ts index 4b8b00155ef7f..af3c5c31ef78a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatResultListSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatResultListSubPart.ts @@ -41,7 +41,6 @@ export class ChatResultListSubPart extends BaseChatToolInvocationSubPart { getToolApprovalMessage(toolInvocation), )); collapsibleListPart.icon = Codicon.check; - this._register(collapsibleListPart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); this.domNode = collapsibleListPart.domNode; } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts index 45ef359a7ee2d..d7826880faa27 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts @@ -11,7 +11,7 @@ import { asArray } from '../../../../../../../base/common/arrays.js'; import { Codicon } from '../../../../../../../base/common/codicons.js'; import { ErrorNoTelemetry } from '../../../../../../../base/common/errors.js'; import { createCommandUri, MarkdownString, type IMarkdownString } from '../../../../../../../base/common/htmlContent.js'; -import { thenIfNotDisposed, thenRegisterOrDispose, toDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { thenRegisterOrDispose, toDisposable } from '../../../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../../../base/common/network.js'; import Severity from '../../../../../../../base/common/severity.js'; import { isObject } from '../../../../../../../base/common/types.js'; @@ -32,17 +32,17 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../../../ import { IPreferencesService } from '../../../../../../services/preferences/common/preferences.js'; import { ITerminalChatService } from '../../../../../terminal/browser/terminal.js'; import { TerminalContribCommandId, TerminalContribSettingId } from '../../../../../terminal/terminalContribExports.js'; -import { migrateLegacyTerminalToolSpecificData } from '../../../../common/chat.js'; import { ChatContextKeys } from '../../../../common/actions/chatContextKeys.js'; +import { migrateLegacyTerminalToolSpecificData } from '../../../../common/chat.js'; import { IChatToolInvocation, ToolConfirmKind, type IChatTerminalToolInvocationData, type ILegacyChatTerminalToolInvocationData } from '../../../../common/chatService/chatService.js'; import type { CodeBlockModelCollection } from '../../../../common/widget/codeBlockModelCollection.js'; import { AcceptToolConfirmationActionId, SkipToolConfirmationActionId } from '../../../actions/chatToolActions.js'; import { IChatCodeBlockInfo, IChatWidgetService } from '../../../chat.js'; -import { ICodeBlockRenderOptions } from '../codeBlockPart.js'; import { ChatCustomConfirmationWidget, IChatConfirmationButton } from '../chatConfirmationWidget.js'; import { EditorPool } from '../chatContentCodePools.js'; import { IChatContentPartRenderContext } from '../chatContentParts.js'; import { ChatMarkdownContentPart } from '../chatMarkdownContentPart.js'; +import { ICodeBlockRenderOptions } from '../codeBlockPart.js'; import { BaseChatToolInvocationSubPart } from './chatToolInvocationSubPart.js'; export const enum TerminalToolConfirmationStorageKeys { @@ -161,7 +161,7 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS )); thenRegisterOrDispose(textModelService.createModelReference(model.uri), this._store); const editor = this._register(this.editorPool.get()); - const renderPromise = editor.object.render({ + editor.object.render({ codeBlockIndex: this.codeBlockStartIndex, codeBlockPartIndex: 0, element: this.context.element, @@ -170,7 +170,6 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS textModel: Promise.resolve(model), chatSessionResource: this.context.element.sessionResource }, this.currentWidthDelegate()); - this._register(thenIfNotDisposed(renderPromise, () => this._onDidChangeHeight.fire())); this.codeblocks.push({ codeBlockIndex: this.codeBlockStartIndex, codemapperUri: undefined, @@ -183,7 +182,6 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS }); this._register(editor.object.onDidChangeContentHeight(() => { editor.object.layout(this.currentWidthDelegate()); - this._onDidChangeHeight.fire(); })); this._register(model.onDidChangeContent(e => { const currentValue = model.getValue(); @@ -388,7 +386,6 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS this.chatWidgetService.getWidgetBySessionResource(this.context.element.sessionResource)?.focusInput(); } })); - this._register(confirmWidget.onDidChangeHeight(() => this._onDidChangeHeight.fire())); this.domNode = confirmWidget.domNode; } @@ -457,6 +454,5 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS { codeBlockRenderOptions }, )); append(container, part.domNode); - this._register(part.onDidChangeHeight(() => this._onDidChangeHeight.fire())); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index 4117c381f8daf..44958482fe4e4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -298,12 +298,11 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart )); this._register(titlePart.onDidChangeHeight(() => { this._decoration.update(); - this._onDidChangeHeight.fire(); })); this._outputView = this._register(this._instantiationService.createInstance( ChatTerminalToolOutputSection, - () => this._onDidChangeHeight.fire(), + () => { }, () => this._ensureTerminalInstance(), () => this._getResolvedCommand(), () => this._terminalData.terminalCommandOutput, @@ -355,7 +354,6 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart }; this.markdownPart = this._register(_instantiationService.createInstance(ChatMarkdownContentPart, chatMarkdownContent, context, editorPool, false, codeBlockStartIndex, renderer, {}, currentWidthDelegate(), codeBlockModelCollection, markdownOptions)); - this._register(this.markdownPart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); elements.message.append(this.markdownPart.domNode); const progressPart = this._register(_instantiationService.createInstance(ChatProgressSubPart, elements.container, this.getIcon(), terminalData.autoApproveInfo)); @@ -398,8 +396,6 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart )); this._thinkingCollapsibleWrapper = wrapper; - this._register(wrapper.onDidChangeHeight(() => this._onDidChangeHeight.fire())); - return wrapper.domNode; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts index 150d5bd1beb7c..6d61965723994 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts @@ -249,7 +249,6 @@ export class ToolConfirmationSubPart extends AbstractToolConfirmationSubPart { }); this._register(editor.object.onDidChangeContentHeight(() => { editor.object.layout(this.currentWidthDelegate()); - this._onDidChangeHeight.fire(); })); this._register(model.onDidChangeContent(e => { try { @@ -286,13 +285,11 @@ export class ToolConfirmationSubPart extends AbstractToolConfirmationSubPart { const show = messageSeeMoreObserver.getHeight() > SHOW_MORE_MESSAGE_HEIGHT_TRIGGER; if (elements.messageContainer.classList.contains('can-see-more') !== show) { elements.messageContainer.classList.toggle('can-see-more', show); - this._onDidChangeHeight.fire(); } }; this._register(dom.addDisposableListener(elements.showMore, 'click', () => { elements.messageContainer.classList.toggle('can-see-more', false); - this._onDidChangeHeight.fire(); messageSeeMoreObserver.dispose(); })); @@ -341,8 +338,6 @@ export class ToolConfirmationSubPart extends AbstractToolConfirmationSubPart { renderFileWidgets(part.domNode, this.instantiationService, this.chatMarkdownAnchorService, this._store); container.append(part.domNode); - this._register(part.onDidChangeHeight(() => this._onDidChangeHeight.fire())); - return part; } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts index 5e079e248348a..1fa9051e6d440 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts @@ -33,9 +33,6 @@ import { ChatToolStreamingSubPart } from './chatToolStreamingSubPart.js'; export class ChatToolInvocationPart extends Disposable implements IChatContentPart { public readonly domNode: HTMLElement; - private _onDidChangeHeight = this._register(new Emitter()); - public readonly onDidChangeHeight = this._onDidChangeHeight.event; - public get codeblocks(): IChatCodeBlockInfo[] { const codeblocks = this.subPart?.codeblocks ?? []; if (this.mcpAppPart) { @@ -100,9 +97,7 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa subPartDomNode.replaceWith(this.subPart.domNode); subPartDomNode = this.subPart.domNode; - partStore.add(this.subPart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); partStore.add(this.subPart.onNeedsRerender(render)); - this._onDidChangeHeight.fire(); }; const mcpAppRenderData = this.getMcpAppRenderData(); @@ -126,13 +121,10 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa )); appDomNode.replaceWith(this.mcpAppPart.domNode); appDomNode = this.mcpAppPart.domNode; - r.store.add(this.mcpAppPart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); } else { this.mcpAppPart = undefined; dom.clearNode(appDomNode); } - - this._onDidChangeHeight.fire(); })); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationSubPart.ts index c6f9cbf585f30..036d5e01d63dc 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationSubPart.ts @@ -17,9 +17,6 @@ export abstract class BaseChatToolInvocationSubPart extends Disposable { protected _onNeedsRerender = this._register(new Emitter()); public readonly onNeedsRerender = this._onNeedsRerender.event; - protected _onDidChangeHeight = this._register(new Emitter()); - public readonly onDidChangeHeight = this._onDidChangeHeight.event; - public abstract codeblocks: IChatCodeBlockInfo[]; private readonly _codeBlocksPartId = 'tool-' + (BaseChatToolInvocationSubPart.idPool++); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolOutputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolOutputPart.ts index a796242c2f3d3..6d903ad98fbc7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolOutputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolOutputPart.ts @@ -117,9 +117,7 @@ export class ChatToolOutputSubPart extends BaseChatToolInvocationSubPart { progressPart.domNode.remove(); - this._onDidChangeHeight.fire(); this._register(renderedItem.onDidChangeHeight(newHeight => { - this._onDidChangeHeight.fire(); partState.height = newHeight; })); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPostExecuteConfirmationPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPostExecuteConfirmationPart.ts index 1c5af92390c46..c05ce8635c3ed 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPostExecuteConfirmationPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPostExecuteConfirmationPart.ts @@ -267,7 +267,6 @@ export class ChatToolPostExecuteConfirmationPart extends AbstractToolConfirmatio )); this._codeblocks.push(...outputSubPart.codeblocks); - this._register(outputSubPart.onDidChangeHeight(() => this._onDidChangeHeight.fire())); outputSubPart.domNode.classList.add('tool-postconfirm-display'); return outputSubPart.domNode; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolStreamingSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolStreamingSubPart.ts index 389a8bacda677..3e3e3cc12d050 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolStreamingSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolStreamingSubPart.ts @@ -90,9 +90,6 @@ export class ChatToolStreamingSubPart extends BaseChatToolInvocationSubPart { )); dom.reset(container, part.domNode); - - // Notify parent that content has changed - this._onDidChangeHeight.fire(); })); return container; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index ea5647b20a415..c5ad85e3af682 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -10,7 +10,7 @@ import { IActionViewItemOptions } from '../../../../../base/browser/ui/actionbar import { alert } from '../../../../../base/browser/ui/aria/aria.js'; import { DropdownMenuActionViewItem, IDropdownMenuActionViewItemOptions } from '../../../../../base/browser/ui/dropdown/dropdownActionViewItem.js'; import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; -import { IListElementRenderDetails, IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js'; +import { CachedListVirtualDelegate, IListElementRenderDetails } from '../../../../../base/browser/ui/list/list.js'; import { ITreeNode, ITreeRenderer } from '../../../../../base/browser/ui/tree/tree.js'; import { IAction } from '../../../../../base/common/actions.js'; import { coalesce, distinct } from '../../../../../base/common/arrays.js'; @@ -533,6 +533,29 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { + if (!template.currentElement) { + return; + } + + const entry = entries[0]; + if (entry) { + const height = entry.borderBoxSize.at(0)?.blockSize; + if (height === 0 || !height || !template.rowContainer.isConnected) { + // Don't fire for changes that happen from the row being removed from the DOM + return; + } + + const normalizedHeight = Math.ceil(height); + template.currentElement.currentRenderedHeight = normalizedHeight; + if (template.currentElement !== this._elementBeingRendered) { + this._onDidChangeItemHeight.fire({ element: template.currentElement, height: normalizedHeight }); + } + } + })); + templateDisposables.add(resizeObserver.observe(rowContainer)); + return template; } @@ -788,8 +811,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { - // Have to recompute the height here because codeblock rendering is currently async and it may have changed. - // If it becomes properly sync, then this could be removed. - if (templateData.rowContainer.isConnected) { - element.currentRenderedHeight = templateData.rowContainer.offsetHeight; - this._onDidChangeItemHeight.fire({ element, height: element.currentRenderedHeight }); - } - disposable.dispose(); - })); - } - } - - private updateItemHeight(templateData: IChatListItemTemplate): void { - if (!templateData.currentElement || templateData.currentElement === this._elementBeingRendered) { - return; - } - - if (templateData.rowContainer.isConnected) { - const newHeight = templateData.rowContainer.offsetHeight; - templateData.currentElement.currentRenderedHeight = newHeight; - this._onDidChangeItemHeight.fire({ element: templateData.currentElement, height: newHeight }); - } } /** @@ -1011,13 +1001,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { - this.updateItemHeight(templateData); - })); return subagentPart; } @@ -1558,7 +1532,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this.updateItemHeight(templateData))); return renderedError; } else if (content.errorDetails.isRateLimited && this.chatEntitlementService.anonymous) { const renderedError = this.instantiationService.createInstance(ChatAnonymousRateLimitedPart, content); @@ -1566,7 +1539,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this.updateItemHeight(templateData))); return errorConfirmation; } else { const level = content.errorDetails.level ?? ChatErrorLevel.Error; @@ -1590,10 +1562,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { - this.updateItemHeight(templateData); - })); - if (isResponseVM(context.element)) { const fileTreeFocusInfo = { treeDataId: data.uri.toString(), @@ -1619,17 +1587,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { - this.updateItemHeight(templateData); - })); return multiDiffPart; } private renderContentReferencesListData(references: IChatReferences, labelOverride: string | undefined, context: IChatContentPartRenderContext, templateData: IChatListItemTemplate): ChatCollapsibleListContentPart { const referencesPart = this.instantiationService.createInstance(ChatUsedReferencesListContentPart, references.references, labelOverride, context, this._contentReferencesListPool, { expandedWhenEmptyResponse: checkModeOption(this.delegate.currentChatMode(), this.rendererOptions.referencesExpandedWhenEmptyResponse) }); - referencesPart.addDisposable(referencesPart.onDidChangeHeight(() => { - this.updateItemHeight(templateData); - })); return referencesPart; } @@ -1689,9 +1651,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { 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 @@ -1732,9 +1691,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { - this.updateItemHeight(templateData); - })); } return thinkingPart; @@ -1767,13 +1723,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this.updateItemHeight(templateData))); return part; } private renderPullRequestContent(pullRequestContent: IChatPullRequestContent, context: IChatContentPartRenderContext, templateData: IChatListItemTemplate): IChatContentPart | undefined { const part = this.instantiationService.createInstance(ChatPullRequestContentPart, pullRequestContent); - part.addDisposable(part.onDidChangeHeight(() => this.updateItemHeight(templateData))); return part; } @@ -1783,16 +1737,12 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { - this.updateItemHeight(templateData); - })); return taskPart; } private renderConfirmation(context: IChatContentPartRenderContext, confirmation: IChatConfirmation, templateData: IChatListItemTemplate): IChatContentPart { const part = this.instantiationService.createInstance(ChatConfirmationContentPart, confirmation, context); - part.addDisposable(part.onDidChangeHeight(() => this.updateItemHeight(templateData))); return part; } @@ -1802,13 +1752,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this.updateItemHeight(templateData))); return part; } private renderChangesSummary(content: IChatChangesSummaryPart, context: IChatContentPartRenderContext, templateData: IChatListItemTemplate): IChatContentPart { const part = this.instantiationService.createInstance(ChatCheckpointFileChangesSummaryContentPart, content, context); - part.addDisposable(part.onDidChangeHeight(() => { this.updateItemHeight(templateData); })); return part; } @@ -1822,11 +1770,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { - textEditPart.layout(this._currentLayoutWidth.get()); - this.updateItemHeight(templateData); - })); - return textEditPart; } @@ -1885,11 +1828,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { - markdownPart.layout(this._currentLayoutWidth.get()); - this.updateItemHeight(templateData); - })); - this.handleRenderedCodeblocks(element, markdownPart, codeBlockStartIndex); const collapsedToolsMode = this.configService.getValue('chat.agent.thinking.collapsedTools'); @@ -1913,9 +1851,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { - this.updateItemHeight(templateData); - })); } return thinkingPart; @@ -1961,7 +1896,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this.updateItemHeight(templateData))); lastPart = itemPart; } } @@ -1975,7 +1909,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this.updateItemHeight(templateData))); return part; } @@ -2021,25 +1954,16 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { +export class ChatListDelegate extends CachedListVirtualDelegate { constructor( private readonly defaultElementHeight: number, - @ILogService private readonly logService: ILogService - ) { } - - private _traceLayout(method: string, message: string) { - if (forceVerboseLayoutTracing) { - this.logService.info(`ChatListDelegate#${method}: ${message}`); - } else { - this.logService.trace(`ChatListDelegate#${method}: ${message}`); - } + ) { + super(); } - getHeight(element: ChatTreeItem): number { - const kind = isRequestVM(element) ? 'request' : 'response'; - const height = element.currentRenderedHeight ?? this.defaultElementHeight; - this._traceLayout('getHeight', `${kind}, height=${height}`); - return height; + protected estimateHeight(element: ChatTreeItem): number { + // currentRenderedHeight is not load-bearing here- probably if it's ever set, then the superclass cache will have the height. + return element.currentRenderedHeight ?? this.defaultElementHeight; } getTemplateId(element: ChatTreeItem): string { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts index 39ea0c348ea70..c26ee8b2f810c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts @@ -4,36 +4,36 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../base/browser/dom.js'; -import { Button } from '../../../../../base/browser/ui/button/button.js'; import { IMouseWheelEvent } from '../../../../../base/browser/mouseEvent.js'; +import { Button } from '../../../../../base/browser/ui/button/button.js'; import { ITreeContextMenuEvent, ITreeElement, ITreeFilter } from '../../../../../base/browser/ui/tree/tree.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { FuzzyScore } from '../../../../../base/common/filters.js'; -import { Disposable, MutableDisposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { ScrollEvent } from '../../../../../base/common/scrollable.js'; import { URI } from '../../../../../base/common/uri.js'; -import { IContextKeyService, IContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { MenuId } from '../../../../../platform/actions/common/actions.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { WorkbenchObjectTree } from '../../../../../platform/list/browser/listService.js'; -import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; -import { MenuId } from '../../../../../platform/actions/common/actions.js'; -import { asCssVariable, buttonSecondaryBackground, buttonSecondaryForeground, buttonSecondaryHoverBackground } from '../../../../../platform/theme/common/colorRegistry.js'; -import { Codicon } from '../../../../../base/common/codicons.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; -import { ChatAccessibilityProvider } from '../accessibility/chatAccessibilityProvider.js'; -import { ChatTreeItem, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions } from '../chat.js'; +import { asCssVariable, buttonSecondaryBackground, buttonSecondaryForeground, buttonSecondaryHoverBackground } from '../../../../../platform/theme/common/colorRegistry.js'; import { katexContainerClassName } from '../../../markdown/common/markedKatexExtension.js'; -import { CodeBlockModelCollection } from '../../common/widget/codeBlockModelCollection.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common/constants.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js'; -import { IChatRequestModeInfo } from '../../common/model/chatModel.js'; import { IChatFollowup, IChatSendRequestOptions, IChatService } from '../../common/chatService/chatService.js'; -import { ChatEditorOptions } from './chatOptions.js'; -import { ChatListDelegate, ChatListItemRenderer, IChatListItemTemplate, IChatRendererDelegate } from './chatListRenderer.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common/constants.js'; +import { IChatRequestModeInfo } from '../../common/model/chatModel.js'; +import { IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js'; +import { CodeBlockModelCollection } from '../../common/widget/codeBlockModelCollection.js'; +import { ChatAccessibilityProvider } from '../accessibility/chatAccessibilityProvider.js'; +import { ChatTreeItem, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions } from '../chat.js'; import { CodeBlockPart } from './chatContentParts/codeBlockPart.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ChatListDelegate, ChatListItemRenderer, IChatListItemTemplate, IChatRendererDelegate } from './chatListRenderer.js'; +import { ChatEditorOptions } from './chatOptions.js'; export interface IChatListWidgetStyles { listForeground?: string; @@ -188,7 +188,6 @@ export class ChatListWidget extends Disposable { private _viewModel: IChatViewModel | undefined; private _visible = true; private _lastItem: ChatTreeItem | undefined; - private _previousScrollHeight: number = 0; private _mostRecentlyFocusedItemIndex: number = -1; private _scrollLock: boolean = true; private _settingChangeCounter: number = 0; @@ -196,7 +195,6 @@ export class ChatListWidget extends Disposable { private readonly _container: HTMLElement; private readonly _scrollDownButton: Button; - private readonly _scrollAnimationFrameDisposable = this._register(new MutableDisposable()); private readonly _lastItemIdContextKey: IContextKey; private readonly _location: ChatAgentLocation | undefined; @@ -323,9 +321,7 @@ export class ChatListWidget extends Disposable { })); this._register(this._renderer.onDidChangeItemHeight(e => { - if (this._tree.hasElement(e.element) && this._visible) { - this._tree.updateElementHeight(e.element, e.height); - } + this._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); @@ -417,7 +413,6 @@ export class ChatListWidget extends Disposable { // Handle content height changes (fires high-level event, internal scroll handling) this._register(this._tree.onDidChangeContentHeight(() => { - this.handleContentHeightChange(); this._onDidChangeContentHeight.fire(); })); @@ -459,25 +454,6 @@ export class ChatListWidget extends Disposable { //#region Internal event handlers - /** - * Handle content height changes - auto-scroll if needed. - */ - private handleContentHeightChange(): void { - if (!this.hasScrollHeightChanged()) { - return; - } - const rendering = this._lastItem && isResponseVM(this._lastItem) && this._lastItem.renderData; - if (!rendering || this.scrollLock) { - if (this.wasLastElementVisible()) { - this._scrollAnimationFrameDisposable.value = dom.scheduleAtNextAnimationFrame(dom.getWindow(this._container), () => { - this.scrollToEnd(); - }, 0); - } - } - - this.updatePreviousScrollHeight(); - } - /** * Update scroll-down button visibility based on scroll position and scroll lock. */ @@ -549,28 +525,30 @@ export class ChatListWidget extends Disposable { const editing = this._viewModel.editing; const checkpoint = this._viewModel.model?.checkpoint; - this._tree.setChildren(null, treeItems, { - diffIdentityProvider: { - getId: (element) => { - return element.dataId + - // If a response is in the process of progressive rendering, we need to ensure that it will - // be re-rendered so progressive rendering is restarted, even if the model wasn't updated. - `${isResponseVM(element) && element.renderData ? `_${this._visibleChangeCount}` : ''}` + - // Re-render once content references are loaded - (isResponseVM(element) ? `_${element.contentReferences.length}` : '') + - // Re-render if element becomes hidden due to undo/redo - `_${element.shouldBeRemovedOnSend ? `${element.shouldBeRemovedOnSend.afterUndoStop || '1'}` : '0'}` + - // Re-render if we have an element currently being edited - `_${editing ? '1' : '0'}` + - // Re-render if we have an element currently being checkpointed - `_${checkpoint ? '1' : '0'}` + - // Re-render all if invoked by setting change - `_setting${this._settingChangeCounter}` + - // Rerender request if we got new content references in the response - // since this may change how we render the corresponding attachments in the request - (isRequestVM(element) && element.contentReferences ? `_${element.contentReferences?.length}` : ''); - }, - } + this._withPersistedAutoScroll(() => { + this._tree.setChildren(null, treeItems, { + diffIdentityProvider: { + getId: (element) => { + return element.dataId + + // If a response is in the process of progressive rendering, we need to ensure that it will + // be re-rendered so progressive rendering is restarted, even if the model wasn't updated. + `${isResponseVM(element) && element.renderData ? `_${this._visibleChangeCount}` : ''}` + + // Re-render once content references are loaded + (isResponseVM(element) ? `_${element.contentReferences.length}` : '') + + // Re-render if element becomes hidden due to undo/redo + `_${element.shouldBeRemovedOnSend ? `${element.shouldBeRemovedOnSend.afterUndoStop || '1'}` : '0'}` + + // Re-render if we have an element currently being edited + `_${editing ? '1' : '0'}` + + // Re-render if we have an element currently being checkpointed + `_${checkpoint ? '1' : '0'}` + + // Re-render all if invoked by setting change + `_setting${this._settingChangeCounter}` + + // Rerender request if we got new content references in the response + // since this may change how we render the corresponding attachments in the request + (isRequestVM(element) && element.contentReferences ? `_${element.contentReferences?.length}` : ''); + }, + } + }); }); } @@ -652,9 +630,11 @@ export class ChatListWidget extends Disposable { /** * Update the height of an element. */ - updateElementHeight(element: ChatTreeItem, height?: number): void { + private _updateElementHeight(element: ChatTreeItem, height?: number): void { if (this._tree.hasElement(element) && this._visible) { - this._tree.updateElementHeight(element, height); + this._withPersistedAutoScroll(() => { + this._tree.updateElementHeight(element, height); + }); } } @@ -721,18 +701,12 @@ export class ChatListWidget extends Disposable { } } - private hasScrollHeightChanged(): boolean { - return this._tree.scrollHeight !== this._previousScrollHeight; - } - - private updatePreviousScrollHeight(): void { - this._previousScrollHeight = this._tree.scrollHeight; - } - - private wasLastElementVisible(): boolean { - // Due to rounding, the scrollTop + renderHeight will not exactly match the scrollHeight. - // Consider the tree to be scrolled all the way down if it is within 2px of the bottom. - return this._tree.scrollTop + this._tree.renderHeight >= this._previousScrollHeight - 2; + private _withPersistedAutoScroll(fn: () => void): void { + const wasScrolledToBottom = this.isScrolledToBottom; + fn(); + if (wasScrolledToBottom) { + this.scrollToEnd(); + } } /** @@ -798,13 +772,6 @@ export class ChatListWidget extends Disposable { return this._renderer.getTemplateDataForRequestId(requestId); } - /** - * Update item height after rendering. - */ - updateItemHeightOnRender(element: ChatTreeItem, template: IChatListItemTemplate): void { - this._renderer.updateItemHeightOnRender(element, template); - } - /** * Update renderer options. */ @@ -850,7 +817,7 @@ export class ChatListWidget extends Disposable { this._previousLastItemMinHeight = lastItemMinHeight; const lastItem = this._viewModel?.getItems().at(-1); if (lastItem && this._visible && this._tree.hasElement(lastItem)) { - this._tree.updateElementHeight(lastItem, undefined); + this._updateElementHeight(lastItem, undefined); } } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 482687545f5c1..261bcb273335e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -1508,7 +1508,6 @@ export class ChatWidget extends Disposable implements IChatWidget { this.inputPart.dnd.setDisabledOverlay(!isInput); this.input.renderAttachedContext(); this.input.setValue(currentElement.messageText, false); - this.listWidget.updateItemHeightOnRender(currentElement, item); this.onDidChangeItems(); this.input.inputEditor.focus(); @@ -1589,9 +1588,6 @@ export class ChatWidget extends Disposable implements IChatWidget { this.inputPart?.setEditing(!!this.viewModel?.editing && isInput); this.onDidChangeItems(); - if (editedRequest?.currentElement) { - this.listWidget.updateItemHeightOnRender(editedRequest.currentElement, editedRequest); - } type CancelRequestEditEvent = { editRequestType: string; @@ -1652,18 +1648,6 @@ export class ChatWidget extends Disposable implements IChatWidget { this.styles, true ); - this._register(autorun(reader => { - this.inlineInputPart.height.read(reader); - if (!this.listWidget) { - // This is set up before the list/renderer are created - return; - } - - const editedRequest = this.listWidget.getTemplateDataForRequestId(this.viewModel?.editing?.id); - if (isRequestVM(editedRequest?.currentElement) && this.viewModel?.editing) { - this.listWidget.updateItemHeightOnRender(editedRequest?.currentElement, editedRequest); - } - })); } else { this.inputPartDisposable.value = this.instantiationService.createInstance(ChatInputPart, this.location, diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidgetService.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidgetService.ts index 2deb9859f1625..b9bb9dbadf8ab 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidgetService.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidgetService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../base/browser/dom.js'; -import { raceCancellablePromises, timeout } from '../../../../../base/common/async.js'; +import { timeout } from '../../../../../base/common/async.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { combinedDisposable, Disposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { isEqual } from '../../../../../base/common/resources.js'; @@ -153,11 +153,8 @@ export class ChatWidgetService extends Disposable implements IChatWidgetService const isGroupActive = () => dom.getWindow(this.layoutService.activeContainer).vscodeWindowId === existingEditorWindowId; let ensureFocusTransfer: Promise | undefined; - if (!isGroupActive()) { - ensureFocusTransfer = raceCancellablePromises([ - timeout(500), - Event.toPromise(Event.once(Event.filter(this.layoutService.onDidChangeActiveContainer, isGroupActive))), - ]); + if (!isGroupActive() && !options?.preserveFocus) { + ensureFocusTransfer = Event.toPromise(Event.once(Event.filter(this.layoutService.onDidChangeActiveContainer, isGroupActive))); } const pane = await existingEditor.group.openEditor(existingEditor.editor, options); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts index 9e562c53e3d32..0e3b54a72eb74 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts @@ -90,7 +90,7 @@ export class DelegationSessionPickerActionItem extends SessionTypePickerActionIt id: 'newChatSession', class: undefined, label: localize('chat.newChatSession', "New Chat Session"), - tooltip: localize('chat.newChatSession.tooltip', "Create a new chat session"), + tooltip: '', checked: false, icon: Codicon.plus, enabled: true, diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts index d31643faecdb1..9843dab12ebcc 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts @@ -76,7 +76,8 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { class: isDisabledViaPolicy ? 'disabled-by-policy' : undefined, enabled: !isDisabledViaPolicy, checked: !isDisabledViaPolicy && currentMode.id === mode.id, - tooltip, + tooltip: '', + hover: { content: tooltip }, run: async () => { if (isDisabledViaPolicy) { return; // Block interaction if disabled by policy @@ -97,7 +98,8 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { const makeActionFromCustomMode = (mode: IChatMode, currentMode: IChatMode): IActionWidgetDropdownAction => { return { ...makeAction(mode, currentMode), - tooltip: mode.description.get() ?? chatAgentService.getDefaultAgent(ChatAgentLocation.Chat, mode.kind)?.description ?? action.tooltip, + tooltip: '', + hover: { content: mode.description.get() ?? chatAgentService.getDefaultAgent(ChatAgentLocation.Chat, mode.kind)?.description ?? action.tooltip }, icon: mode.icon.get() ?? (isModeConsideredBuiltIn(mode, this._productService) ? builtinDefaultIcon : undefined), category: agentModeDisabledViaPolicy ? policyDisabledCategory : customCategory }; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts index bc378fc83fe33..584f6948a8428 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts @@ -17,7 +17,7 @@ import { IContextKeyService } from '../../../../../../platform/contextkey/common import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; import { IChatSessionsService } from '../../../common/chatSessionsService.js'; -import { AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderIcon, getAgentSessionProviderName, isFirstPartyAgentSessionProvider } from '../../agentSessions/agentSessions.js'; +import { AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderDescription, getAgentSessionProviderIcon, getAgentSessionProviderName, isFirstPartyAgentSessionProvider } from '../../agentSessions/agentSessions.js'; import { ChatInputPickerActionViewItem, IChatInputPickerOptions } from './chatInputPickerActionItem.js'; import { ISessionTypePickerDelegate } from '../../chat.js'; import { IActionProvider } from '../../../../../../base/browser/ui/dropdown/dropdown.js'; @@ -25,7 +25,7 @@ import { IActionProvider } from '../../../../../../base/browser/ui/dropdown/drop export interface ISessionTypeItem { type: AgentSessionProviders; label: string; - description: string; + hoverDescription: string; commandId: string; } @@ -66,12 +66,13 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { ...action, id: sessionTypeItem.commandId, label: sessionTypeItem.label, - tooltip: sessionTypeItem.description, checked: currentType === sessionTypeItem.type, icon: getAgentSessionProviderIcon(sessionTypeItem.type), enabled: this._isSessionTypeEnabled(sessionTypeItem.type), category: this._getSessionCategory(sessionTypeItem), description: this._getSessionDescription(sessionTypeItem), + tooltip: '', + hover: { content: sessionTypeItem.hoverDescription }, run: async () => { this._run(sessionTypeItem); }, @@ -141,7 +142,7 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { const localSessionItem: ISessionTypeItem = { type: AgentSessionProviders.Local, label: getAgentSessionProviderName(AgentSessionProviders.Local), - description: localize('chat.sessionTarget.local.description', "Local chat session"), + hoverDescription: getAgentSessionProviderDescription(AgentSessionProviders.Local), commandId: `workbench.action.chat.openNewChatSessionInPlace.${AgentSessionProviders.Local}`, }; @@ -157,7 +158,7 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { agentSessionItems.push({ type: agentSessionType, label: getAgentSessionProviderName(agentSessionType), - description: contribution.description, + hoverDescription: getAgentSessionProviderDescription(agentSessionType), commandId: contribution.canDelegate ? `workbench.action.chat.openNewChatSessionInPlace.${contribution.type}` : `workbench.action.chat.openNewChatSessionExternal.${contribution.type}`, diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index dce1f75f7a0e3..93219c0793ed2 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -11,6 +11,7 @@ import { RawContextKey } from '../../../../platform/contextkey/common/contextkey export enum ChatConfiguration { AgentEnabled = 'chat.agent.enabled', AgentStatusEnabled = 'chat.agentsControl.enabled', + UnifiedAgentsBar = 'chat.unifiedAgentsBar.enabled', AgentSessionProjectionEnabled = 'chat.agentSessionProjection.enabled', EditModeHidden = 'chat.editMode.hidden', Edits2Enabled = 'chat.edits2.enabled', diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 875f3abf2839e..ff97b0a75b4ac 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -1054,13 +1054,6 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel this._register(this._response.onDidChangeValue(() => this._onDidChange.fire(defaultChatResponseModelChangeReason))); this.id = params.restoredId ?? 'response_' + generateUuid(); - this._register(this._session.onDidChange((e) => { - if (e.kind === 'setCheckpoint') { - const isDisabled = e.disabledResponseIds.has(this.id); - this._shouldBeBlocked.set(isDisabled, undefined); - } - })); - let lastStartedWaitingAt: number | undefined = undefined; this.confirmationAdjustedTimestamp = derived(reader => { const pending = this.isPendingConfirmation.read(reader); @@ -1089,6 +1082,10 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel this._codeBlockInfos = [...codeBlockInfo]; } + setBlockedState(isBlocked: boolean): void { + this._shouldBeBlocked.set(isBlocked, undefined); + } + /** * Apply a progress update to the actual response content. */ @@ -1615,7 +1612,6 @@ export type IChatChangeEvent = | IChatMoveEvent | IChatSetHiddenEvent | IChatCompletedRequestEvent - | IChatSetCheckpointEvent | IChatSetCustomTitleEvent ; @@ -1624,12 +1620,6 @@ export interface IChatAddRequestEvent { request: IChatRequestModel; } -export interface IChatSetCheckpointEvent { - kind: 'setCheckpoint'; - disabledRequestIds: Set; - disabledResponseIds: Set; -} - export interface IChatChangedRequestEvent { kind: 'changedRequest'; request: IChatRequestModel; @@ -2153,17 +2143,14 @@ export class ChatModel extends Disposable implements IChatModel { } } - const disabledRequestIds = new Set(); - const disabledResponseIds = new Set(); for (let i = this._requests.length - 1; i >= 0; i -= 1) { const request = this._requests[i]; if (this._checkpoint && !checkpoint) { request.setShouldBeBlocked(false); } else if (checkpoint && i >= checkpointIndex) { request.setShouldBeBlocked(true); - disabledRequestIds.add(request.id); if (request.response) { - disabledResponseIds.add(request.response.id); + request.response.setBlockedState(true); } } else if (checkpoint && i < checkpointIndex) { request.setShouldBeBlocked(false); @@ -2171,11 +2158,6 @@ export class ChatModel extends Disposable implements IChatModel { } this._checkpoint = checkpoint; - this._onDidChange.fire({ - kind: 'setCheckpoint', - disabledRequestIds, - disabledResponseIds - }); } private _checkpoint: ChatRequestModel | undefined = undefined; diff --git a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts index d36593cb3bf53..2e75fe9d44e26 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts @@ -38,7 +38,7 @@ export function assertIsResponseVM(item: unknown): asserts item is IChatResponse } } -export type IChatViewModelChangeEvent = IChatAddRequestEvent | IChangePlaceholderEvent | IChatSessionInitEvent | IChatSetHiddenEvent | IChatSetCheckpointEvent | null; +export type IChatViewModelChangeEvent = IChatAddRequestEvent | IChangePlaceholderEvent | IChatSessionInitEvent | IChatSetHiddenEvent | null; export interface IChatAddRequestEvent { kind: 'addRequest'; @@ -56,10 +56,6 @@ export interface IChatSetHiddenEvent { kind: 'setHidden'; } -export interface IChatSetCheckpointEvent { - kind: 'setCheckpoint'; -} - export interface IChatViewModel { readonly model: IChatModel; readonly sessionResource: URI; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptContentStore.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptContentStore.ts index 30de1ac7e0c8a..a28402f889709 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptContentStore.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptContentStore.ts @@ -44,8 +44,16 @@ export class ChatPromptContentStore extends Disposable implements IChatPromptCon super(); } + /** + * Normalizes a URI by stripping query and fragment for consistent lookup. + * Query parameters like vscodeLinkType are metadata for rendering, not content identification. + */ + private normalizeUri(uri: URI): string { + return uri.with({ query: '', fragment: '' }).toString(); + } + registerContent(uri: URI, content: string): { dispose: () => void } { - const key = uri.toString(); + const key = this.normalizeUri(uri); this._contentMap.set(key, content); const dispose = () => { @@ -56,7 +64,7 @@ export class ChatPromptContentStore extends Disposable implements IChatPromptCon } getContent(uri: URI): string | undefined { - return this._contentMap.get(uri.toString()); + return this._contentMap.get(this.normalizeUri(uri)); } override dispose(): void { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts index 0eecdcb0d053b..8e9d41c3db744 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts @@ -10,8 +10,11 @@ import { localize } from '../../../../../nls.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import * as extensionsRegistry from '../../../../services/extensions/common/extensionsRegistry.js'; -import { IPromptsService } from './service/promptsService.js'; +import { IPromptsService, PromptsStorage } from './service/promptsService.js'; import { PromptsType } from './promptTypes.js'; +import { UriComponents } from '../../../../../base/common/uri.js'; +import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; interface IRawChatFileContribution { readonly path: string; @@ -126,3 +129,36 @@ export class ChatPromptFilesExtensionPointHandler implements IWorkbenchContribut }); } } + +/** + * Result type for the extension prompt file provider command. + */ +export interface IExtensionPromptFileResult { + readonly uri: UriComponents; + readonly type: PromptsType; +} + +/** + * Register the command to list all extension-contributed prompt files. + */ +CommandsRegistry.registerCommand('_listExtensionPromptFiles', async (accessor): Promise => { + const promptsService = accessor.get(IPromptsService); + + // Get extension prompt files for all prompt types in parallel + const [agents, instructions, prompts, skills] = await Promise.all([ + promptsService.listPromptFiles(PromptsType.agent, CancellationToken.None), + promptsService.listPromptFiles(PromptsType.instructions, CancellationToken.None), + promptsService.listPromptFiles(PromptsType.prompt, CancellationToken.None), + promptsService.listPromptFiles(PromptsType.skill, CancellationToken.None), + ]); + + // Combine all files and collect extension-contributed ones + const result: IExtensionPromptFileResult[] = []; + for (const file of [...agents, ...instructions, ...prompts, ...skills]) { + if (file.storage === PromptsStorage.extension) { + result.push({ uri: file.uri.toJSON(), type: file.type }); + } + } + + return result; +}); diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/editFileTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/editFileTool.ts index 8a582a9210d27..f83c3c4e7a83d 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/editFileTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/editFileTool.ts @@ -14,7 +14,6 @@ import { ICodeMapperService } from '../../editing/chatCodeMapperService.js'; import { ChatModel } from '../../model/chatModel.js'; import { IChatService } from '../../chatService/chatService.js'; import { CountTokensCallback, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolInvocationPresentation, ToolProgress } from '../languageModelToolsService.js'; -import { LocalChatSessionUri } from '../../model/chatUri.js'; export const ExtensionEditToolId = 'vscode_editFile'; export const InternalEditToolId = 'vscode_editFile_internal'; @@ -48,7 +47,7 @@ export class EditTool implements IToolImpl { const fileUri = URI.revive(parameters.uri); const uri = CellUri.parse(fileUri)?.notebook || fileUri; - const model = this.chatService.getSession(LocalChatSessionUri.forSession(invocation.context?.sessionId)) as ChatModel; + const model = this.chatService.getSession(invocation.context.sessionResource) as ChatModel; const request = model.getRequests().at(-1)!; model.acceptResponseProgress(request, { diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index a153f3b842525..f1fc3f1d8efbe 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -294,7 +294,9 @@ export interface IToolInvocationStreamContext { toolCallId: string; rawInput: unknown; chatRequestId?: string; + /** @deprecated Use {@link chatSessionResource} instead */ chatSessionId?: string; + chatSessionResource?: URI; chatInteractionId?: string; } diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/chatPromptFileSystemProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/chatPromptFileSystemProvider.test.ts new file mode 100644 index 0000000000000..f43e53d2cce11 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/chatPromptFileSystemProvider.test.ts @@ -0,0 +1,257 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { VSBuffer } from '../../../../../../base/common/buffer.js'; +import { FileSystemProviderErrorCode, toFileSystemProviderErrorCode } from '../../../../../../platform/files/common/files.js'; +import { ChatPromptFileSystemProvider } from '../../../browser/promptSyntax/chatPromptFileSystemProvider.js'; +import { ChatPromptContentStore } from '../../../common/promptSyntax/chatPromptContentStore.js'; + +suite('ChatPromptFileSystemProvider', () => { + const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); + + let contentStore: ChatPromptContentStore; + let provider: ChatPromptFileSystemProvider; + + setup(() => { + contentStore = testDisposables.add(new ChatPromptContentStore()); + provider = new ChatPromptFileSystemProvider(contentStore); + }); + + suite('stat', () => { + test('returns stat for registered content', async () => { + const uri = URI.parse('vscode-chat-prompt:/.agent.md/test-agent'); + const content = '# Test Agent\nThis is test content.'; + + testDisposables.add(contentStore.registerContent(uri, content)); + + const stat = await provider.stat(uri); + + assert.strictEqual(stat.type, 1); // FileType.File + assert.strictEqual(stat.size, VSBuffer.fromString(content).byteLength); + }); + + test('throws FileNotFound for unregistered URI', async () => { + const uri = URI.parse('vscode-chat-prompt:/.agent.md/missing'); + + await assert.rejects( + () => provider.stat(uri), + (err: Error & { code?: string }) => { + return toFileSystemProviderErrorCode(err) === FileSystemProviderErrorCode.FileNotFound; + }, + 'Should throw FileNotFound error' + ); + }); + + test('returns correct size for empty content', async () => { + const uri = URI.parse('vscode-chat-prompt:/.prompt.md/empty'); + + testDisposables.add(contentStore.registerContent(uri, '')); + + const stat = await provider.stat(uri); + + assert.strictEqual(stat.size, 0); + }); + + test('returns correct size for unicode content', async () => { + const uri = URI.parse('vscode-chat-prompt:/.instructions.md/unicode'); + const content = '日本語テスト 🎉'; + + testDisposables.add(contentStore.registerContent(uri, content)); + + const stat = await provider.stat(uri); + + assert.strictEqual(stat.size, VSBuffer.fromString(content).byteLength); + }); + }); + + suite('readFile', () => { + test('returns content for registered URI', async () => { + const uri = URI.parse('vscode-chat-prompt:/.agent.md/test-agent'); + const content = '# Test Agent\nThis is test content.'; + + testDisposables.add(contentStore.registerContent(uri, content)); + + const result = await provider.readFile(uri); + + assert.strictEqual(VSBuffer.wrap(result).toString(), content); + }); + + test('throws FileNotFound for unregistered URI', async () => { + const uri = URI.parse('vscode-chat-prompt:/.agent.md/missing'); + + await assert.rejects( + () => provider.readFile(uri), + (err: Error & { code?: string }) => { + return toFileSystemProviderErrorCode(err) === FileSystemProviderErrorCode.FileNotFound; + }, + 'Should throw FileNotFound error' + ); + }); + + test('returns empty buffer for empty content', async () => { + const uri = URI.parse('vscode-chat-prompt:/.prompt.md/empty'); + + testDisposables.add(contentStore.registerContent(uri, '')); + + const result = await provider.readFile(uri); + + assert.strictEqual(result.byteLength, 0); + }); + + test('preserves unicode content', async () => { + const uri = URI.parse('vscode-chat-prompt:/.instructions.md/unicode'); + const content = '日本語テスト 🎉\n\n```typescript\nconst greeting = "こんにちは";\n```'; + + testDisposables.add(contentStore.registerContent(uri, content)); + + const result = await provider.readFile(uri); + + assert.strictEqual(VSBuffer.wrap(result).toString(), content); + }); + + test('handles content with special markdown characters', async () => { + const uri = URI.parse('vscode-chat-prompt:/.agent.md/markdown'); + const content = '# Heading\n\n- List item\n- Another item\n\n> Blockquote\n\n```\ncode block\n```'; + + testDisposables.add(contentStore.registerContent(uri, content)); + + const result = await provider.readFile(uri); + + assert.strictEqual(VSBuffer.wrap(result).toString(), content); + }); + }); + + suite('content lifecycle', () => { + test('readFile fails after content is disposed', async () => { + const uri = URI.parse('vscode-chat-prompt:/.agent.md/lifecycle-test'); + const content = 'Temporary content'; + + const registration = contentStore.registerContent(uri, content); + + // Verify content is readable + const result = await provider.readFile(uri); + assert.strictEqual(VSBuffer.wrap(result).toString(), content); + + // Dispose the content + registration.dispose(); + + // Now reading should fail + await assert.rejects( + () => provider.readFile(uri), + (err: Error & { code?: string }) => { + return toFileSystemProviderErrorCode(err) === FileSystemProviderErrorCode.FileNotFound; + } + ); + }); + + test('stat fails after content is disposed', async () => { + const uri = URI.parse('vscode-chat-prompt:/.prompt.md/lifecycle-stat'); + const content = 'Content for stat test'; + + const registration = contentStore.registerContent(uri, content); + + // Verify stat works + const stat = await provider.stat(uri); + assert.strictEqual(stat.size, VSBuffer.fromString(content).byteLength); + + // Dispose the content + registration.dispose(); + + // Now stat should fail + await assert.rejects( + () => provider.stat(uri), + (err: Error & { code?: string }) => { + return toFileSystemProviderErrorCode(err) === FileSystemProviderErrorCode.FileNotFound; + } + ); + }); + }); + + suite('URI normalization', () => { + test('readFile succeeds when URI has query parameters', async () => { + const baseUri = URI.parse('vscode-chat-prompt:/.agent.md/query-test'); + const content = 'Content for query test'; + + testDisposables.add(contentStore.registerContent(baseUri, content)); + + // Read with query parameters + const uriWithQuery = baseUri.with({ query: 'vscodeLinkType=prompt' }); + const result = await provider.readFile(uriWithQuery); + + assert.strictEqual(VSBuffer.wrap(result).toString(), content); + }); + + test('stat succeeds when URI has fragment', async () => { + const baseUri = URI.parse('vscode-chat-prompt:/.instructions.md/fragment-test'); + const content = 'Content for fragment test'; + + testDisposables.add(contentStore.registerContent(baseUri, content)); + + // Stat with fragment + const uriWithFragment = baseUri.with({ fragment: 'section1' }); + const stat = await provider.stat(uriWithFragment); + + assert.strictEqual(stat.size, VSBuffer.fromString(content).byteLength); + }); + }); + + suite('unsupported operations', () => { + test('writeFile throws NoPermissions error', async () => { + const uri = URI.parse('vscode-chat-prompt:/.agent.md/write-test'); + + await assert.rejects( + () => provider.writeFile(uri, new Uint8Array(), { create: true, overwrite: true, unlock: false, atomic: false }), + (err: Error & { code?: string }) => { + return toFileSystemProviderErrorCode(err) === FileSystemProviderErrorCode.NoPermissions; + } + ); + }); + + test('mkdir throws NoPermissions error', async () => { + const uri = URI.parse('vscode-chat-prompt:/test-dir'); + + await assert.rejects( + () => provider.mkdir(uri), + (err: Error & { code?: string }) => { + return toFileSystemProviderErrorCode(err) === FileSystemProviderErrorCode.NoPermissions; + } + ); + }); + + test('delete throws NoPermissions error', async () => { + const uri = URI.parse('vscode-chat-prompt:/.agent.md/delete-test'); + + await assert.rejects( + () => provider.delete(uri, { recursive: false, useTrash: false, atomic: false }), + (err: Error & { code?: string }) => { + return toFileSystemProviderErrorCode(err) === FileSystemProviderErrorCode.NoPermissions; + } + ); + }); + + test('rename throws NoPermissions error', async () => { + const from = URI.parse('vscode-chat-prompt:/.agent.md/rename-from'); + const to = URI.parse('vscode-chat-prompt:/.agent.md/rename-to'); + + await assert.rejects( + () => provider.rename(from, to, { overwrite: false }), + (err: Error & { code?: string }) => { + return toFileSystemProviderErrorCode(err) === FileSystemProviderErrorCode.NoPermissions; + } + ); + }); + + test('readdir returns empty array', async () => { + const uri = URI.parse('vscode-chat-prompt:/'); + + const result = await provider.readdir(uri); + + assert.deepStrictEqual(result, []); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/chatPromptContentStore.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/chatPromptContentStore.test.ts index 3d3236124b97e..75f4496a0e322 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/chatPromptContentStore.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/chatPromptContentStore.test.ts @@ -142,4 +142,52 @@ suite('ChatPromptContentStore', () => { // Should be retrievable with equivalent URI assert.strictEqual(store.getContent(uri2), content); }); + + test('getContent normalizes URI by stripping query parameters', () => { + const baseUri = URI.parse('vscode-chat-prompt:/.agent.md/normalize-test'); + const content = 'Normalized content'; + + const disposable = store.registerContent(baseUri, content); + testDisposables.add(disposable); + + // Should retrieve content when queried with extra query parameters + const uriWithQuery = baseUri.with({ query: 'vscodeLinkType=prompt' }); + assert.strictEqual(store.getContent(uriWithQuery), content); + }); + + test('getContent normalizes URI by stripping fragment', () => { + const baseUri = URI.parse('vscode-chat-prompt:/.instructions.md/fragment-test'); + const content = 'Content with fragment lookup'; + + const disposable = store.registerContent(baseUri, content); + testDisposables.add(disposable); + + // Should retrieve content when queried with fragment + const uriWithFragment = baseUri.with({ fragment: 'section1' }); + assert.strictEqual(store.getContent(uriWithFragment), content); + }); + + test('getContent normalizes URI by stripping both query and fragment', () => { + const baseUri = URI.parse('vscode-chat-prompt:/.prompt.md/full-normalize'); + const content = 'Fully normalized content'; + + const disposable = store.registerContent(baseUri, content); + testDisposables.add(disposable); + + // Should retrieve content when queried with both query and fragment + const uriWithBoth = baseUri.with({ query: 'vscodeLinkType=skill&foo=bar', fragment: 'heading' }); + assert.strictEqual(store.getContent(uriWithBoth), content); + }); + + test('registerContent normalizes URI so content registered with query is found without it', () => { + const uriWithQuery = URI.parse('vscode-chat-prompt:/.agent.md/register-with-query?vscodeLinkType=agent'); + const content = 'Content registered with query'; + + const disposable = store.registerContent(uriWithQuery, content); + testDisposables.add(disposable); + + // Should retrieve content using base URI without query + const baseUri = uriWithQuery.with({ query: '' }); + assert.strictEqual(store.getContent(baseUri), content); + }); }); diff --git a/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts index e437e7144ef1c..a94e82021940c 100644 --- a/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts +++ b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts @@ -40,10 +40,13 @@ import { AgentSessionsWelcomeEditorOptions, AgentSessionsWelcomeInput } from './ import { IChatService } from '../../chat/common/chatService/chatService.js'; import { IChatModel } from '../../chat/common/model/chatModel.js'; import { ISessionTypePickerDelegate } from '../../chat/browser/chat.js'; +import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; import { AgentSessionsControl, IAgentSessionsControlOptions } from '../../chat/browser/agentSessions/agentSessionsControl.js'; import { IAgentSessionsFilter } from '../../chat/browser/agentSessions/agentSessionsViewer.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { IResolvedWalkthrough, IWalkthroughsService } from '../../welcomeGettingStarted/browser/gettingStartedService.js'; +import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; const configurationKey = 'workbench.startupEditor'; const MAX_SESSIONS = 6; @@ -79,6 +82,8 @@ export class AgentSessionsWelcomePage extends EditorPane { @IProductService private readonly productService: IProductService, @IWalkthroughsService private readonly walkthroughsService: IWalkthroughsService, @IChatService private readonly chatService: IChatService, + @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, + @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, ) { super(AgentSessionsWelcomePage.ID, group, telemetryService, themeService, storageService); @@ -349,7 +354,48 @@ export class AgentSessionsWelcomePage extends EditorPane { } } + private buildPrivacyNotice(container: HTMLElement): void { + // TOS/Privacy notice for users who are not signed in - reusing walkthrough card design + if (!this.chatEntitlementService.anonymous) { + return; + } + + const providers = this.productService.defaultChatAgent?.provider; + if (!providers || !providers.default || !this.productService.defaultChatAgent?.termsStatementUrl || !this.productService.defaultChatAgent?.privacyStatementUrl) { + return; + } + + const tosCard = append(container, $('.agentSessionsWelcome-walkthroughCard.agentSessionsWelcome-tosCard')); + + // Icon + const iconContainer = append(tosCard, $('.agentSessionsWelcome-walkthroughCard-icon')); + iconContainer.appendChild(renderIcon(Codicon.commentDiscussion)); + + // Content + const content = append(tosCard, $('.agentSessionsWelcome-walkthroughCard-content')); + const title = append(content, $('.agentSessionsWelcome-walkthroughCard-title')); + title.textContent = localize('tosTitle', "AI Feature Trial is Active"); + + const desc = append(content, $('.agentSessionsWelcome-walkthroughCard-description')); + const descriptionMarkdown = new MarkdownString( + localize( + { key: 'tosDescription', comment: ['{Locked="]({1})"}', '{Locked="]({2})"}'] }, + "By continuing, you agree to {0}'s [Terms]({1}) and [Privacy Statement]({2}).", + providers.default.name, + this.productService.defaultChatAgent.termsStatementUrl, + this.productService.defaultChatAgent.privacyStatementUrl + ), + { isTrusted: true } + ); + const renderedMarkdown = this.markdownRendererService.render(descriptionMarkdown); + desc.appendChild(renderedMarkdown.element); + } + private buildFooter(container: HTMLElement): void { + + // Privacy notice + this.buildPrivacyNotice(container); + // Learning link const learningLink = append(container, $('button.agentSessionsWelcome-footerLink')); learningLink.appendChild(renderIcon(Codicon.mortarBoard)); diff --git a/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css b/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css index 35d56a9feabdc..2309a4ba97d57 100644 --- a/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css +++ b/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css @@ -358,3 +358,25 @@ width: 16px; height: 16px; } + +/* TOS/Privacy card - extends walkthrough card with TOS-specific overrides */ +.agentSessionsWelcome-tosCard { + width: 100%; + max-width: 800px; + margin-bottom: 16px; + box-sizing: border-box; + cursor: default; +} + +.agentSessionsWelcome-tosCard:hover { + background-color: var(--vscode-welcomePage-tileBackground); +} + +.agentSessionsWelcome-tosLink { + color: var(--vscode-textLink-foreground); + text-decoration: none; +} + +.agentSessionsWelcome-tosLink:hover { + text-decoration: underline; +} diff --git a/src/vs/workbench/services/configuration/browser/configuration.ts b/src/vs/workbench/services/configuration/browser/configuration.ts index c4027279f984b..0defa53393500 100644 --- a/src/vs/workbench/services/configuration/browser/configuration.ts +++ b/src/vs/workbench/services/configuration/browser/configuration.ts @@ -1080,4 +1080,8 @@ export class FolderConfiguration extends Disposable { this.cachedFolderConfiguration.updateConfiguration(settingsContent, standAloneConfigurationContents); } } + + public addRelated(disposable: IDisposable): void { + this._register(disposable); + } } diff --git a/src/vs/workbench/services/configuration/browser/configurationService.ts b/src/vs/workbench/services/configuration/browser/configurationService.ts index ecbfc79a45a1d..e9e0189165bc2 100644 --- a/src/vs/workbench/services/configuration/browser/configurationService.ts +++ b/src/vs/workbench/services/configuration/browser/configurationService.ts @@ -7,7 +7,7 @@ import { URI } from '../../../../base/common/uri.js'; import { Event, Emitter } from '../../../../base/common/event.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { equals } from '../../../../base/common/objects.js'; -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js'; import { Queue, Barrier, Promises, Delayer, Throttler } from '../../../../base/common/async.js'; import { IJSONContributionRegistry, Extensions as JSONExtensions } from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js'; import { IWorkspaceContextService, Workspace as BaseWorkspace, WorkbenchState, IWorkspaceFolder, IWorkspaceFoldersChangeEvent, WorkspaceFolder, toWorkspaceFolder, isWorkspaceFolder, IWorkspaceFoldersWillChangeEvent, IEmptyWorkspaceIdentifier, ISingleFolderWorkspaceIdentifier, isSingleFolderWorkspaceIdentifier, isWorkspaceIdentifier, IWorkspaceIdentifier, IAnyWorkspaceIdentifier } from '../../../../platform/workspace/common/workspace.js'; @@ -77,7 +77,7 @@ export class WorkspaceService extends Disposable implements IWorkbenchConfigurat private readonly localUserConfiguration: UserConfiguration; private readonly remoteUserConfiguration: RemoteUserConfiguration | null = null; private readonly workspaceConfiguration: WorkspaceConfiguration; - private cachedFolderConfigs: ResourceMap; + private cachedFolderConfigs: DisposableMap = this._register(new DisposableMap(new ResourceMap())); private readonly workspaceEditingQueue: Queue; private readonly _onDidChangeConfiguration: Emitter = this._register(new Emitter()); @@ -131,7 +131,6 @@ export class WorkspaceService extends Disposable implements IWorkbenchConfigurat this.applicationConfigurationDisposables = this._register(new DisposableStore()); this.createApplicationConfiguration(); this.localUserConfiguration = this._register(new UserConfiguration(userDataProfileService.currentProfile.settingsResource, userDataProfileService.currentProfile.tasksResource, userDataProfileService.currentProfile.mcpResource, { scopes: getLocalUserConfigurationScopes(userDataProfileService.currentProfile, !!remoteAuthority) }, fileService, uriIdentityService, logService)); - this.cachedFolderConfigs = new ResourceMap(); this._register(this.localUserConfiguration.onDidChangeConfiguration(userConfiguration => this.onLocalUserConfigurationChanged(userConfiguration))); if (remoteAuthority) { const remoteUserConfiguration = this.remoteUserConfiguration = this._register(new RemoteUserConfiguration(remoteAuthority, configurationCache, fileService, uriIdentityService, remoteAgentService, logService)); @@ -686,7 +685,7 @@ export class WorkspaceService extends Disposable implements IWorkbenchConfigurat private async loadConfiguration(applicationConfigurationModel: ConfigurationModel, userConfigurationModel: ConfigurationModel, remoteUserConfigurationModel: ConfigurationModel, trigger: boolean): Promise { // reset caches - this.cachedFolderConfigs = new ResourceMap(); + this.cachedFolderConfigs.clearAndDisposeAll(); const folders = this.workspace.folders; const folderConfigurations = await this.loadFolderConfigurations(folders); @@ -942,9 +941,7 @@ export class WorkspaceService extends Disposable implements IWorkbenchConfigurat // Remove the configurations of deleted folders for (const key of this.cachedFolderConfigs.keys()) { if (!this.workspace.folders.filter(folder => folder.uri.toString() === key.toString())[0]) { - const folderConfiguration = this.cachedFolderConfigs.get(key); - folderConfiguration!.dispose(); - this.cachedFolderConfigs.delete(key); + this.cachedFolderConfigs.deleteAndDispose(key); changes.push(this._configuration.compareAndDeleteFolderConfiguration(key)); } } @@ -964,8 +961,8 @@ export class WorkspaceService extends Disposable implements IWorkbenchConfigurat let folderConfiguration = this.cachedFolderConfigs.get(folder.uri); if (!folderConfiguration) { folderConfiguration = new FolderConfiguration(!this.initialized, folder, FOLDER_CONFIG_FOLDER_NAME, this.getWorkbenchState(), this.isWorkspaceTrusted, this.fileService, this.uriIdentityService, this.logService, this.configurationCache); - this._register(folderConfiguration.onDidChange(() => this.onWorkspaceFolderConfigurationChanged(folder))); - this.cachedFolderConfigs.set(folder.uri, this._register(folderConfiguration)); + folderConfiguration.addRelated(folderConfiguration.onDidChange(() => this.onWorkspaceFolderConfigurationChanged(folder))); + this.cachedFolderConfigs.set(folder.uri, folderConfiguration); } return folderConfiguration.loadConfiguration(); })]); diff --git a/src/vscode-dts/vscode.d.ts b/src/vscode-dts/vscode.d.ts index d858a7745fea4..30ba7df267a04 100644 --- a/src/vscode-dts/vscode.d.ts +++ b/src/vscode-dts/vscode.d.ts @@ -10138,7 +10138,7 @@ declare module 'vscode' { * * This closes the panel if it showing and disposes of the resources owned by the webview. * Webview panels are also disposed when the user closes the webview panel. Both cases - * fire the `onDispose` event. + * fire the `onDidDispose` event. */ dispose(): any; } diff --git a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts index 83235b8fea7e4..6dbf88c6895ff 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts @@ -695,7 +695,9 @@ declare module 'vscode' { readonly rawInput?: unknown; readonly chatRequestId?: string; + /** @deprecated Use {@link chatSessionResource} instead */ readonly chatSessionId?: string; + readonly chatSessionResource?: Uri; readonly chatInteractionId?: string; } diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index 4196e31d90334..4ab722c122e23 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -61,10 +61,17 @@ declare module 'vscode' { readonly attempt: number; /** - * The session identifier for this chat request + * The session identifier for this chat request. + * + * @deprecated Use {@link chatSessionResource} instead. */ readonly sessionId: string; + /** + * The resource URI for the chat session this request belongs to. + */ + readonly sessionResource: Uri; + /** * If automatic command detection is enabled. */ @@ -239,7 +246,9 @@ declare module 'vscode' { export interface LanguageModelToolInvocationOptions { chatRequestId?: string; + /** @deprecated Use {@link chatSessionResource} instead */ chatSessionId?: string; + chatSessionResource?: Uri; chatInteractionId?: string; terminalCommand?: string; /** @@ -254,7 +263,9 @@ declare module 'vscode' { */ input: T; chatRequestId?: string; + /** @deprecated Use {@link chatSessionResource} instead */ chatSessionId?: string; + chatSessionResource?: Uri; chatInteractionId?: string; } diff --git a/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts b/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts index 8e35b35ba92d0..f97cb106c1004 100644 --- a/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts +++ b/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts @@ -8,18 +8,12 @@ declare module 'vscode' { // #region Resource Classes - /** - * Describes a chat resource URI with optional editability. - */ - export type ChatResourceUriDescriptor = - | Uri - | { uri: Uri; isEditable?: boolean }; - /** * Describes a chat resource file. */ export type ChatResourceDescriptor = - | ChatResourceUriDescriptor + | Uri + | { uri: Uri; isEditable?: boolean } | { id: string; content: string; @@ -80,14 +74,14 @@ declare module 'vscode' { /** * The skill resource descriptor. */ - readonly resource: ChatResourceUriDescriptor; + readonly resource: ChatResourceDescriptor; /** * Creates a new skill resource from the specified resource URI pointing to SKILL.md. * The parent folder name needs to match the name of the skill in the frontmatter. * @param resource The chat resource descriptor. */ - constructor(resource: ChatResourceUriDescriptor); + constructor(resource: ChatResourceDescriptor); } // #endregion diff --git a/src/vscode-dts/vscode.proposed.mcpServerDefinitions.d.ts b/src/vscode-dts/vscode.proposed.mcpServerDefinitions.d.ts new file mode 100644 index 0000000000000..c0d4dc2b70244 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.mcpServerDefinitions.d.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + // https://github.com/microsoft/vscode/issues/288777 @DonJayamanne + + /** + * Namespace for language model related functionality. + */ + export namespace lm { + /** + * All MCP server definitions known to the editor. This includes + * servers defined in user and workspace mcp.json files as well as those + * provided by extensions. + * + * Consumers should listen to {@link onDidChangeMcpServerDefinitions} and + * re-read this property when it fires. + */ + export const mcpServerDefinitions: readonly McpServerDefinition[]; + + /** + * Event that fires when the set of MCP server definitions changes. + * This can be due to additions, deletions, or modifications of server + * definitions from any source. + */ + export const onDidChangeMcpServerDefinitions: Event; + } +}