diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 06341d03c02be..130f387072c4d 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -23,7 +23,8 @@ import { ExtensionIdentifier } from '../../../platform/extensions/common/extensi import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../platform/log/common/log.js'; import { IUriIdentityService } from '../../../platform/uriIdentity/common/uriIdentity.js'; -import { IChatWidgetService } from '../../contrib/chat/browser/chat.js'; +import { IChatWidget, IChatWidgetService } from '../../contrib/chat/browser/chat.js'; +import { AgentSessionProviders, getAgentSessionProvider } from '../../contrib/chat/browser/agentSessions/agentSessions.js'; import { AddDynamicVariableAction, IAddDynamicVariableContext } from '../../contrib/chat/browser/attachments/chatDynamicVariables.js'; import { IChatAgentHistoryEntry, IChatAgentImplementation, IChatAgentRequest, IChatAgentService } from '../../contrib/chat/common/participants/chatAgents.js'; import { IPromptFileContext, IPromptsService } from '../../contrib/chat/common/promptSyntax/service/promptsService.js'; @@ -145,9 +146,18 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA this._register(this._chatService.onDidReceiveQuestionCarouselAnswer(e => { this._proxy.$handleQuestionCarouselAnswer(e.requestId, e.resolveId, e.answers); })); - this._register(this._chatWidgetService.onDidChangeFocusedWidget(widget => { - this._proxy.$acceptActiveChatSession(widget?.viewModel?.sessionResource); + this._register(this._chatWidgetService.onDidChangeFocusedSession(() => { + this._acceptActiveChatSession(this._chatWidgetService.lastFocusedWidget); })); + + // Push the initial active session if there is already a focused widget + this._acceptActiveChatSession(this._chatWidgetService.lastFocusedWidget); + } + + private _acceptActiveChatSession(widget: IChatWidget | undefined): void { + const sessionResource = widget?.viewModel?.sessionResource; + const isLocal = sessionResource && getAgentSessionProvider(sessionResource) === AgentSessionProviders.Local; + this._proxy.$acceptActiveChatSession(isLocal ? sessionResource : undefined); } $unregisterAgent(handle: number): void { diff --git a/src/vs/workbench/api/common/extHostLanguageModels.ts b/src/vs/workbench/api/common/extHostLanguageModels.ts index f4383ce38d2d5..18c2045825c0d 100644 --- a/src/vs/workbench/api/common/extHostLanguageModels.ts +++ b/src/vs/workbench/api/common/extHostLanguageModels.ts @@ -228,6 +228,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { isDefaultForLocation, isUserSelectable: m.isUserSelectable, statusIcon: m.statusIcon, + targetChatSessionType: m.targetChatSessionType, modelPickerCategory: m.category ?? DEFAULT_MODEL_PICKER_CATEGORY, capabilities: m.capabilities ? { vision: m.capabilities.imageInput, diff --git a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts index 8571437e5dc58..643bbb4a9794d 100644 --- a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -97,7 +97,7 @@ export class ModalEditorPart { ModalEditorPartImpl, mainWindow.vscodeWindowId, this.editorPartsView, - localize('modalEditorPart', "Modal Editor Area") + modalElement, )); disposables.add(this.editorPartsView.registerPart(editorPart)); editorPart.create(editorPartContainer); @@ -240,7 +240,7 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { constructor( windowId: number, editorPartsView: IEditorPartsView, - groupsLabel: string, + private readonly modalElement: HTMLElement, @IInstantiationService instantiationService: IInstantiationService, @IThemeService themeService: IThemeService, @IConfigurationService configurationService: IConfigurationService, @@ -250,11 +250,15 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { @IContextKeyService contextKeyService: IContextKeyService, ) { const id = ModalEditorPartImpl.COUNTER++; - super(editorPartsView, `workbench.parts.modalEditor.${id}`, groupsLabel, windowId, instantiationService, themeService, configurationService, storageService, layoutService, hostService, contextKeyService); + super(editorPartsView, `workbench.parts.modalEditor.${id}`, localize('modalEditorPart', "Modal Editor Area"), windowId, instantiationService, themeService, configurationService, storageService, layoutService, hostService, contextKeyService); this.enforceModalPartOptions(); } + getModalElement() { + return this.modalElement; + } + override create(parent: HTMLElement, options?: object): void { this.previousMainWindowActiveElement = mainWindow.document.activeElement; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 18c34c4bf6564..e020ad9808cca 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -38,7 +38,7 @@ import { ITelemetryService } from '../../../../../platform/telemetry/common/tele import { ActiveEditorContext } from '../../../../common/contextkeys.js'; import { IViewDescriptorService, ViewContainerLocation } from '../../../../common/views.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; -import { ACTIVE_GROUP, AUX_WINDOW_GROUP } from '../../../../services/editor/common/editorService.js'; +import { ACTIVE_GROUP, AUX_WINDOW_GROUP, SIDE_GROUP } from '../../../../services/editor/common/editorService.js'; import { IHostService } from '../../../../services/host/browser/host.js'; import { IWorkbenchLayoutService, Parts } from '../../../../services/layout/browser/layoutService.js'; import { IPreferencesService } from '../../../../services/preferences/common/preferences.js'; @@ -695,6 +695,23 @@ export function registerChatActions() { } }); + registerAction2(class NewChatEditorToSideAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.openChatToSide', + title: localize2('interactiveSession.openToSide', "New Chat Editor to the Side"), + f1: true, + category: CHAT_CATEGORY, + precondition: ChatContextKeys.enabled, + }); + } + + async run(accessor: ServicesAccessor) { + const widgetService = accessor.get(IChatWidgetService); + await widgetService.openSession(LocalChatSessionUri.getNewSessionUri(), SIDE_GROUP, { pinned: true } satisfies IChatEditorOptions); + } + }); + registerAction2(class NewChatWindowAction extends Action2 { constructor() { super({ diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 2192b3827277b..bccd2696f4461 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -417,7 +417,9 @@ export class OpenModelPickerAction extends Action2 { group: 'navigation', when: ContextKeyExpr.and( - ChatContextKeys.lockedToCodingAgent.negate(), + ContextKeyExpr.or( + ChatContextKeys.lockedToCodingAgent.negate(), + ChatContextKeys.chatSessionHasTargetedModels), ContextKeyExpr.or( ContextKeyExpr.equals(ChatContextKeys.location.key, ChatAgentLocation.Chat), ContextKeyExpr.equals(ChatContextKeys.location.key, ChatAgentLocation.EditorInline), diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts index 1bed8d9733a48..101377d162559 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts @@ -15,7 +15,6 @@ import { CommandsRegistry } from '../../../../../platform/commands/common/comman import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; -import { ActiveEditorContext } from '../../../../common/contextkeys.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatEditingSession } from '../../common/editing/chatEditingService.js'; @@ -25,7 +24,6 @@ import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common import { getChatSessionType, LocalChatSessionUri } from '../../common/model/chatUri.js'; import { ChatViewId, IChatWidgetService, isIChatViewViewContext } from '../chat.js'; import { EditingSessionAction, EditingSessionActionContext, getEditingSessionContext } from '../chatEditing/chatEditingActions.js'; -import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js'; import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.js'; import { ACTION_ID_NEW_CHAT, ACTION_ID_NEW_EDIT_SESSION, CHAT_CATEGORY, handleCurrentEditingSession } from './chatActions.js'; import { clearChatEditor } from './chatClear.js'; @@ -115,12 +113,6 @@ export function registerNewChatActions() { id: MenuId.ChatNewMenu, group: '1_open', order: 1, - }, - { - id: MenuId.CompactWindowEditorTitle, - group: 'navigation', - when: ActiveEditorContext.isEqualTo(ChatEditorInput.EditorID), - order: 1 } ], keybinding: { diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts index e0df9ffe04f2d..7744f6a9e9f92 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts @@ -614,8 +614,7 @@ export class DefaultChatAttachmentWidget extends AbstractChatAttachmentWidget { } if (attachment.kind === 'symbol') { - const scopedContextKeyService = this._register(this.contextKeyService.createScoped(this.element)); - this._register(this.instantiationService.invokeFunction(hookUpSymbolAttachmentDragAndContextMenu, this.element, scopedContextKeyService, { ...attachment, kind: attachment.symbolKind }, MenuId.ChatInputSymbolAttachmentContext)); + this._register(this.instantiationService.invokeFunction(hookUpSymbolAttachmentDragAndContextMenu, this.element, this.contextKeyService, { ...attachment, kind: attachment.symbolKind }, MenuId.ChatInputSymbolAttachmentContext)); } // Handle click for string context attachments with context commands @@ -1115,19 +1114,15 @@ export function hookUpResourceAttachmentDragAndContextMenu(accessor: ServicesAcc return store; } -export function hookUpSymbolAttachmentDragAndContextMenu(accessor: ServicesAccessor, widget: HTMLElement, scopedContextKeyService: IScopedContextKeyService, attachment: { name: string; value: Location; kind: SymbolKind }, contextMenuId: MenuId): IDisposable { +export function hookUpSymbolAttachmentDragAndContextMenu(accessor: ServicesAccessor, widget: HTMLElement, parentContextKeyService: IContextKeyService, attachment: { name: string; value: Location; kind: SymbolKind }, contextMenuId: MenuId): IDisposable { const instantiationService = accessor.get(IInstantiationService); const languageFeaturesService = accessor.get(ILanguageFeaturesService); const textModelService = accessor.get(ITextModelService); + const contextMenuService = accessor.get(IContextMenuService); + const menuService = accessor.get(IMenuService); const store = new DisposableStore(); - // Context - store.add(setResourceContext(accessor, scopedContextKeyService, attachment.value.uri)); - - const chatResourceContext = chatAttachmentResourceContextKey.bindTo(scopedContextKeyService); - chatResourceContext.set(attachment.value.uri.toString()); - // Drag and drop widget.draggable = true; store.add(dom.addDisposableListener(widget, 'dragstart', e => { @@ -1143,26 +1138,57 @@ export function hookUpSymbolAttachmentDragAndContextMenu(accessor: ServicesAcces e.dataTransfer?.setDragImage(widget, 0, 0); })); - // Context menu - const providerContexts: ReadonlyArray<[IContextKey, LanguageFeatureRegistry]> = [ - [EditorContextKeys.hasDefinitionProvider.bindTo(scopedContextKeyService), languageFeaturesService.definitionProvider], - [EditorContextKeys.hasReferenceProvider.bindTo(scopedContextKeyService), languageFeaturesService.referenceProvider], - [EditorContextKeys.hasImplementationProvider.bindTo(scopedContextKeyService), languageFeaturesService.implementationProvider], - [EditorContextKeys.hasTypeDefinitionProvider.bindTo(scopedContextKeyService), languageFeaturesService.typeDefinitionProvider], - ]; + // Context menu (context key service created eagerly for keybinding preconditions, + // but resource context and provider contexts are initialized lazily on first use) + const scopedContextKeyService = store.add(parentContextKeyService.createScoped(widget)); + chatAttachmentResourceContextKey.bindTo(scopedContextKeyService).set(attachment.value.uri.toString()); + store.add(setResourceContext(accessor, scopedContextKeyService, attachment.value.uri)); + + let providerContexts: ReadonlyArray<[IContextKey, LanguageFeatureRegistry]> | undefined; + + const ensureProviderContexts = () => { + if (!providerContexts) { + providerContexts = [ + [EditorContextKeys.hasDefinitionProvider.bindTo(scopedContextKeyService), languageFeaturesService.definitionProvider], + [EditorContextKeys.hasReferenceProvider.bindTo(scopedContextKeyService), languageFeaturesService.referenceProvider], + [EditorContextKeys.hasImplementationProvider.bindTo(scopedContextKeyService), languageFeaturesService.implementationProvider], + [EditorContextKeys.hasTypeDefinitionProvider.bindTo(scopedContextKeyService), languageFeaturesService.typeDefinitionProvider], + ]; + } + }; const updateContextKeys = async () => { + ensureProviderContexts(); const modelRef = await textModelService.createModelReference(attachment.value.uri); try { const model = modelRef.object.textEditorModel; - for (const [contextKey, registry] of providerContexts) { + for (const [contextKey, registry] of providerContexts!) { contextKey.set(registry.has(model)); } } finally { modelRef.dispose(); } }; - store.add(addBasicContextMenu(accessor, widget, scopedContextKeyService, contextMenuId, attachment.value, updateContextKeys)); + + store.add(dom.addDisposableListener(widget, dom.EventType.CONTEXT_MENU, async domEvent => { + const event = new StandardMouseEvent(dom.getWindow(domEvent), domEvent); + dom.EventHelper.stop(domEvent, true); + + try { + await updateContextKeys(); + } catch (e) { + console.error(e); + } + + contextMenuService.showContextMenu({ + contextKeyService: scopedContextKeyService, + getAnchor: () => event, + getActions: () => { + const menu = menuService.getMenuActions(contextMenuId, scopedContextKeyService, { arg: attachment.value }); + return getFlatContextMenuActions(menu); + }, + }); + })); return store; } diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 1cace4dc13431..e58d1fe7fba73 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -126,6 +126,12 @@ export interface IChatWidgetService { */ readonly onDidChangeFocusedWidget: Event; + /** + * Fires when the focused chat session changes, either because the focused widget + * changed or because the focused widget loaded a different session. + */ + readonly onDidChangeFocusedSession: Event; + /** * Reveals the widget, focusing its input unless `preserveFocus` is true. */ diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 1bb18e323323b..cb8b452684a93 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -207,6 +207,11 @@ const extensionPoint = ExtensionsRegistry.registerExtensionPoint { progress({ kind: 'progressMessage', - content: new MarkdownString(localize('waitingChat2', "Chat is almost ready...")), + content: new MarkdownString(localize('waitingChat2', "Chat is almost ready")), + shimmer: true, }); }, 10000); @@ -602,13 +604,15 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { case ChatSetupStep.SigningIn: progress({ kind: 'progressMessage', - content: new MarkdownString(localize('setupChatSignIn2', "Signing in to {0}...", defaultAccountService.getDefaultAccountAuthenticationProvider().name)), + content: new MarkdownString(localize('setupChatSignIn2', "Signing in to {0}", defaultAccountService.getDefaultAccountAuthenticationProvider().name)), + shimmer: true, }); break; case ChatSetupStep.Installing: progress({ kind: 'progressMessage', - content: new MarkdownString(localize('installingChat', "Getting chat ready...")), + content: new MarkdownString(localize('installingChat', "Getting chat ready")), + shimmer: true, }); break; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts index d3cd2bd310e9a..d499fda339e62 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts @@ -23,7 +23,7 @@ import { getFlatContextMenuActions } from '../../../../../../platform/actions/br import { Action2, IMenuService, MenuId, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; import { IClipboardService } from '../../../../../../platform/clipboard/common/clipboardService.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; -import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../../../platform/contextview/browser/contextView.js'; import { IResourceStat } from '../../../../../../platform/dnd/browser/dnd.js'; import { ITextResourceEditorInput } from '../../../../../../platform/editor/common/editor.js'; @@ -125,8 +125,6 @@ export class InlineAnchorWidget extends Disposable { public static readonly className = 'chat-inline-anchor-widget'; - private readonly _chatResourceContext: IContextKey; - readonly data: ContentRefData; constructor( @@ -158,9 +156,6 @@ export class InlineAnchorWidget extends Disposable { ? { kind: 'symbol', symbol: inlineReference.inlineReference } : { uri: inlineReference.inlineReference }; - const contextKeyService = this._register(originalContextKeyService.createScoped(element)); - this._chatResourceContext = chatAttachmentResourceContextKey.bindTo(contextKeyService); - element.classList.add(InlineAnchorWidget.className, 'show-file-icons'); let iconText: Array; @@ -168,7 +163,6 @@ export class InlineAnchorWidget extends Disposable { let location: { readonly uri: URI; readonly range?: IRange }; - let updateContextKeys: (() => Promise) | undefined; if (this.data.kind === 'symbol') { const symbol = this.data.symbol; @@ -176,7 +170,7 @@ export class InlineAnchorWidget extends Disposable { iconText = [this.data.symbol.name]; iconClasses = ['codicon', ...getIconClasses(modelService, languageService, undefined, undefined, SymbolKinds.toIcon(symbol.kind))]; - this._store.add(instantiationService.invokeFunction(accessor => hookUpSymbolAttachmentDragAndContextMenu(accessor, element, contextKeyService, { value: symbol.location, name: symbol.name, kind: symbol.kind }, MenuId.ChatInlineSymbolAnchorContext))); + this._store.add(instantiationService.invokeFunction(accessor => hookUpSymbolAttachmentDragAndContextMenu(accessor, element, originalContextKeyService, { value: symbol.location, name: symbol.name, kind: symbol.kind }, MenuId.ChatInlineSymbolAnchorContext))); } else { location = this.data; @@ -209,10 +203,10 @@ export class InlineAnchorWidget extends Disposable { refreshIconClasses(); })); - const isFolderContext = ExplorerFolderContext.bindTo(contextKeyService); + let isDirectory = false; fileService.stat(location.uri) .then(stat => { - isFolderContext.set(stat.isDirectory); + isDirectory = stat.isDirectory; if (stat.isDirectory) { fileKind = FileKind.FOLDER; refreshIconClasses(); @@ -221,15 +215,20 @@ export class InlineAnchorWidget extends Disposable { .catch(() => { }); // Context menu + const contextKeyService = this._register(originalContextKeyService.createScoped(element)); + chatAttachmentResourceContextKey.bindTo(contextKeyService).set(location.uri.toString()); + const isFolderContext = ExplorerFolderContext.bindTo(contextKeyService); + let contextMenuInitialized = false; this._register(dom.addDisposableListener(element, dom.EventType.CONTEXT_MENU, async domEvent => { const event = new StandardMouseEvent(dom.getWindow(domEvent), domEvent); dom.EventHelper.stop(domEvent, true); - try { - await updateContextKeys?.(); - } catch (e) { - console.error(e); + if (!contextMenuInitialized) { + contextMenuInitialized = true; + const resourceContextKey = new StaticResourceContextKey(contextKeyService, fileService, languageService, modelService); + resourceContextKey.set(location.uri); } + isFolderContext.set(isDirectory); if (this._store.isDisposed) { return; @@ -255,10 +254,6 @@ export class InlineAnchorWidget extends Disposable { } } - const resourceContextKey = new StaticResourceContextKey(contextKeyService, fileService, languageService, modelService); - resourceContextKey.set(location.uri); - this._chatResourceContext.set(location.uri.toString()); - const iconEl = dom.$('span.icon'); iconEl.classList.add(...iconClasses); element.replaceChildren(iconEl, dom.$('span.icon-label', {}, ...iconText)); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts index cd6169b793e09..12dbdf346e3b2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownDecorationsRenderer.ts @@ -260,6 +260,44 @@ export class ChatMarkdownDecorationsRenderer { return container; } + /** + * Renders a parsed chat request as plain text DOM elements with inline decoration spans + * for agents, slash commands, and other special parts. No markdown rendering is used. + */ + renderParsedRequestToPlainText(parsedRequest: IParsedChatRequest, store: DisposableStore): HTMLElement { + const container = dom.$('span'); + container.style.whiteSpace = 'pre-wrap'; + for (const part of parsedRequest.parts) { + if (part instanceof ChatRequestTextPart) { + container.appendChild(document.createTextNode(part.text)); + } else if (part instanceof ChatRequestAgentPart) { + const widget = this.renderResourceWidget(`${chatAgentLeader}${part.agent.name}`, undefined, store); + const hover: Lazy = new Lazy(() => store.add(this.instantiationService.createInstance(ChatAgentHover))); + store.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), widget, () => { + hover.value.setAgent(part.agent.id); + return hover.value.domNode; + }, getChatAgentHoverOptions(() => part.agent, this.commandService))); + container.appendChild(widget); + } else { + const title = this.getDecorationTitle(part); + const widget = this.renderResourceWidget(part.text, title ? { title } : undefined, store); + container.appendChild(widget); + } + } + return container; + } + + private getDecorationTitle(part: IParsedChatRequestPart): string | undefined { + const uri = part instanceof ChatRequestDynamicVariablePart && part.data instanceof URI ? + part.data : + undefined; + return uri ? this.labelService.getUriLabel(uri, { relative: true }) : + part instanceof ChatRequestSlashCommandPart ? part.slashCommand.detail : + part instanceof ChatRequestAgentSubcommandPart ? part.command.description : + part instanceof ChatRequestSlashPromptPart ? part.name : + part instanceof ChatRequestToolPart ? (this.toolsService.getTool(part.toolId)?.userDescription) : + undefined; + } private injectKeybindingHint(a: HTMLAnchorElement, href: string, keybindingService: IKeybindingService): void { const command = href.match(/command:([^\)]+)/)?.[1]; 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 3e7c9c9166709..101b84d981099 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersInteractionContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersInteractionContentPart.ts @@ -138,6 +138,7 @@ export class ChatMcpServersInteractionContentPart extends Disposable implements true, // forceShowMessage undefined, // icon undefined, // toolInvocation + false, // no shimmer for now )); this.domNode.appendChild(this.workingProgressPart.domNode); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts index 28d1f62c1c1a8..03f160cdd7296 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts @@ -7,7 +7,7 @@ import { $, append } from '../../../../../../base/browser/dom.js'; import { alert } from '../../../../../../base/browser/ui/aria/aria.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { MarkdownString, type IMarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; import { IRenderedMarkdown } from '../../../../../../base/browser/markdownRenderer.js'; @@ -33,6 +33,7 @@ export class ChatProgressContentPart extends Disposable implements IChatContentP private readonly showSpinner: boolean; private readonly isHidden: boolean; private readonly renderedMessage = this._register(new MutableDisposable()); + private readonly _fileWidgetStore = this._register(new DisposableStore()); private currentContent: IMarkdownString; constructor( @@ -43,6 +44,7 @@ export class ChatProgressContentPart extends Disposable implements IChatContentP forceShowMessage: boolean | undefined, icon: ThemeIcon | undefined, private readonly toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized | undefined, + shimmer: boolean | undefined, @IInstantiationService private readonly instantiationService: IInstantiationService, @IChatMarkdownAnchorService private readonly chatMarkdownAnchorService: IChatMarkdownAnchorService, @IConfigurationService private readonly configurationService: IConfigurationService @@ -64,14 +66,20 @@ export class ChatProgressContentPart extends Disposable implements IChatContentP // this step is in progress, communicate it to SR users alert(progress.content.value); } - const codicon = icon ? icon : this.showSpinner ? ThemeIcon.modify(Codicon.loading, 'spin') : Codicon.check; + const isLoadingIcon = icon && ThemeIcon.isEqual(icon, ThemeIcon.modify(Codicon.loading, 'spin')); + const useShimmer = shimmer ?? ((!icon || isLoadingIcon) && this.showSpinner); + // if we have shimmer, don't show spinner + const codicon = useShimmer ? Codicon.check : (icon ?? (this.showSpinner ? ThemeIcon.modify(Codicon.loading, 'spin') : Codicon.check)); const result = this.chatContentMarkdownRenderer.render(progress.content); result.element.classList.add('progress-step'); - renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._store); + renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._fileWidgetStore); const tooltip: IMarkdownString | undefined = this.createApprovalMessage(); const progressPart = this._register(instantiationService.createInstance(ChatProgressSubPart, result.element, codicon, tooltip)); this.domNode = progressPart.domNode; + if (useShimmer) { + this.domNode.classList.add('shimmer-progress'); + } this.renderedMessage.value = result; } @@ -83,7 +91,8 @@ export class ChatProgressContentPart extends Disposable implements IChatContentP // Render the new message const result = this._register(this.chatContentMarkdownRenderer.render(content)); result.element.classList.add('progress-step'); - renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._store); + this._fileWidgetStore.clear(); + renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._fileWidgetStore); // Replace the old message container with the new one if (this.renderedMessage.value) { @@ -162,10 +171,9 @@ export class ChatWorkingProgressContentPart extends ChatProgressContentPart impl ) { const progressMessage: IChatProgressMessage = { kind: 'progressMessage', - content: new MarkdownString().appendText(localize('workingMessage', "Working...")) + content: new MarkdownString().appendText(localize('workingMessage', "Working")) }; - super(progressMessage, chatContentMarkdownRenderer, context, undefined, undefined, undefined, undefined, instantiationService, chatMarkdownAnchorService, configurationService); - this.domNode.classList.add('working-progress'); + super(progressMessage, chatContentMarkdownRenderer, context, undefined, undefined, undefined, undefined, true, instantiationService, chatMarkdownAnchorService, configurationService); this._register(languageModelToolsService.onDidPrepareToolCallBecomeUnresponsive(e => { if (isEqual(context.element.sessionResource, e.sessionResource)) { this.updateMessage(new MarkdownString(localize('toolCallUnresponsive', "Waiting for tool '{0}' to respond...", e.toolData.displayName))); 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 5e03ac8c2479b..fc8c59c8d8699 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -11,7 +11,6 @@ import { Lazy } from '../../../../../../base/common/lazy.js'; import { IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { autorun } from '../../../../../../base/common/observable.js'; import { rcut } from '../../../../../../base/common/strings.js'; -import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { localize } from '../../../../../../nls.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -25,11 +24,12 @@ import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; import { ChatCollapsibleMarkdownContentPart } from './chatCollapsibleMarkdownContentPart.js'; import { EditorPool } from './chatContentCodePools.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; +import { renderFileWidgets } from './chatInlineAnchorWidget.js'; import { IChatMarkdownAnchorService } from './chatMarkdownAnchorService.js'; import { CollapsibleListPool } from './chatReferencesContentPart.js'; import { createThinkingIcon, getToolInvocationIcon } from './chatThinkingContentPart.js'; -import './media/chatSubagentContent.css'; import { ChatToolInvocationPart } from './toolInvocationParts/chatToolInvocationPart.js'; +import './media/chatSubagentContent.css'; const MAX_TITLE_LENGTH = 100; @@ -97,6 +97,10 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen private userManuallyExpanded: boolean = false; private autoExpandedForConfirmation: boolean = false; + // Persistent title elements for shimmer + private titleShimmerSpan: HTMLElement | undefined; + private titleDetailContainer: HTMLElement | undefined; + /** * Check if a tool invocation is the parent subagent tool (the tool that spawns a subagent). * A parent subagent tool has subagent toolSpecificData but no subAgentInvocationId. @@ -174,17 +178,30 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen const node = this.domNode; node.classList.add('chat-thinking-box', 'chat-thinking-fixed-mode', 'chat-subagent-part'); + if (!this.element.isComplete) { + node.classList.add('chat-thinking-active'); + } + + // Apply shimmer to the initial title when still active + if (!this.element.isComplete && this._collapseButton) { + const labelElement = this._collapseButton.labelElement; + labelElement.textContent = ''; + this.titleShimmerSpan = $('span.chat-thinking-title-shimmer'); + this.titleShimmerSpan.textContent = initialTitle; + labelElement.appendChild(this.titleShimmerSpan); + } + // Note: wrapper is created lazily in initContent(), so we can't set its style here if (this._collapseButton && !this.element.isComplete) { - this._collapseButton.icon = ThemeIcon.modify(Codicon.loading, 'spin'); + this._collapseButton.icon = Codicon.circleFilled; } this._register(autorun(r => { this.expanded.read(r); if (this._collapseButton) { if (!this.element.isComplete && this.isActive) { - this._collapseButton.icon = ThemeIcon.modify(Codicon.loading, 'spin'); + this._collapseButton.icon = Codicon.circleFilled; } else { this._collapseButton.icon = Codicon.check; } @@ -331,6 +348,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen public markAsInactive(): void { this.isActive = false; + this.domNode.classList.remove('chat-thinking-active'); if (this._collapseButton) { this._collapseButton.icon = Codicon.check; } @@ -348,11 +366,40 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen private updateTitle(): void { const prefix = this.agentName || localize('chat.subagent.prefix', 'Subagent'); - let finalLabel = `${prefix}: ${this.description}`; + const shimmerText = `${prefix}: `; + let detailText = this.description; if (this.currentRunningToolMessage && this.isActive) { - finalLabel += ` \u2014 ${this.currentRunningToolMessage}`; + detailText += ` \u2014 ${this.currentRunningToolMessage}`; + } + + if (!this._collapseButton) { + return; } - this.setTitleWithWidgets(new MarkdownString(finalLabel), this.instantiationService, this.chatMarkdownAnchorService, this.chatContentMarkdownRenderer); + + const labelElement = this._collapseButton.labelElement; + + // Ensure the persistent shimmer span exists + if (!this.titleShimmerSpan || !this.titleShimmerSpan.parentElement) { + labelElement.textContent = ''; + this.titleShimmerSpan = $('span.chat-thinking-title-shimmer'); + labelElement.appendChild(this.titleShimmerSpan); + } + this.titleShimmerSpan.textContent = shimmerText; + + const result = this.chatContentMarkdownRenderer.render(new MarkdownString(detailText)); + result.element.classList.add('collapsible-title-content', 'chat-thinking-title-detail'); + renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._store); + + if (this.titleDetailContainer) { + this.titleDetailContainer.replaceWith(result.element); + } else { + labelElement.appendChild(result.element); + } + this.titleDetailContainer = result.element; + + const fullLabel = `${shimmerText}${detailText}`; + this._collapseButton.element.ariaLabel = fullLabel; + this._collapseButton.element.ariaExpanded = String(this.isExpanded()); } private updateHover(): void { 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 b112e4bd61678..fb8a8b4569d11 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTaskContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTaskContentPart.ts @@ -38,7 +38,7 @@ export class ChatTaskContentPart extends Disposable implements IChatContentPart true; this.isSettled = isSettled; const showSpinner = !isSettled && !context.element.isComplete; - const progressPart = this._register(instantiationService.createInstance(ChatProgressContentPart, task, chatContentMarkdownRenderer, context, showSpinner, true, undefined, undefined)); + const progressPart = this._register(instantiationService.createInstance(ChatProgressContentPart, task, chatContentMarkdownRenderer, context, showSpinner, true, undefined, undefined, undefined)); this.domNode = progressPart.domNode; } } 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 e3ddd1312adbe..673b0b5d0a002 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -20,6 +20,7 @@ import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/m import { extractCodeblockUrisFromText } from '../../../common/widget/annotations.js'; import { basename } from '../../../../../../base/common/resources.js'; import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; +import { renderFileWidgets } from './chatInlineAnchorWidget.js'; import { localize } from '../../../../../../nls.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; @@ -115,25 +116,25 @@ const enum WorkingMessageCategory { } const thinkingMessages = [ - localize('chat.thinking.thinking.1', 'Thinking...'), - localize('chat.thinking.thinking.2', 'Reasoning...'), - localize('chat.thinking.thinking.3', 'Considering...'), - localize('chat.thinking.thinking.4', 'Analyzing...'), - localize('chat.thinking.thinking.5', 'Evaluating...'), + localize('chat.thinking.thinking.1', 'Thinking'), + localize('chat.thinking.thinking.2', 'Reasoning'), + localize('chat.thinking.thinking.3', 'Considering'), + localize('chat.thinking.thinking.4', 'Analyzing'), + localize('chat.thinking.thinking.5', 'Evaluating'), ]; const terminalMessages = [ - localize('chat.thinking.terminal.1', 'Executing...'), - localize('chat.thinking.terminal.2', 'Running...'), - localize('chat.thinking.terminal.3', 'Processing...'), + localize('chat.thinking.terminal.1', 'Executing'), + localize('chat.thinking.terminal.2', 'Running'), + localize('chat.thinking.terminal.3', 'Processing'), ]; const toolMessages = [ - localize('chat.thinking.tool.1', 'Processing...'), - localize('chat.thinking.tool.2', 'Preparing...'), - localize('chat.thinking.tool.3', 'Loading...'), - localize('chat.thinking.tool.4', 'Analyzing...'), - localize('chat.thinking.tool.5', 'Evaluating...'), + localize('chat.thinking.tool.1', 'Processing'), + localize('chat.thinking.tool.2', 'Preparing'), + localize('chat.thinking.tool.3', 'Loading'), + localize('chat.thinking.tool.4', 'Analyzing'), + localize('chat.thinking.tool.5', 'Evaluating'), ]; export class ChatThinkingContentPart extends ChatCollapsibleContentPart implements IChatContentPart { @@ -146,7 +147,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen private content: IChatThinkingPart; private currentThinkingValue: string; private currentTitle: string; - private defaultTitle = localize('chat.thinking.header', 'Working...'); + private defaultTitle = localize('chat.thinking.header', 'Working'); private textContainer!: HTMLElement; private markdownResult: IRenderedMarkdown | undefined; private wrapper!: HTMLElement; @@ -171,6 +172,9 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen private pendingScrollDisposable: IDisposable | undefined; private mutationObserverDisposable: IDisposable | undefined; private isUpdatingDimensions: boolean = false; + private titleShimmerSpan: HTMLElement | undefined; + private titleDetailContainer: HTMLElement | undefined; + private titleDetailRendered: IRenderedMarkdown | undefined; private getRandomWorkingMessage(category: WorkingMessageCategory = WorkingMessageCategory.Tool): string { let pool = this.availableMessagesByCategory.get(category); @@ -206,7 +210,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen ) { const initialText = extractTextFromPart(content); const extractedTitle = extractTitleFromThinkingContent(initialText) - ?? 'Working...'; + ?? 'Working'; super(extractedTitle, context, undefined, hoverService); @@ -244,6 +248,20 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen const node = this.domNode; node.classList.add('chat-thinking-box'); + if (!this.streamingCompleted && !this.element.isComplete) { + if (!this.fixedScrollingMode) { + node.classList.add('chat-thinking-active'); + } + } + + if (!this.fixedScrollingMode && !this.streamingCompleted && !this.element.isComplete && this._collapseButton) { + const labelElement = this._collapseButton.labelElement; + labelElement.textContent = ''; + this.titleShimmerSpan = $('span.chat-thinking-title-shimmer'); + this.titleShimmerSpan.textContent = extractedTitle; + labelElement.appendChild(this.titleShimmerSpan); + } + if (this.fixedScrollingMode) { node.classList.add('chat-thinking-fixed-mode'); this.currentTitle = this.defaultTitle; @@ -259,20 +277,28 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen if (isExpanded) { this._collapseButton.icon = Codicon.chevronDown; } else { - this._collapseButton.icon = ThemeIcon.modify(Codicon.loading, 'spin'); + this._collapseButton.icon = Codicon.circleFilled; } } } })); this._register(autorun(r => { + const isExpanded = this._isExpanded.read(r); // Materialize lazy items when first expanded - if (this._isExpanded.read(r) && !this.hasExpandedOnce && this.lazyItems.length > 0) { + if (isExpanded && !this.hasExpandedOnce && this.lazyItems.length > 0) { this.hasExpandedOnce = true; for (const item of this.lazyItems) { this.materializeLazyItem(item); } } + + // If expanded but content matches title and there's nothing else to show, revert immediately + if (isExpanded && !this.shouldAllowExpansion()) { + this.setExpanded(false); + return; + } + // Fire when expanded/collapsed this._onDidChangeHeight.fire(); })); @@ -290,12 +316,17 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen const expanded = this.isExpanded(); if (expanded) { + // Just expanded: show plain 'Working' with no detail this.setTitle(this.defaultTitle, true); this.currentTitle = this.defaultTitle; - } else if (this.lastExtractedTitle) { - const collapsedLabel = this.lastExtractedTitle ?? ''; - this.setTitle(collapsedLabel); - this.currentTitle = collapsedLabel; + } else { + // Just collapsed: show latest tool/thinking title with 'Working:' prefix + if (this.lastExtractedTitle) { + this.setTitle(this.lastExtractedTitle); + } else { + this.setTitle(this.defaultTitle, true); + this.currentTitle = this.defaultTitle; + } } })); } @@ -325,7 +356,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen // Create the persistent working spinner element only if still streaming if (!this.streamingCompleted && !this.element.isComplete) { this.workingSpinnerElement = $('.chat-thinking-item.chat-thinking-spinner-item'); - const spinnerIcon = createThinkingIcon(ThemeIcon.modify(Codicon.loading, 'spin')); + const spinnerIcon = createThinkingIcon(Codicon.circleFilled); this.workingSpinnerElement.appendChild(spinnerIcon); this.workingSpinnerLabel = $('span.chat-thinking-spinner-label'); this.workingSpinnerLabel.textContent = this.getRandomWorkingMessage(WorkingMessageCategory.Thinking); @@ -533,14 +564,18 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen } } - private updateDropdownClickability(): void { - if (!this.wrapper) { - return; + private shouldAllowExpansion(): boolean { + // Multiple tool invocations or lazy items mean there's content to show + if (this.toolInvocationCount > 0 || this.lazyItems.length > 0) { + return true; } - if (this.wrapper.children.length > 1 || this.toolInvocationCount > 0 || this.lazyItems.length > 0) { - this.setDropdownClickable(true); - return; + // Count meaningful children in the wrapper (exclude the working spinner) + if (this.wrapper) { + const meaningfulChildren = Array.from(this.wrapper.children).filter(child => child !== this.workingSpinnerElement).length; + if (meaningfulChildren > 1) { + return true; + } } const contentWithoutTitle = this.currentThinkingValue.trim(); @@ -552,8 +587,16 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen }; const strippedContent = stripMarkdown(contentWithoutTitle); - const shouldDisable = !strippedContent || strippedContent === titleToCompare; - this.setDropdownClickable(!shouldDisable); + // If content is empty or matches the title exactly, nothing to expand + return !(!strippedContent || strippedContent === titleToCompare); + } + + private updateDropdownClickability(): void { + const allowExpansion = this.shouldAllowExpansion(); + if (!allowExpansion && this.isExpanded()) { + this.setExpanded(false); + } + this.setDropdownClickable(allowExpansion); } private appendToWrapper(element: HTMLElement): void { @@ -631,6 +674,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen public markAsInactive(): void { this.isActive = false; + this.domNode.classList.remove('chat-thinking-active'); this.processPendingRemovals(); if (this.workingSpinnerElement) { this.workingSpinnerElement.remove(); @@ -646,6 +690,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen if (this.wrapper) { this.wrapper.classList.remove('chat-thinking-streaming'); } + this.domNode.classList.remove('chat-thinking-active'); this.streamingCompleted = true; if (this.mutationObserverDisposable) { @@ -917,6 +962,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen if (this.wrapper) { this.wrapper.classList.remove('chat-thinking-streaming'); } + this.domNode.classList.remove('chat-thinking-active'); this.streamingCompleted = true; if (this._collapseButton) { @@ -1318,18 +1364,67 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen } if (omitPrefix) { - this.setTitleWithWidgets(new MarkdownString(title), this.instantiationService, this.chatMarkdownAnchorService, this.chatContentMarkdownRenderer); + if (this._collapseButton) { + const labelElement = this._collapseButton.labelElement; + labelElement.textContent = ''; + const plainSpan = $('span'); + plainSpan.textContent = title; + labelElement.appendChild(plainSpan); + this._collapseButton.element.ariaLabel = title; + } + this.titleShimmerSpan = undefined; + this.titleDetailContainer = undefined; + if (this.titleDetailRendered) { + this.titleDetailRendered.dispose(); + this.titleDetailRendered = undefined; + } this.currentTitle = title; return; } - const thinkingLabel = `Working: ${title}`; + this.lastExtractedTitle = title; + const thinkingLabel = `Working: ${title}`; this.currentTitle = thinkingLabel; - this.setTitleWithWidgets(new MarkdownString(thinkingLabel), this.instantiationService, this.chatMarkdownAnchorService, this.chatContentMarkdownRenderer); + + if (!this._collapseButton) { + return; + } + + const labelElement = this._collapseButton.labelElement; + + // Ensure the persistent shimmer span exists + if (!this.titleShimmerSpan || !this.titleShimmerSpan.parentElement) { + labelElement.textContent = ''; + this.titleShimmerSpan = $('span.chat-thinking-title-shimmer'); + labelElement.appendChild(this.titleShimmerSpan); + } + this.titleShimmerSpan.textContent = 'Working: '; + + // Dispose previous detail rendering + if (this.titleDetailRendered) { + this.titleDetailRendered.dispose(); + this.titleDetailRendered = undefined; + } + + const result = this.chatContentMarkdownRenderer.render(new MarkdownString(title)); + result.element.classList.add('collapsible-title-content', 'chat-thinking-title-detail'); + renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._store); + this.titleDetailRendered = result; + + if (this.titleDetailContainer) { + // Replace old detail in-place + this.titleDetailContainer.replaceWith(result.element); + } else { + labelElement.appendChild(result.element); + } + this.titleDetailContainer = result.element; + + this._collapseButton.element.ariaLabel = thinkingLabel; + this._collapseButton.element.ariaExpanded = String(this.isExpanded()); } hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], _element: ChatTreeItem): boolean { - if (other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized' || other.kind === 'markdownContent') { + if (other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized' || other.kind === 'markdownContent' || other.kind === 'hook') { return true; } @@ -1345,6 +1440,10 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.markdownResult.dispose(); this.markdownResult = undefined; } + if (this.titleDetailRendered) { + this.titleDetailRendered.dispose(); + this.titleDetailRendered = undefined; + } if (this.workingSpinnerElement) { this.workingSpinnerElement.remove(); this.workingSpinnerElement = undefined; 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 b34ce7a6cd494..553704389064c 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 @@ -32,6 +32,35 @@ .rendered-markdown.collapsible-title-content { line-height: unset; } + + .rendered-markdown.chat-thinking-title-detail { + display: inline; + + > p { + display: inline; + } + } + } + + &.chat-thinking-active > .chat-used-context-label .monaco-button.monaco-icon-button { + + .codicon.codicon-circle-filled { + display: none; + } + + .chat-thinking-title-shimmer { + background: linear-gradient(90deg, + var(--vscode-descriptionForeground) 0%, + var(--vscode-descriptionForeground) 30%, + var(--vscode-chat-thinkingShimmer) 50%, + var(--vscode-descriptionForeground) 70%, + var(--vscode-descriptionForeground) 100%); + background-size: 400% 100%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: chat-thinking-shimmer 2s linear infinite; + } } /* shimmer animation stuffs */ diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts index 96c8fd5f7c780..4742a98d3d952 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts @@ -105,7 +105,8 @@ export class ChatToolProgressSubPart extends BaseChatToolInvocationSubPart { this.provideScreenReaderStatus(content); } - return this.instantiationService.createInstance(ChatProgressContentPart, progressMessage, this.renderer, this.context, undefined, true, this.getIcon(), this.toolInvocation); + const isAskQuestionsTool = this.toolInvocation.toolId === 'copilot_askQuestions'; + return this.instantiationService.createInstance(ChatProgressContentPart, progressMessage, this.renderer, this.context, undefined, true, this.getIcon(), this.toolInvocation, isAskQuestionsTool ? undefined : false); } private getAnnouncementKey(kind: 'progress' | 'complete'): string { 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 3e3e3cc12d050..ae3d2c18d6147 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 @@ -86,7 +86,8 @@ export class ChatToolStreamingSubPart extends BaseChatToolInvocationSubPart { undefined, true, this.getIcon(), - toolInvocation + toolInvocation, + false )); dom.reset(container, part.domNode); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 47d1d634c6c47..8fb98c9147f1e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -1169,8 +1169,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer, contentForThisTurn: ReadonlyArray, element: IChatResponseViewModel, elementIndex: number, templateData: IChatListItemTemplate): void { const renderedParts = templateData.renderedParts ?? []; templateData.renderedParts = renderedParts; + const lastMarkdownIndex = partsToRender.findLastIndex(part => part?.kind === 'markdownContent'); partsToRender.forEach((partToRender, contentIndex) => { const alreadyRenderedPart = templateData.renderedParts?.[contentIndex]; + const isFinalAnswerPart = partToRender?.kind === 'markdownContent' && contentIndex === lastMarkdownIndex && element.isComplete; if (!partToRender) { // null=no change @@ -1188,7 +1190,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer, contentToRender: ReadonlyArray, element: ChatTreeItem): ReadonlyArray { const diff: (IChatRendererContent | null)[] = []; + const elementIsComplete = isResponseVM(element) && element.isComplete; + const lastMarkdownContentIndex = contentToRender.findLastIndex(part => part.kind === 'markdownContent'); for (let i = 0; i < contentToRender.length; i++) { const content = contentToRender[i]; const renderedPart = renderedParts[i]; + const isFinalAnswerPart = content.kind === 'markdownContent' && i === lastMarkdownContentIndex && elementIsComplete; + + if (isFinalAnswerPart && this.isRenderedPartInsideThinking(renderedPart)) { + diff.push(content); + continue; + } if (!renderedPart || !renderedPart.hasSameContent(content, contentToRender.slice(i + 1), element)) { diff.push(content); @@ -1394,6 +1404,13 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer c.kind === 'thinking' || c.kind === 'toolInvocation' || c.kind === 'toolInvocationSerialized') : -1; + const isFinalAnswerPart = isFinalRenderPass && context.contentIndex > lastPinnedPartIndex; if (!this.hasCodeblockUri(markdown) || isFinalAnswerPart) { this.finalizeCurrentThinkingPart(context, templateData); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidgetService.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidgetService.ts index 9e5ce02f368c8..136fe05787f7a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidgetService.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidgetService.ts @@ -36,6 +36,9 @@ export class ChatWidgetService extends Disposable implements IChatWidgetService private readonly _onDidChangeFocusedWidget = this._register(new Emitter()); readonly onDidChangeFocusedWidget = this._onDidChangeFocusedWidget.event; + private readonly _onDidChangeFocusedSession = this._register(new Emitter()); + readonly onDidChangeFocusedSession = this._onDidChangeFocusedSession.event; + constructor( @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, @IViewsService private readonly viewsService: IViewsService, @@ -222,6 +225,7 @@ export class ChatWidgetService extends Disposable implements IChatWidgetService this._lastFocusedWidget = widget; this._onDidChangeFocusedWidget.fire(widget); + this._onDidChangeFocusedSession.fire(); } register(newWidget: IChatWidget): IDisposable { @@ -239,6 +243,10 @@ export class ChatWidgetService extends Disposable implements IChatWidgetService return combinedDisposable( newWidget.onDidFocus(() => this.setLastFocusedWidget(newWidget)), newWidget.onDidChangeViewModel(({ previousSessionResource, currentSessionResource }) => { + if (this._lastFocusedWidget === newWidget && !isEqual(previousSessionResource, currentSessionResource)) { + this._onDidChangeFocusedSession.fire(); + } + if (!previousSessionResource || (currentSessionResource && isEqual(previousSessionResource, currentSessionResource))) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index f4ed42bfbc577..cc4fd8795ebca 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -353,6 +353,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private chatSessionOptionsValid: IContextKey; private agentSessionTypeKey: IContextKey; private chatSessionHasCustomAgentTarget: IContextKey; + private chatSessionHasTargetedModels: IContextKey; private modelWidget: ModelPickerActionItem | undefined; private modeWidget: ModePickerActionItem | undefined; private sessionTargetWidget: SessionTypePickerActionItem | undefined; @@ -473,6 +474,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private _emptyInputState: ObservableMemento; private _chatSessionIsEmpty = false; private _pendingDelegationTarget: AgentSessionProviders | undefined = undefined; + private _currentSessionType: string | undefined = undefined; constructor( // private readonly editorOptions: ChatEditorOptions, // TODO this should be used @@ -576,6 +578,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } this.chatSessionHasCustomAgentTarget = ChatContextKeys.chatSessionHasCustomAgentTarget.bindTo(contextKeyService); + this.chatSessionHasTargetedModels = ChatContextKeys.chatSessionHasTargetedModels.bindTo(contextKeyService); this.history = this._register(this.instantiationService.createInstance(ChatHistoryNavigator, this.location)); @@ -618,8 +621,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // We've changed models and the current one is no longer available. Select a new one const selectedModel = this._currentLanguageModel ? this.getModels().find(m => m.identifier === this._currentLanguageModel.get()?.identifier) : undefined; - const selectedModelNotAvailable = this._currentLanguageModel && (!selectedModel?.metadata.isUserSelectable); - if (!this.currentLanguageModel || selectedModelNotAvailable) { + if (!this.currentLanguageModel || !selectedModel) { this.setCurrentLanguageModelToDefault(); } })); @@ -680,10 +682,18 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } private getSelectedModelStorageKey(): string { + const sessionType = this._currentSessionType; + if (sessionType && this.hasModelsTargetingSessionType()) { + return `chat.currentLanguageModel.${this.location}.${sessionType}`; + } return `chat.currentLanguageModel.${this.location}`; } private getSelectedModelIsDefaultStorageKey(): string { + const sessionType = this._currentSessionType; + if (sessionType && this.hasModelsTargetingSessionType()) { + return `chat.currentLanguageModel.${this.location}.${sessionType}.isDefault`; + } return `chat.currentLanguageModel.${this.location}.isDefault`; } @@ -922,11 +932,16 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } - // Sync selected model + // Sync selected model - validate it belongs to the current session's model pool if (state?.selectedModel) { const lm = this._currentLanguageModel.get(); if (!lm || lm.identifier !== state.selectedModel.identifier) { - this.setCurrentLanguageModel(state.selectedModel); + if (this.isModelValidForCurrentSession(state.selectedModel)) { + this.setCurrentLanguageModel(state.selectedModel); + } else { + // Model from state doesn't belong to this session's pool - use default + this.setCurrentLanguageModelToDefault(); + } } } @@ -992,7 +1007,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private checkModelSupported(): void { const lm = this._currentLanguageModel.get(); - if (lm && (!this.modelSupportedForDefaultAgent(lm) || !this.modelSupportedForInlineChat(lm))) { + if (lm && (!this.modelSupportedForDefaultAgent(lm) || !this.modelSupportedForInlineChat(lm) || !this.isModelValidForCurrentSession(lm))) { this.setCurrentLanguageModelToDefault(); } } @@ -1049,12 +1064,79 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.storageService.store(CachedLanguageModelsKey, models, StorageScope.APPLICATION, StorageTarget.MACHINE); } models.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name)); - return models.filter(entry => entry.metadata?.isUserSelectable && this.modelSupportedForDefaultAgent(entry) && this.modelSupportedForInlineChat(entry)); + + const sessionType = this.getCurrentSessionType(); + if (sessionType) { + // Session has a specific chat session type - show only models that target + // this session type, if any such models exist. + const targeted = models.filter(entry => entry.metadata?.targetChatSessionType === sessionType); + if (targeted.length > 0) { + return targeted; + } + } + + // No session type or no targeted models - show general models (those without + // a targetChatSessionType) filtered by the standard criteria. + return models.filter(entry => !entry.metadata?.targetChatSessionType && entry.metadata?.isUserSelectable && this.modelSupportedForDefaultAgent(entry) && this.modelSupportedForInlineChat(entry)); + } + + /** + * Get the chat session type for the current session, if any. + * Uses the delegate or `getChatSessionFromInternalUri` to determine the session type. + */ + private getCurrentSessionType(): string | undefined { + const delegateSessionType = this.options.sessionTypePickerDelegate?.getActiveSessionProvider?.(); + if (delegateSessionType) { + return delegateSessionType; + } + const sessionResource = this._widget?.viewModel?.model.sessionResource; + const ctx = sessionResource ? this.chatService.getChatSessionFromInternalUri(sessionResource) : undefined; + return ctx?.chatSessionType; + } + + /** + * Check if any registered models target the current session type. + * This is used to set the context key that controls model picker visibility. + */ + private hasModelsTargetingSessionType(): boolean { + const sessionType = this.getCurrentSessionType(); + if (!sessionType) { + return false; + } + return this.languageModelsService.getLanguageModelIds().some(modelId => { + const metadata = this.languageModelsService.lookupLanguageModel(modelId); + return metadata?.targetChatSessionType === sessionType; + }); + } + + /** + * Check if a model is valid for the current session's model pool. + * If the session has targeted models, the model must target this session type. + * If no models target this session, the model must not have a targetChatSessionType. + */ + private isModelValidForCurrentSession(model: ILanguageModelChatMetadataAndIdentifier): boolean { + if (this.hasModelsTargetingSessionType()) { + // Session has targeted models - model must match + return model.metadata.targetChatSessionType === this.getCurrentSessionType(); + } + // No targeted models - model must not be session-specific + return !model.metadata.targetChatSessionType; + } + + /** + * Validate that the current model belongs to the current session's pool. + * Called when switching sessions to prevent cross-contamination. + */ + private checkModelInSessionPool(): void { + const lm = this._currentLanguageModel.get(); + if (lm && !this.isModelValidForCurrentSession(lm)) { + this.setCurrentLanguageModelToDefault(); + } } private setCurrentLanguageModelToDefault() { const allModels = this.getModels(); - const defaultModel = allModels.find(m => m.metadata.isDefaultForLocation[this.location]) || allModels.find(m => m.metadata.isUserSelectable); + const defaultModel = allModels.find(m => m.metadata.isDefaultForLocation[this.location]) || allModels[0]; if (defaultModel) { this.setCurrentLanguageModel(defaultModel); } @@ -1439,6 +1521,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const customAgentTarget = ctx && this.chatSessionsService.getCustomAgentTargetForSessionType(ctx.chatSessionType); this.chatSessionHasCustomAgentTarget.set(customAgentTarget !== Target.Undefined); + // Check if this session type requires custom models + const requiresCustomModels = ctx && this.chatSessionsService.requiresCustomModelsForSessionType(ctx.chatSessionType); + this.chatSessionHasTargetedModels.set(!!requiresCustomModels); + // Handle agent option from session - set initial mode if (customAgentTarget) { const agentOption = this.chatSessionsService.getSessionOption(ctx.chatSessionResource, agentOptionId); @@ -1761,6 +1847,18 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.tryUpdateWidgetController(); this.updateContextUsageWidget(); this.clearQuestionCarousel(); + + // Track the current session type and re-initialize model selection + // when the session type changes (different session types may have + // different model pools via targetChatSessionType). + const newSessionType = this.getCurrentSessionType(); + if (newSessionType !== this._currentSessionType) { + this._currentSessionType = newSessionType; + this.initSelectedModel(); + } + + // Validate that the current model belongs to the new session's pool + this.checkModelInSessionPool(); })); let elements; diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index ff8f0fc8f16da..c0abfa14e9703 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -457,12 +457,14 @@ } /* not ideal but I cannot query the last div with this class... */ - .rendered-markdown:last-of-type > P > SPAN:empty { + .rendered-markdown:last-of-type > P > SPAN:empty, + .rendered-markdown:last-of-type > SPAN:empty { display: inline-block; width: 11px; } - .rendered-markdown:last-of-type > P > SPAN:empty::after { + .rendered-markdown:last-of-type > P > SPAN:empty::after, + .rendered-markdown:last-of-type > SPAN:empty::after { content: ''; white-space: nowrap; overflow: hidden; @@ -2244,8 +2246,13 @@ have to be updated for changes to the rules above, or to support more deeply nes } } - /* shimmer animation for working progress only */ - &.working-progress:has(.codicon-loading) .rendered-markdown.progress-step > p { + /* hide spinner icon for shimmer progress, shimmer handles animation */ + &.shimmer-progress > .codicon { + display: none; + } + + /* shimmer animation for shimmer progress */ + &.shimmer-progress .rendered-markdown.progress-step > p { background: linear-gradient( 90deg, var(--vscode-descriptionForeground) 0%, diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index c8f138be639a8..5e45b28b9de6b 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -58,6 +58,12 @@ export namespace ChatContextKeys { * which means the mode picker should be shown with filtered custom agents. */ export const chatSessionHasCustomAgentTarget = new RawContextKey('chatSessionHasCustomAgentTarget', false, { type: 'boolean', description: localize('chatSessionHasCustomAgentTarget', "True when the chat session has a customAgentTarget defined to filter modes.") }); + /** + * True when the current chat session has models that specifically target it + * via `targetChatSessionType`, which means the model picker should be shown + * even when the widget is locked to a coding agent. + */ + export const chatSessionHasTargetedModels = new RawContextKey('chatSessionHasTargetedModels', false, { type: 'boolean', description: localize('chatSessionHasTargetedModels', "True when the chat session has language models that target it via targetChatSessionType.") }); export const agentSupportsAttachments = new RawContextKey('agentSupportsAttachments', false, { type: 'boolean', description: localize('agentSupportsAttachments', "True when the chat agent supports attachments.") }); export const withinEditSessionDiff = new RawContextKey('withinEditSessionDiff', false, { type: 'boolean', description: localize('withinEditSessionDiff', "True when the chat widget dispatches to the edit session chat.") }); export const filePartOfEditSession = new RawContextKey('filePartOfEditSession', false, { type: 'boolean', description: localize('filePartOfEditSession', "True when the chat widget is within a file with an edit session.") }); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index e244d2336b493..7e0aa5701f283 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -229,6 +229,7 @@ export class ChatMultiDiffData implements IChatMultiDiffData { export interface IChatProgressMessage { content: IMarkdownString; kind: 'progressMessage'; + shimmer?: boolean; } export interface IChatTask extends IChatTaskDto { diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 6afc7b19a9d74..989e005c0b5fb 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -85,6 +85,7 @@ export interface IChatSessionsExtensionPoint { readonly capabilities?: IChatAgentAttachmentCapabilities; readonly commands?: IChatSessionCommandContribution[]; readonly canDelegate?: boolean; + readonly isReadOnly?: boolean; /** * When set, the chat session will show a filtered mode picker with custom agents * that have a matching `target` property. This enables contributed chat sessions @@ -92,6 +93,7 @@ export interface IChatSessionsExtensionPoint { * Custom agents without a `target` property are also shown in all filtered lists */ readonly customAgentTarget?: Target; + readonly requiresCustomModels?: boolean; } export interface IChatSessionItem { @@ -273,6 +275,10 @@ export interface IChatSessionsService { */ getCustomAgentTargetForSessionType(chatSessionType: string): Target; + /** + * Returns whether the session type requires custom models. When true, the model picker should show filtered custom models. + */ + requiresCustomModelsForSessionType(chatSessionType: string): boolean; onDidChangeOptionGroups: Event; getOptionGroupsForSessionType(chatSessionType: string): IChatSessionProviderOptionGroup[] | undefined; diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index f01c341dd9a54..f77edfbc6b487 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -195,6 +195,12 @@ export interface ILanguageModelChatMetadata { readonly agentMode?: boolean; readonly editTools?: ReadonlyArray; }; + /** + * When set, this model is only shown in the model picker for the specified chat session type. + * Models with this property are excluded from the general model picker and only appear + * when the user is in a session matching this type. + */ + readonly targetChatSessionType?: string; } export namespace ILanguageModelChatMetadata { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts index 393726f0c607b..1f3400c6fc029 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts @@ -526,7 +526,7 @@ suite('ChatSubagentContentPart', () => { const button = getCollapseButton(part); assert.ok(button, 'Should have collapse button'); const loadingIcon = getCollapseButtonIcon(button); - assert.ok(loadingIcon?.classList.contains('codicon-loading'), 'Should have loading spinner while streaming'); + assert.ok(loadingIcon?.classList.contains('codicon-circle-filled'), 'Should have circle-filled icon while streaming'); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts index 81ff8e020e991..eeaac1a46c705 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts @@ -247,7 +247,7 @@ suite('ChatThinkingContentPart', () => { }); test('should start expanded when streaming (not complete)', () => { - const content = createThinkingPart('**Analyzing**'); + const content = createThinkingPart('**Analyzing**\nSome detailed reasoning about the code structure'); const context = createMockRenderContext(false); const part = store.add(instantiationService.createInstance( @@ -495,7 +495,7 @@ suite('ChatThinkingContentPart', () => { }); test('appendItem should render immediately when expanded', () => { - const content = createThinkingPart('**Working**'); + const content = createThinkingPart('**Working**\nSome detailed analysis of the problem'); const context = createMockRenderContext(false); const part = store.add(instantiationService.createInstance( @@ -984,7 +984,7 @@ suite('ChatThinkingContentPart', () => { }); test('collapseContent should collapse the part', () => { - const content = createThinkingPart('**Content**'); + const content = createThinkingPart('**Content**\nSome detailed reasoning that differs from the title'); const context = createMockRenderContext(false); // Use CollapsedPreview to start expanded @@ -1130,7 +1130,7 @@ suite('ChatThinkingContentPart', () => { }); test('should have proper aria-expanded attribute', () => { - const content = createThinkingPart('**Content**'); + const content = createThinkingPart('**Content**\nSome detailed reasoning that differs from the title'); const context = createMockRenderContext(false); const part = store.add(instantiationService.createInstance( @@ -1169,9 +1169,9 @@ suite('ChatThinkingContentPart', () => { mainWindow.document.body.appendChild(part.domNode); disposables.add(toDisposable(() => part.domNode.remove())); - // Should have loading spinner icon - const loadingIcon = part.domNode.querySelector('.codicon-loading'); - assert.ok(loadingIcon, 'Should have loading spinner while streaming'); + // Should have circle-filled icon (not loading spinner) while streaming + const circleIcon = part.domNode.querySelector('.codicon-circle-filled'); + assert.ok(circleIcon, 'Should have circle-filled icon while streaming'); }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/mockChatWidget.ts b/src/vs/workbench/contrib/chat/test/browser/widget/mockChatWidget.ts index 7e7d3336a17a1..edd6275ee86bf 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/mockChatWidget.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/mockChatWidget.ts @@ -13,6 +13,7 @@ export class MockChatWidgetService implements IChatWidgetService { readonly onDidAddWidget: Event = Event.None; readonly onDidBackgroundSession: Event = Event.None; readonly onDidChangeFocusedWidget: Event = Event.None; + readonly onDidChangeFocusedSession: Event = Event.None; readonly _serviceBrand: undefined; diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index 097866e2e4d61..932cbb036b06b 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -205,6 +205,10 @@ export class MockChatSessionsService implements IChatSessionsService { return this.contributions.find(c => c.type === chatSessionType)?.customAgentTarget ?? Target.Undefined; } + requiresCustomModelsForSessionType(chatSessionType: string): boolean { + return this.contributions.find(c => c.type === chatSessionType)?.requiresCustomModels ?? false; + } + getContentProviderSchemes(): string[] { return Array.from(this.contentProviders.keys()); } diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts index 3773741743fab..e9b7b03798826 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts @@ -35,7 +35,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IHostService } from '../../../services/host/browser/host.js'; import { URI } from '../../../../base/common/uri.js'; import { IExtension, ExtensionState, IExtensionsWorkbenchService, AutoUpdateConfigurationKey, AutoCheckUpdatesConfigurationKey, HasOutdatedExtensionsContext, AutoUpdateConfigurationValue, InstallExtensionOptions, ExtensionRuntimeState, ExtensionRuntimeActionType, AutoRestartConfigurationKey, VIEWLET_ID, IExtensionsViewPaneContainer, IExtensionsNotification } from '../common/extensions.js'; -import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../services/editor/common/editorService.js'; +import { IEditorService, MODAL_GROUP, SIDE_GROUP } from '../../../services/editor/common/editorService.js'; import { IURLService, IURLHandler, IOpenURLOptions } from '../../../../platform/url/common/url.js'; import { ExtensionsInput, IExtensionEditorOptions } from '../common/extensionsInput.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -1577,7 +1577,7 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension if (!extension) { throw new Error(`Extension not found. ${extension}`); } - await this.editorService.openEditor(this.instantiationService.createInstance(ExtensionsInput, extension), options, options?.sideByside ? SIDE_GROUP : ACTIVE_GROUP); + await this.editorService.openEditor(this.instantiationService.createInstance(ExtensionsInput, extension), options, options?.sideByside ? SIDE_GROUP : MODAL_GROUP); } async openSearch(searchValue: string, preserveFoucs?: boolean): Promise { diff --git a/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts b/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts index fcfc538cf8070..19dd14ad537c1 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts @@ -30,7 +30,7 @@ import { IUserDataProfilesService } from '../../../../platform/userDataProfile/c import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { MCP_CONFIGURATION_KEY, WORKSPACE_STANDALONE_CONFIGURATIONS } from '../../../services/configuration/common/configuration.js'; -import { ACTIVE_GROUP, IEditorService } from '../../../services/editor/common/editorService.js'; +import { IEditorService, MODAL_GROUP } from '../../../services/editor/common/editorService.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { DidUninstallWorkbenchMcpServerEvent, IWorkbenchLocalMcpServer, IWorkbenchMcpManagementService, IWorkbenchMcpServerInstallResult, IWorkbencMcpServerInstallOptions, LocalMcpServerScope, REMOTE_USER_CONFIG_ID, USER_CONFIG_ID, WORKSPACE_CONFIG_ID, WORKSPACE_FOLDER_CONFIG_ID_PREFIX } from '../../../services/mcp/common/mcpWorkbenchManagementService.js'; import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; @@ -721,7 +721,7 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ } async open(extension: IWorkbenchMcpServer, options?: IEditorOptions): Promise { - await this.editorService.openEditor(this.instantiationService.createInstance(McpServerEditorInput, extension), options, ACTIVE_GROUP); + await this.editorService.openEditor(this.instantiationService.createInstance(McpServerEditorInput, extension), options, MODAL_GROUP); } private getInstallState(extension: McpWorkbenchServer): McpServerInstallState { diff --git a/src/vs/workbench/contrib/webview/browser/overlayWebview.ts b/src/vs/workbench/contrib/webview/browser/overlayWebview.ts index 42293c78211f7..e47649ef3b4e6 100644 --- a/src/vs/workbench/contrib/webview/browser/overlayWebview.ts +++ b/src/vs/workbench/contrib/webview/browser/overlayWebview.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Dimension, getWindowById } from '../../../../base/browser/dom.js'; +import { Dimension, getWindowById, isHTMLElement } from '../../../../base/browser/dom.js'; import { FastDomNode } from '../../../../base/browser/fastDomNode.js'; import { IMouseWheelEvent } from '../../../../base/browser/mouseEvent.js'; import { CodeWindow } from '../../../../base/browser/window.js'; @@ -15,6 +15,7 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import { IContextKey, IContextKeyService, IScopedContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js'; +import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; import { IOverlayWebview, IWebview, IWebviewElement, IWebviewService, KEYBINDING_CONTEXT_WEBVIEW_FIND_WIDGET_ENABLED, KEYBINDING_CONTEXT_WEBVIEW_FIND_WIDGET_VISIBLE, WebviewContentOptions, WebviewExtensionDescription, WebviewInitInfo, WebviewMessageReceivedEvent, WebviewOptions } from './webview.js'; /** @@ -56,7 +57,8 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { initInfo: WebviewInitInfo, @IWorkbenchLayoutService private readonly _layoutService: IWorkbenchLayoutService, @IWebviewService private readonly _webviewService: IWebviewService, - @IContextKeyService private readonly _baseContextKeyService: IContextKeyService + @IContextKeyService private readonly _baseContextKeyService: IContextKeyService, + @IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService, ) { super(); @@ -107,8 +109,15 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { this._container.setVisibility('hidden'); // Webviews cannot be reparented in the dom as it will destroy their contents. - // Mount them to a high level node to avoid this. - this._layoutService.getContainer(this.window).appendChild(node); + // Mount them to a high level node to avoid this depending on the active container. + const modalEditorContainer = this._editorGroupsService.activeModalEditorPart?.getModalElement(); + let root: HTMLElement; + if (isHTMLElement(modalEditorContainer)) { + root = modalEditorContainer; + } else { + root = this._layoutService.getContainer(this.window); + } + root.appendChild(node); } return this._container.domNode; diff --git a/src/vs/workbench/contrib/webviewPanel/browser/webviewEditor.ts b/src/vs/workbench/contrib/webviewPanel/browser/webviewEditor.ts index 5a7de862a3593..62c9a4bb88c81 100644 --- a/src/vs/workbench/contrib/webviewPanel/browser/webviewEditor.ts +++ b/src/vs/workbench/contrib/webviewPanel/browser/webviewEditor.ts @@ -25,6 +25,7 @@ import { IEditorGroup, IEditorGroupsService } from '../../../services/editor/com import { IEditorService } from '../../../services/editor/common/editorService.js'; import { IHostService } from '../../../services/host/browser/host.js'; import { IWorkbenchLayoutService, Parts } from '../../../services/layout/browser/layoutService.js'; +import { isHTMLElement } from '../../../../base/browser/dom.js'; /** * Tracks the id of the actively focused webview. @@ -194,8 +195,14 @@ export class WebviewEditor extends EditorPane { return; } - const rootContainer = this._workbenchLayoutService.getContainer(this.window, Parts.EDITOR_PART); - webview.layoutWebviewOverElement(this._element.parentElement!, dimension, rootContainer); + const modalEditorContainer = this._editorGroupsService.activeModalEditorPart?.getModalElement(); + let clippingContainer: HTMLElement | undefined; + if (isHTMLElement(modalEditorContainer)) { + clippingContainer = modalEditorContainer; + } else { + clippingContainer = this._workbenchLayoutService.getContainer(this.window, Parts.EDITOR_PART); + } + webview.layoutWebviewOverElement(this._element.parentElement!, dimension, clippingContainer); } private trackFocus(webview: IOverlayWebview): IDisposable { diff --git a/src/vs/workbench/services/editor/common/editorGroupsService.ts b/src/vs/workbench/services/editor/common/editorGroupsService.ts index d782d8df7baf0..d87d8724ebd32 100644 --- a/src/vs/workbench/services/editor/common/editorGroupsService.ts +++ b/src/vs/workbench/services/editor/common/editorGroupsService.ts @@ -543,6 +543,11 @@ export interface IModalEditorPart extends IEditorPart { */ toggleMaximized(): void; + /** + * Modal container of the editor part. + */ + getModalElement(): unknown /* HTMLElement */; + /** * Close this modal editor part after moving all * editors of all groups back to the main editor part diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index a911112cb4ac8..529c27d720d98 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -2143,6 +2143,7 @@ export class TestChatWidgetService implements IChatWidgetService { onDidAddWidget = Event.None; onDidBackgroundSession = Event.None; onDidChangeFocusedWidget = Event.None; + onDidChangeFocusedSession = Event.None; async reveal(widget: IChatWidget, preserveFocus?: boolean): Promise { return false; } async revealWidget(preserveFocus?: boolean): Promise { return undefined; } diff --git a/src/vscode-dts/vscode.proposed.chatProvider.d.ts b/src/vscode-dts/vscode.proposed.chatProvider.d.ts index 48a3f1048feeb..1ded6ac9ba730 100644 --- a/src/vscode-dts/vscode.proposed.chatProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatProvider.d.ts @@ -65,6 +65,15 @@ declare module 'vscode' { readonly category?: { label: string; order: number }; readonly statusIcon?: ThemeIcon; + + /** + * When set, this model is only shown in the model picker for the specified chat session type. + * Models with this property are excluded from the general model picker and only appear + * when the user is in a session matching this type. + * + * The value must match a `type` declared in a `chatSessions` extension contribution. + */ + readonly targetChatSessionType?: string; } export interface LanguageModelChatCapabilities { diff --git a/test/sanity/src/cli.test.ts b/test/sanity/src/cli.test.ts index d22761ee92cda..0b813085d3935 100644 --- a/test/sanity/src/cli.test.ts +++ b/test/sanity/src/cli.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Browser } from 'playwright'; +import { Page } from 'playwright'; import { TestContext } from './context.js'; import { GitHubAuth } from './githubAuth.js'; import { UITest } from './uiTest.js'; @@ -84,7 +84,7 @@ export function setup(context: TestContext) { const cliDataDir = context.createTempDir(); const test = new UITest(context); const auth = new GitHubAuth(context); - let browser: Browser | undefined; + let page: Page | undefined; context.log('Logging out of Dev Tunnel to ensure fresh authentication'); context.run(entryPoint, '--cli-data-dir', cliDataDir, 'tunnel', 'user', 'logout'); @@ -103,8 +103,9 @@ export function setup(context: TestContext) { const deviceCode = /To grant access .* use code ([A-Z0-9-]+)/.exec(line)?.[1]; if (deviceCode) { context.log(`Device code detected: ${deviceCode}, starting device flow authentication`); - browser = await context.launchBrowser(); - await auth.runDeviceCodeFlow(browser, deviceCode); + const browser = await context.launchBrowser(); + page = await browser.newPage(); + await auth.runDeviceCodeFlow(page, deviceCode); return; } @@ -114,12 +115,11 @@ export function setup(context: TestContext) { const url = context.getTunnelUrl(tunnelUrl, test.workspaceDir); context.log(`CLI started successfully with tunnel URL: ${url}`); - if (!browser) { + if (!page) { throw new Error('Browser instance is not available'); } context.log(`Navigating to ${url}`); - const page = await context.getPage(browser.newPage()); await page.goto(url); context.log('Waiting for the workbench to load'); @@ -131,7 +131,7 @@ export function setup(context: TestContext) { context.log('Clicking Allow on confirmation dialog'); await page.getByRole('button', { name: 'Allow' }).click(); - await auth.runUserWebFlow(page); + await auth.runAuthorizeFlow(page); context.log('Waiting for connection to be established'); await page.getByRole('button', { name: `remote ${tunnelId}` }).waitFor({ timeout: 5 * 60 * 1000 }); @@ -139,7 +139,7 @@ export function setup(context: TestContext) { await test.run(page); context.log('Closing browser'); - await browser.close(); + await page.context().browser()?.close(); test.validate(); return true; diff --git a/test/sanity/src/githubAuth.ts b/test/sanity/src/githubAuth.ts index d7576d761cdf7..359ac030b7ba5 100644 --- a/test/sanity/src/githubAuth.ts +++ b/test/sanity/src/githubAuth.ts @@ -3,15 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Browser, Page } from 'playwright'; +import { Page } from 'playwright'; import { TestContext } from './context.js'; /** * Handles GitHub authentication flows in the browser. */ export class GitHubAuth { - // private readonly username = process.env.GITHUB_ACCOUNT; - // private readonly password = process.env.GITHUB_PASSWORD; + private readonly username = process.env.GITHUB_ACCOUNT; + private readonly password = process.env.GITHUB_PASSWORD; public constructor(private readonly context: TestContext) { } @@ -20,17 +20,43 @@ export class GitHubAuth { * @param browser Browser to use. * @param code Device authentication code to use. */ - public async runDeviceCodeFlow(browser: Browser, code: string) { + public async runDeviceCodeFlow(page: Page, code: string) { + if (!this.username || !this.password) { + this.context.error('GITHUB_ACCOUNT and GITHUB_PASSWORD environment variables must be set'); + } + this.context.log(`Running GitHub device flow with code ${code}`); - const page = await browser.newPage(); await page.goto('https://github.com/login/device'); + + this.context.log('Filling in GitHub credentials'); + await page.getByLabel('Username or email address').fill(this.username); + await page.getByLabel('Password').fill(this.password); + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); + + this.context.log('Confirming device activation'); + await page.getByRole('button', { name: 'Continue' }).click(); + + this.context.log('Entering device code'); + const codeChars = code.replace('-', ''); + for (let i = 0; i < codeChars.length; i++) { + await page.getByRole('textbox').nth(i).fill(codeChars[i]); + } + await page.getByRole('button', { name: 'Continue' }).click(); + + this.context.log('Authorizing device'); + await page.getByRole('button', { name: 'Authorize' }).click(); } /** - * Runs GitHub user authentication flow in the browser. - * @param page Authentication page. + * Handles the GitHub "Authorize" dialog in a popup. + * Clicks "Continue" to authorize the app with the already signed-in account. + * @param page Main page that triggers the GitHub OAuth popup. */ - public async runUserWebFlow(page: Page) { - this.context.log(`Running GitHub browser flow at ${page.url()}`); + public async runAuthorizeFlow(page: Page) { + this.context.log('Waiting for GitHub OAuth popup'); + const popup = await page.waitForEvent('popup'); + + this.context.log(`Authorizing app at ${popup.url()}`); + await popup.getByRole('button', { name: 'Continue' }).click(); } }