diff --git a/.github/instructions/chat.instructions.md b/.github/instructions/chat.instructions.md index c1d1061bb565c..657866be20563 100644 --- a/.github/instructions/chat.instructions.md +++ b/.github/instructions/chat.instructions.md @@ -1,5 +1,4 @@ --- -applyTo: '**/chat/**' description: Chat feature area coding guidelines --- diff --git a/.github/instructions/interactive.instructions.md b/.github/instructions/interactive.instructions.md index 21ed92f6460a9..d6867257e66a9 100644 --- a/.github/instructions/interactive.instructions.md +++ b/.github/instructions/interactive.instructions.md @@ -1,6 +1,5 @@ --- -applyTo: '**/interactive/**' -description: Architecture documentation for VS Code interactive window component +description: Architecture documentation for VS Code interactive window component. Use when working in folder --- # Interactive Window diff --git a/.github/instructions/learnings.instructions.md b/.github/instructions/learnings.instructions.md index 9358a943e3d94..22fa31ae4744c 100644 --- a/.github/instructions/learnings.instructions.md +++ b/.github/instructions/learnings.instructions.md @@ -1,5 +1,4 @@ --- -applyTo: ** description: This document describes how to deal with learnings that you make. (meta instruction) --- diff --git a/.github/instructions/notebook.instructions.md b/.github/instructions/notebook.instructions.md index 3d78e744d317a..890b0c20db296 100644 --- a/.github/instructions/notebook.instructions.md +++ b/.github/instructions/notebook.instructions.md @@ -1,5 +1,4 @@ --- -applyTo: '**/notebook/**' description: Architecture documentation for VS Code notebook and interactive window components --- diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 2e30ae24cec53..c6b4113cbd4f6 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -948,9 +948,12 @@ MenuRegistry.appendMenuItem(MenuId.CommandCenter, { ChatContextKeys.Setup.disabled.negate() ), ContextKeyExpr.has('config.chat.commandCenter.enabled'), - ContextKeyExpr.has(`config.${ChatConfiguration.AgentStatusEnabled}`).negate() // Hide when agent status is shown + ContextKeyExpr.or( + ContextKeyExpr.has(`config.${ChatConfiguration.AgentStatusEnabled}`).negate(), // Show when agent status is disabled + ChatContextKeys.agentStatusHasNotifications.negate() // Or when agent status has no notifications + ) ), - order: 10001 // to the right of command center + order: 10003 // to the right of agent controls }); // Add to the global title bar if command center is disabled diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 0d297efe2ffdf..80e6d09bce670 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -440,7 +440,7 @@ export class OpenSessionTargetPickerAction extends Action2 { tooltip: localize('setSessionTarget', "Set Session Target"), category: CHAT_CATEGORY, f1: false, - precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.hasCanDelegateProviders, ChatContextKeys.chatSessionIsEmpty), + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.hasCanDelegateProviders, ContextKeyExpr.or(ChatContextKeys.chatSessionIsEmpty, ChatContextKeys.inAgentSessionsWelcome)), menu: [ { id: MenuId.ChatInput, @@ -479,12 +479,14 @@ export class ChatSessionPrimaryPickerAction extends Action2 { order: 4, group: 'navigation', when: - ContextKeyExpr.or( + ContextKeyExpr.and( ChatContextKeys.chatSessionHasModels, - ChatContextKeys.lockedToCodingAgent, - ContextKeyExpr.and( - ChatContextKeys.inAgentSessionsWelcome, - ChatContextKeys.chatSessionType.notEqualsTo('local') + ContextKeyExpr.or( + ChatContextKeys.lockedToCodingAgent, + ContextKeyExpr.and( + ChatContextKeys.inAgentSessionsWelcome, + ChatContextKeys.chatSessionType.notEqualsTo('local') + ) ) ) } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionProjectionActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionProjectionActions.ts index 0be275274f0a1..7571d0f8e5027 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionProjectionActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionProjectionActions.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { localize, localize2 } from '../../../../../nls.js'; -import { Action2, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { Action2 } from '../../../../../platform/actions/common/actions.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; @@ -15,7 +15,6 @@ import { IAgentSessionProjectionService } from './agentSessionProjectionService. import { IAgentSession, isMarshalledAgentSessionContext, IMarshalledAgentSessionContext } from './agentSessionsModel.js'; import { IAgentSessionsService } from './agentSessionsService.js'; import { CHAT_CATEGORY } from '../actions/chatActions.js'; -import { openSessionInChatWidget } from './agentSessionsOpener.js'; import { ToggleTitleBarConfigAction } from '../../../../browser/parts/titlebar/titlebarActions.js'; import { IsCompactTitleBarContext } from '../../../../common/contextkeys.js'; @@ -90,45 +89,6 @@ export class ExitAgentSessionProjectionAction extends Action2 { //#endregion -//#region Open in Chat Panel - -export class OpenInChatPanelAction extends Action2 { - static readonly ID = 'agentSession.openInChatPanel'; - - constructor() { - super({ - id: OpenInChatPanelAction.ID, - title: localize2('openInChatPanel', "Open in Chat Panel"), - category: CHAT_CATEGORY, - precondition: ChatContextKeys.enabled, - menu: [{ - id: MenuId.AgentSessionsContext, - group: '1_open', - order: 1, - }] - }); - } - - override async run(accessor: ServicesAccessor, context?: IAgentSession | IMarshalledAgentSessionContext): Promise { - const agentSessionsService = accessor.get(IAgentSessionsService); - - let session: IAgentSession | undefined; - if (context) { - if (isMarshalledAgentSessionContext(context)) { - session = agentSessionsService.getSession(context.session.resource); - } else { - session = context; - } - } - - if (session) { - await openSessionInChatWidget(accessor, session); - } - } -} - -//#endregion - //#region Toggle Agent Status export class ToggleAgentStatusAction extends ToggleTitleBarConfigAction { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionProjectionService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionProjectionService.ts index db02aad380ec3..8521dd2ecd781 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionProjectionService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionProjectionService.ts @@ -142,7 +142,11 @@ export class AgentSessionProjectionService extends Disposable implements IAgentS } } - private async _openSessionFiles(session: IAgentSession): Promise { + /** + * Open the session's files in a multi-diff editor. + * @returns true if any files were opened, false if nothing to display + */ + private async _openSessionFiles(session: IAgentSession): Promise { // Clear editors first await this.editorGroupsService.applyWorkingSet('empty', { preserveFocus: true }); @@ -178,11 +182,14 @@ export class AgentSessionProjectionService extends Disposable implements IAgentS const sessionKey = session.resource.toString(); const newWorkingSet = this.editorGroupsService.saveWorkingSet(`agent-session-projection-${sessionKey}`); this._sessionWorkingSets.set(sessionKey, newWorkingSet); + return true; } else { this.logService.trace(`[AgentSessionProjection] No files with diffs to display (all changes missing originalUri)`); + return false; } } else { this.logService.trace(`[AgentSessionProjection] Session has no changes to display`); + return false; } } @@ -222,24 +229,45 @@ export class AgentSessionProjectionService extends Disposable implements IAgentS this._sessionWorkingSets.set(previousSessionKey, previousWorkingSet); } - // Always open session files to ensure they're displayed - await this._openSessionFiles(session); - - // Set active state - const wasActive = this._isActive; - this._isActive = true; - this._activeSession = session; - this._inProjectionModeContextKey.set(true); - this.layoutService.mainContainer.classList.add('agent-session-projection-active'); - - // Update the agent status to show session mode - this.agentStatusService.enterSessionMode(session.resource.toString(), session.label); + // For local sessions, changes are shown via chatEditing.viewChanges, not _openSessionFiles + // For other providers, try to open session files from session.changes + let filesOpened = false; + if (session.providerType === AgentSessionProviders.Local) { + // Local sessions use editing session for changes - we already verified hasUndecidedChanges above + // Clear editors to prepare for the changes view + await this.editorGroupsService.applyWorkingSet('empty', { preserveFocus: true }); + filesOpened = true; + } else { + // Try to open session files - only continue with projection if files were displayed + filesOpened = await this._openSessionFiles(session); + } - if (!wasActive) { - this._onDidChangeProjectionMode.fire(true); + if (!filesOpened) { + this.logService.trace('[AgentSessionProjection] No files to display, opening chat without projection mode'); + // Restore the working set we just saved if this was our first attempt + if (!this._isActive && this._preProjectionWorkingSet) { + await this.editorGroupsService.applyWorkingSet(this._preProjectionWorkingSet); + this.editorGroupsService.deleteWorkingSet(this._preProjectionWorkingSet); + this._preProjectionWorkingSet = undefined; + } + // Fall through to just open the chat panel + } else { + // Set active state + const wasActive = this._isActive; + this._isActive = true; + this._activeSession = session; + this._inProjectionModeContextKey.set(true); + this.layoutService.mainContainer.classList.add('agent-session-projection-active'); + + // Update the agent status to show session mode + this.agentStatusService.enterSessionMode(session.resource.toString(), session.label); + + if (!wasActive) { + this._onDidChangeProjectionMode.fire(true); + } + // Always fire session change event (for title updates when switching sessions) + this._onDidChangeActiveSession.fire(session); } - // Always fire session change event (for title updates when switching sessions) - this._onDidChangeActiveSession.fire(session); } // Open the session in the chat panel (always, even without changes) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts index e9a78c12610ab..10c3927eab99d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts @@ -28,6 +28,7 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ChatConfiguration } from '../../common/constants.js'; import { AuxiliaryBarMaximizedContext } from '../../../../common/contextkeys.js'; +import { LayoutSettings } from '../../../../services/layout/browser/layoutService.js'; //#region Actions and Menus @@ -61,7 +62,6 @@ registerAction2(SetAgentSessionsOrientationSideBySideAction); // Agent Session Projection registerAction2(EnterAgentSessionProjectionAction); registerAction2(ExitAgentSessionProjectionAction); -// registerAction2(OpenInChatPanelAction); // TODO@joshspicer https://github.com/microsoft/vscode/issues/288082 registerAction2(ToggleAgentStatusAction); registerAction2(ToggleAgentSessionProjectionAction); @@ -240,9 +240,15 @@ class AgentStatusRendering extends Disposable implements IWorkbenchContribution }, undefined)); // Add/remove CSS class on workbench based on setting + // Also force enable command center when agent status is enabled const updateClass = () => { const enabled = configurationService.getValue(ChatConfiguration.AgentStatusEnabled) === true; mainWindow.document.body.classList.toggle('agent-status-enabled', enabled); + + // Force enable command center when agent status is enabled + if (enabled && configurationService.getValue(LayoutSettings.COMMAND_CENTER) !== true) { + configurationService.updateValue(LayoutSettings.COMMAND_CENTER, true); + } }; updateClass(); this._register(configurationService.onDidChangeConfiguration(e => { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts index 5537672599a5d..1510faf82ec9b 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts @@ -196,15 +196,6 @@ export class PickAgentSessionAction extends Action2 { group: 'navigation', order: 2 }, - { - id: MenuId.ViewTitle, - when: ContextKeyExpr.and( - ContextKeyExpr.equals('view', ChatViewId), - ContextKeyExpr.equals(`config.${ChatConfiguration.ChatViewSessionsEnabled}`, true) - ), - group: '2_history', - order: 1 - }, { id: MenuId.EditorTitle, when: ActiveEditorContext.isEqualTo(ChatEditorInput.EditorID), diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index 9a1b6e8d992ec..f99a9b8de6cb6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -37,19 +37,27 @@ import { IMouseEvent } from '../../../../../base/browser/mouseEvent.js'; export interface IAgentSessionsControlOptions extends IAgentSessionsSorterOptions { readonly overrideStyles: IStyleOverride; readonly filter: IAgentSessionsFilter; + readonly source: AgentSessionsControlSource; getHoverPosition(): HoverPosition; trackActiveEditorSession(): boolean; } +export const enum AgentSessionsControlSource { + ChatViewPane = 'chatViewPane', + WelcomeView = 'welcomeView' +} + type AgentSessionOpenedClassification = { owner: 'bpasero'; providerType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The provider type of the opened agent session.' }; + source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The source of the opened agent session.' }; comment: 'Event fired when a agent session is opened from the agent sessions control.'; }; type AgentSessionOpenedEvent = { providerType: string; + source: AgentSessionsControlSource; }; export class AgentSessionsControl extends Disposable implements IAgentSessionsControl { @@ -196,10 +204,11 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo } this.telemetryService.publicLog2('agentSessionOpened', { - providerType: element.providerType + providerType: element.providerType, + source: this.options.source }); - await this.instantiationService.invokeFunction(openSession, element, e); + await this.instantiationService.invokeFunction(openSession, element, { ...e, expanded: this.options.source === AgentSessionsControlSource.WelcomeView }); } private async showContextMenu({ element, anchor, browserEvent }: ITreeContextMenuEvent): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts index 7fe2e56a9772b..7220766b9dfb3 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts @@ -15,7 +15,7 @@ import { IAgentSessionProjectionService } from './agentSessionProjectionService. import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ChatConfiguration } from '../../common/constants.js'; -export async function openSession(accessor: ServicesAccessor, session: IAgentSession, openOptions?: { sideBySide?: boolean; editorOptions?: IEditorOptions }): Promise { +export async function openSession(accessor: ServicesAccessor, session: IAgentSession, openOptions?: { sideBySide?: boolean; editorOptions?: IEditorOptions; expanded?: boolean }): Promise { const configurationService = accessor.get(IConfigurationService); const projectionService = accessor.get(IAgentSessionProjectionService); @@ -35,7 +35,7 @@ export async function openSession(accessor: ServicesAccessor, session: IAgentSes * Opens a session in the traditional chat widget (side panel or editor). * Use this when you explicitly want to open in the chat widget rather than agent session projection mode. */ -export async function openSessionInChatWidget(accessor: ServicesAccessor, session: IAgentSession, openOptions?: { sideBySide?: boolean; editorOptions?: IEditorOptions }): Promise { +export async function openSessionInChatWidget(accessor: ServicesAccessor, session: IAgentSession, openOptions?: { sideBySide?: boolean; editorOptions?: IEditorOptions; expanded?: boolean }): Promise { const chatSessionsService = accessor.get(IChatSessionsService); const chatWidgetService = accessor.get(IChatWidgetService); @@ -51,7 +51,8 @@ export async function openSessionInChatWidget(accessor: ServicesAccessor, sessio let options: IChatEditorOptions = { ...sessionOptions, ...openOptions?.editorOptions, - revealIfOpened: true // always try to reveal if already opened + revealIfOpened: true, // always try to reveal if already opened + expanded: openOptions?.expanded }; await chatSessionsService.activateChatSessionItemProvider(session.providerType); // ensure provider is activated before trying to open diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentStatusWidget.ts index 4c3887db7ba36..6bfea0a5b276c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentStatusWidget.ts @@ -19,7 +19,7 @@ import { ExitAgentSessionProjectionAction } from './agentSessionProjectionAction import { IAgentSessionsService } from './agentSessionsService.js'; import { AgentSessionStatus, IAgentSession, isSessionInProgressStatus } from './agentSessionsModel.js'; import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; -import { IAction } from '../../../../../base/common/actions.js'; +import { IAction, SubmenuAction } from '../../../../../base/common/actions.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IBrowserWorkbenchEnvironmentService } from '../../../../services/environment/browser/environmentService.js'; @@ -30,6 +30,12 @@ import { Schemas } from '../../../../../base/common/network.js'; import { renderAsPlaintext } from '../../../../../base/browser/markdownRenderer.js'; import { openSession } from './agentSessionsOpener.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { HiddenItemStrategy, WorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; +import { createActionViewItem } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { FocusAgentSessionsAction } from './agentSessionsActions.js'; // Action triggered when clicking the main pill - change this to modify the primary action const ACTION_ID = 'workbench.action.quickchat.toggle'; @@ -49,6 +55,8 @@ const TITLE_DIRTY = '\u25cf '; */ export class AgentStatusWidget extends BaseActionViewItem { + private static readonly _quickOpenCommandId = 'workbench.action.quickOpenWithModes'; + private _container: HTMLElement | undefined; private readonly _dynamicDisposables = this._register(new DisposableStore()); @@ -58,6 +66,9 @@ export class AgentStatusWidget extends BaseActionViewItem { /** Cached render state to avoid unnecessary DOM rebuilds */ private _lastRenderState: string | undefined; + /** Reusable menu for CommandCenterCenter items (e.g., debug toolbar) */ + private readonly _commandCenterMenu; + constructor( action: IAction, options: IBaseActionViewItemOptions | undefined, @@ -72,9 +83,15 @@ export class AgentStatusWidget extends BaseActionViewItem { @IBrowserWorkbenchEnvironmentService private readonly environmentService: IBrowserWorkbenchEnvironmentService, @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, @IEditorService private readonly editorService: IEditorService, + @IMenuService private readonly menuService: IMenuService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IStorageService private readonly storageService: IStorageService, ) { super(undefined, action, options); + // Create menu for CommandCenterCenter to get items like debug toolbar + this._commandCenterMenu = this._register(this.menuService.createMenu(MenuId.CommandCenterCenter, this.contextKeyService)); + // Re-render when control mode or session info changes this._register(this.agentStatusService.onDidChangeMode(() => { this._render(); @@ -100,6 +117,12 @@ export class AgentStatusWidget extends BaseActionViewItem { this._render(); } })); + + // Re-render when command center menu changes (e.g., debug toolbar visibility) + this._register(this._commandCenterMenu.onDidChange(() => { + this._lastRenderState = undefined; // Force re-render + this._render(); + })); } override render(container: HTMLElement): void { @@ -210,6 +233,9 @@ export class AgentStatusWidget extends BaseActionViewItem { const { activeSessions, unreadSessions, attentionNeededSessions, hasAttentionNeeded } = this._getSessionStats(); + // Render command center items (like debug toolbar) FIRST - to the left + this._renderCommandCenterToolbar(disposables); + // Create pill const pill = $('div.agent-status-pill.chat-input-mode'); if (hasAttentionNeeded) { @@ -315,6 +341,9 @@ export class AgentStatusWidget extends BaseActionViewItem { const { activeSessions, unreadSessions } = this._getSessionStats(); + // Render command center items (like debug toolbar) FIRST - to the left + this._renderCommandCenterToolbar(disposables); + const pill = $('div.agent-status-pill.session-mode'); this._container.appendChild(pill); @@ -345,6 +374,59 @@ export class AgentStatusWidget extends BaseActionViewItem { // #region Reusable Components + /** + * Render command center toolbar items (like debug toolbar) that are registered to CommandCenter + * Filters out the quick open action since we provide our own search UI. + * Adds a dot separator after the toolbar if content was rendered. + */ + private _renderCommandCenterToolbar(disposables: DisposableStore): void { + if (!this._container) { + return; + } + + // Get menu actions from CommandCenterCenter (e.g., debug toolbar) + const allActions: IAction[] = []; + for (const [, actions] of this._commandCenterMenu.getActions({ shouldForwardArgs: true })) { + for (const action of actions) { + // Filter out the quick open action - we provide our own search UI + if (action.id === AgentStatusWidget._quickOpenCommandId) { + continue; + } + // For submenus (like debug toolbar), add the submenu actions + if (action instanceof SubmenuAction) { + allActions.push(...action.actions); + } else { + allActions.push(action); + } + } + } + + // Only render toolbar if there are actions + if (allActions.length === 0) { + return; + } + + const hoverDelegate = getDefaultHoverDelegate('mouse'); + const toolbarContainer = $('div.agent-status-command-center-toolbar'); + this._container.appendChild(toolbarContainer); + + const toolbar = this.instantiationService.createInstance(WorkbenchToolBar, toolbarContainer, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + telemetrySource: 'agentStatusCommandCenter', + actionViewItemProvider: (action, options) => { + return createActionViewItem(this.instantiationService, action, { ...options, hoverDelegate }); + } + }); + disposables.add(toolbar); + + toolbar.setActions(allActions); + + // Add dot separator after the toolbar (matching command center style) + const separator = renderIcon(Codicon.circleSmallFilled); + separator.classList.add('agent-status-separator'); + this._container.appendChild(separator); + } + /** * Render the search button. If parent is provided, appends to parent; otherwise appends to container. */ @@ -389,7 +471,7 @@ export class AgentStatusWidget extends BaseActionViewItem { /** * Render the status badge showing in-progress and/or unread session counts. * Shows split UI with both indicators when both types exist. - * Always renders for smooth fade transitions - uses visibility classes. + * When no notifications, shows a chat sparkle icon. */ private _renderStatusBadge(disposables: DisposableStore, activeSessions: IAgentSession[], unreadSessions: IAgentSession[]): void { if (!this._container) { @@ -400,15 +482,23 @@ export class AgentStatusWidget extends BaseActionViewItem { const hasUnreadSessions = unreadSessions.length > 0; const hasContent = hasActiveSessions || hasUnreadSessions; + // Auto-clear filter if the filtered category becomes empty + this._clearFilterIfCategoryEmpty(hasUnreadSessions, hasActiveSessions); + const badge = $('div.agent-status-badge'); + this._container.appendChild(badge); + + // When no notifications, hide the badge if (!hasContent) { badge.classList.add('empty'); + return; } - this._container.appendChild(badge); // Unread section (blue dot + count) if (hasUnreadSessions) { const unreadSection = $('span.agent-status-badge-section.unread'); + unreadSection.setAttribute('role', 'button'); + unreadSection.tabIndex = 0; const unreadIcon = $('span.agent-status-icon'); reset(unreadIcon, renderIcon(Codicon.circleFilled)); unreadSection.appendChild(unreadIcon); @@ -416,11 +506,27 @@ export class AgentStatusWidget extends BaseActionViewItem { unreadCount.textContent = String(unreadSessions.length); unreadSection.appendChild(unreadCount); badge.appendChild(unreadSection); + + // Click handler - filter to unread sessions + disposables.add(addDisposableListener(unreadSection, EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this._openSessionsWithFilter('unread'); + })); + disposables.add(addDisposableListener(unreadSection, EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this._openSessionsWithFilter('unread'); + } + })); } // In-progress section (session-in-progress icon + count) if (hasActiveSessions) { const activeSection = $('span.agent-status-badge-section.active'); + activeSection.setAttribute('role', 'button'); + activeSection.tabIndex = 0; const runningIcon = $('span.agent-status-icon'); reset(runningIcon, renderIcon(Codicon.sessionInProgress)); activeSection.appendChild(runningIcon); @@ -428,6 +534,20 @@ export class AgentStatusWidget extends BaseActionViewItem { runningCount.textContent = String(activeSessions.length); activeSection.appendChild(runningCount); badge.appendChild(activeSection); + + // Click handler - filter to in-progress sessions + disposables.add(addDisposableListener(activeSection, EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this._openSessionsWithFilter('inProgress'); + })); + disposables.add(addDisposableListener(activeSection, EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this._openSessionsWithFilter('inProgress'); + } + })); } // Setup hover with combined tooltip @@ -448,6 +568,116 @@ export class AgentStatusWidget extends BaseActionViewItem { })); } + /** + * Clear the filter if the currently filtered category becomes empty. + * For example, if filtered to "unread" but no unread sessions exist, clear the filter. + */ + private _clearFilterIfCategoryEmpty(hasUnreadSessions: boolean, hasActiveSessions: boolean): void { + const FILTER_STORAGE_KEY = 'agentSessions.filterExcludes.agentsessionsviewerfiltersubmenu'; + + const currentFilterStr = this.storageService.get(FILTER_STORAGE_KEY, StorageScope.PROFILE); + if (!currentFilterStr) { + return; + } + + let currentFilter: { providers: string[]; states: AgentSessionStatus[]; archived: boolean; read: boolean } | undefined; + try { + currentFilter = JSON.parse(currentFilterStr); + } catch { + return; + } + + if (!currentFilter) { + return; + } + + // Detect if filtered to unread (read=true excludes read sessions, leaving only unread) + const isFilteredToUnread = currentFilter.read === true && currentFilter.states.length === 0; + // Detect if filtered to in-progress (2 excluded states = Completed + Failed) + const isFilteredToInProgress = currentFilter.states?.length === 2 && currentFilter.read === false; + + // Clear filter if filtered category is now empty + if ((isFilteredToUnread && !hasUnreadSessions) || (isFilteredToInProgress && !hasActiveSessions)) { + const clearedFilter = { + providers: [], + states: [], + archived: true, + read: false + }; + this.storageService.store(FILTER_STORAGE_KEY, JSON.stringify(clearedFilter), StorageScope.PROFILE, StorageTarget.USER); + } + } + + /** + * Opens the agent sessions view with a specific filter applied, or clears filter if already applied. + * @param filterType 'unread' to show only unread sessions, 'inProgress' to show only in-progress sessions + */ + private _openSessionsWithFilter(filterType: 'unread' | 'inProgress'): void { + const FILTER_STORAGE_KEY = 'agentSessions.filterExcludes.agentsessionsviewerfiltersubmenu'; + + // Check current filter to see if we should toggle off + const currentFilterStr = this.storageService.get(FILTER_STORAGE_KEY, StorageScope.PROFILE); + let currentFilter: { providers: string[]; states: AgentSessionStatus[]; archived: boolean; read: boolean } | undefined; + if (currentFilterStr) { + try { + currentFilter = JSON.parse(currentFilterStr); + } catch { + // Ignore parse errors + } + } + + // Determine if the current filter matches what we're clicking + const isCurrentlyFilteredToUnread = currentFilter?.read === true && currentFilter.states.length === 0; + const isCurrentlyFilteredToInProgress = currentFilter?.states?.length === 2 && currentFilter.read === false; + + // Build filter excludes based on filter type + let excludes: { providers: string[]; states: AgentSessionStatus[]; archived: boolean; read: boolean }; + + if (filterType === 'unread') { + if (isCurrentlyFilteredToUnread) { + // Toggle off - clear all filters + excludes = { + providers: [], + states: [], + archived: true, + read: false + }; + } else { + // Exclude read sessions to show only unread + excludes = { + providers: [], + states: [], + archived: true, + read: true // exclude read sessions + }; + } + } else { + if (isCurrentlyFilteredToInProgress) { + // Toggle off - clear all filters + excludes = { + providers: [], + states: [], + archived: true, + read: false + }; + } else { + // Exclude Completed and Failed to show InProgress and NeedsInput + excludes = { + providers: [], + states: [AgentSessionStatus.Completed, AgentSessionStatus.Failed], + archived: true, + read: false + }; + } + } + + // Store the filter + this.storageService.store(FILTER_STORAGE_KEY, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + + // Open the sessions view + this.commandService.executeCommand(FocusAgentSessionsAction.id); + } + /** * Render the escape button for exiting session projection mode. */ diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentStatusWidget.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentStatusWidget.css index 0b7063507a4b4..e1d663108da41 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentStatusWidget.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentStatusWidget.css @@ -271,6 +271,22 @@ Agent Status Widget - Titlebar control outline-offset: -1px; } +/* Command center toolbar (debug toolbar, etc.) */ +.agent-status-command-center-toolbar { + display: flex; + align-items: center; + -webkit-app-region: no-drag; +} + +/* Separator dot between command center toolbar and agent status pill */ +.agent-status-separator { + padding: 0 8px; + height: 100%; + opacity: 0.5; + display: flex; + align-items: center; +} + /* Status badge (separate rectangle on right of pill) */ .agent-status-badge { display: flex; @@ -283,19 +299,11 @@ Agent Status Widget - Titlebar control border: 1px solid color-mix(in srgb, var(--vscode-progressBar-background) 50%, transparent); flex-shrink: 0; -webkit-app-region: no-drag; - transition: opacity 0.2s ease-in-out, background-color 0.2s ease-in-out, border-color 0.2s ease-in-out; - opacity: 1; - /* Reserve minimum width to prevent layout shift */ - min-width: 50px; - justify-content: center; } -/* Empty badge - invisible but reserves space to prevent layout shift */ +/* Empty badge - completely hidden */ .agent-status-badge.empty { - opacity: 0; - pointer-events: none; - background-color: transparent; - border-color: transparent; + display: none; } /* Badge section (for split UI) */ @@ -305,11 +313,18 @@ Agent Status Widget - Titlebar control gap: 4px; padding: 0 8px; height: 100%; + position: relative; } /* Separator between sections */ -.agent-status-badge-section + .agent-status-badge-section { - border-left: 1px solid color-mix(in srgb, var(--vscode-progressBar-background) 40%, transparent); +.agent-status-badge-section + .agent-status-badge-section::before { + content: ''; + position: absolute; + left: 0; + top: 4px; + bottom: 4px; + width: 1px; + background-color: color-mix(in srgb, var(--vscode-progressBar-background) 40%, transparent); } /* Unread section styling */ diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 9f80ffb1cb014..511abdebc43d9 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -195,7 +195,7 @@ configurationRegistry.registerConfiguration({ }, [ChatConfiguration.AgentStatusEnabled]: { type: 'boolean', - markdownDescription: nls.localize('chat.agentsControl.enabled', "Controls whether the Agent Status is shown in the title bar command center, replacing the search box with quick access to chat sessions."), + markdownDescription: nls.localize('chat.agentsControl.enabled', "Controls whether the Agent Status is shown in the title bar command center, replacing the search box with quick access to chat sessions. Enabling this setting will automatically enable {0}.", '`#window.commandCenter#`'), default: false, tags: ['experimental'] }, @@ -262,6 +262,16 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.renderRelatedFiles', "Controls whether related files should be rendered in the chat input."), default: false }, + [ChatConfiguration.InlineReferencesStyle]: { + type: 'string', + enum: ['box', 'link'], + enumDescriptions: [ + nls.localize('chat.inlineReferences.style.box', "Display file and symbol references as boxed widgets with icons."), + nls.localize('chat.inlineReferences.style.link', "Display file and symbol references as simple blue links without icons.") + ], + description: nls.localize('chat.inlineReferences.style', "Controls how file and symbol references are displayed in chat messages."), + default: 'box' + }, 'chat.notifyWindowOnConfirmation': { type: 'boolean', description: nls.localize('chat.notifyWindowOnConfirmation', "Controls whether a chat session should present the user with an OS notification when a confirmation is needed while the window is not in focus. This includes a window badge as well as notification toast."), diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts index 9a6f9ac8a1fe4..a5f36c602f374 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts @@ -122,10 +122,16 @@ export class ChatSessionPickerActionItem extends ActionWidgetDropdownActionViewI const domChildren = []; element.classList.add('chat-session-option-picker'); + // If the current option is the default and has an icon, collapse the text and show only the icon + const isDefaultWithIcon = this.currentOption?.default && this.currentOption?.icon; + if (this.currentOption?.icon) { domChildren.push(renderIcon(this.currentOption.icon)); } - domChildren.push(dom.$('span.chat-session-option-label', undefined, this.currentOption?.name ?? localize('chat.sessionPicker.label', "Pick Option"))); + + if (!isDefaultWithIcon) { + domChildren.push(dom.$('span.chat-session-option-label', undefined, this.currentOption?.name ?? localize('chat.sessionPicker.label', "Pick Option"))); + } domChildren.push(...renderLabelWithIcons(`$(chevron-down)`)); 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 a163fb84ca511..91d6ad46898c2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts @@ -44,6 +44,8 @@ import { IChatContentInlineReference } from '../../../common/chatService/chatSer import { IChatWidgetService } from '../../chat.js'; import { chatAttachmentResourceContextKey, hookUpSymbolAttachmentDragAndContextMenu } from '../../attachments/chatAttachmentWidgets.js'; import { IChatMarkdownAnchorService } from './chatMarkdownAnchorService.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { ChatConfiguration } from '../../../common/constants.js'; type ContentRefData = | { readonly kind: 'symbol'; readonly symbol: IWorkspaceSymbol } @@ -113,6 +115,7 @@ export class InlineAnchorWidget extends Disposable { private readonly element: HTMLAnchorElement | HTMLElement, public readonly inlineReference: IChatContentInlineReference, private readonly metadata: InlineAnchorWidgetMetadata | undefined, + @IConfigurationService private readonly configurationService: IConfigurationService, @IContextKeyService originalContextKeyService: IContextKeyService, @IContextMenuService contextMenuService: IContextMenuService, @IFileService fileService: IFileService, @@ -248,6 +251,14 @@ export class InlineAnchorWidget extends Disposable { const relativeLabel = labelService.getUriLabel(location.uri, { relative: true }); this._register(hoverService.setupManagedHover(getDefaultHoverDelegate('element'), element, relativeLabel)); + // Apply link-style if configured + this.updateAppearance(); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ChatConfiguration.InlineReferencesStyle)) { + this.updateAppearance(); + } + })); + // Drag and drop if (this.data.kind !== 'symbol') { element.draggable = true; @@ -268,6 +279,12 @@ export class InlineAnchorWidget extends Disposable { return this.element; } + private updateAppearance(): void { + const style = this.configurationService.getValue(ChatConfiguration.InlineReferencesStyle); + const useLinkStyle = style === 'link'; + this.element.classList.toggle('link-style', useLinkStyle); + } + private getCellIndex(location: URI) { const notebook = this.notebookDocumentService.getNotebook(location); const index = notebook?.getCellIndex(location) ?? -1; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatInlineAnchorWidget.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatInlineAnchorWidget.css index c6de9263a3b9e..d277f2219647e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatInlineAnchorWidget.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatInlineAnchorWidget.css @@ -59,3 +59,26 @@ flex-shrink: 0; } + +/* Link-style appearance - no box, no icon */ +.chat-inline-anchor-widget.link-style, +.interactive-item-container .value .rendered-markdown .chat-inline-anchor-widget.link-style { + border: none; + background-color: transparent; + padding: 0; + margin: 0; + color: var(--vscode-textLink-foreground); +} + +.chat-inline-anchor-widget.link-style:hover { + background-color: transparent; + text-decoration: underline; +} + +.chat-inline-anchor-widget.link-style .icon { + display: none; +} + +.chat-inline-anchor-widget.link-style .icon-label { + padding: 0; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index a1ef74cebd99e..285aade3b1f53 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -1289,7 +1289,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { + this.computeVisibleOptionGroups(); this.agentSessionTypeKey.set(newSessionType); this.updateWidgetLockStateFromSessionType(newSessionType); this.refreshChatSessionPickers(); @@ -754,58 +755,18 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private createChatSessionPickerWidgets(action: MenuItemAction): (ChatSessionPickerActionItem | SearchableOptionPickerActionItem)[] { this._lastSessionPickerAction = action; - // Helper to resolve chat session context - const resolveChatSessionContext = () => { - const sessionResource = this._widget?.viewModel?.model.sessionResource; - if (!sessionResource) { - return undefined; - } - return this.chatService.getChatSessionFromInternalUri(sessionResource); - }; - - // Get all option groups for the current session type - const ctx = resolveChatSessionContext(); - const effectiveSessionType = this.getEffectiveSessionType(ctx, this.options.sessionTypePickerDelegate); - const usingDelegateSessionType = effectiveSessionType !== ctx?.chatSessionType; - const optionGroups = effectiveSessionType ? this.chatSessionsService.getOptionGroupsForSessionType(effectiveSessionType) : undefined; - if (!optionGroups || optionGroups.length === 0) { + const result = this.computeVisibleOptionGroups(); + if (!result) { return []; } + const { visibleGroupIds, optionGroups, effectiveSessionType } = result; // Clear existing widgets this.disposeSessionPickerWidgets(); - // Init option group context keys - for (const optionGroup of optionGroups) { - if (!ctx) { - continue; - } - const currentOption = this.chatSessionsService.getSessionOption(ctx.chatSessionResource, optionGroup.id); - if (currentOption) { - const optionId = typeof currentOption === 'string' ? currentOption : currentOption.id; - this.updateOptionContextKey(optionGroup.id, optionId); - } - } - const widgets: (ChatSessionPickerActionItem | SearchableOptionPickerActionItem)[] = []; for (const optionGroup of optionGroups) { - // For delegate session types, we don't require ctx or session values - if (!usingDelegateSessionType && !ctx) { - continue; - } - - const hasSessionValue = ctx ? this.chatSessionsService.getSessionOption(ctx.chatSessionResource, optionGroup.id) : undefined; - const hasItems = optionGroup.items.length > 0; - // For delegate session types, only check if items exist; otherwise check session value or items - if (!usingDelegateSessionType && !hasSessionValue && !hasItems) { - // This session does not have a value to contribute for this option group - continue; - } - if (usingDelegateSessionType && !hasItems) { - continue; - } - - if (!this.evaluateOptionGroupVisibility(optionGroup)) { + if (!visibleGroupIds.has(optionGroup.id)) { continue; } @@ -821,11 +782,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.updateOptionContextKey(optionGroup.id, option.id); this.getOrCreateOptionEmitter(optionGroup.id).fire(option); - // Only notify session options change if we have an actual session (not delegate-only) - const ctx = resolveChatSessionContext(); - if (ctx && !usingDelegateSessionType) { + // Notify session if we have one (not in welcome view before session creation) + const sessionResource = this._widget?.viewModel?.model.sessionResource; + const currentCtx = sessionResource ? this.chatService.getChatSessionFromInternalUri(sessionResource) : undefined; + if (currentCtx) { this.chatSessionsService.notifySessionOptionsChange( - ctx.chatSessionResource, + currentCtx.chatSessionResource, [{ optionId: optionGroup.id, value: option }] ).catch(err => this.logService.error(`Failed to notify extension of ${optionGroup.id} change:`, err)); } @@ -834,10 +796,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.refreshChatSessionPickers(); }, getOptionGroup: () => { - // Use the effective session type (delegate's type takes precedence) - // effectiveSessionType is guaranteed to be defined here since we've already - // validated optionGroups exist at this point - const groups = effectiveSessionType ? this.chatSessionsService.getOptionGroupsForSessionType(effectiveSessionType) : undefined; + const groups = this.chatSessionsService.getOptionGroupsForSessionType(effectiveSessionType); return groups?.find(g => g.id === optionGroup.id); } }; @@ -1396,84 +1355,118 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } /** - * Refresh all registered option groups for the current chat session. - * Fires events for each option group with their current selection. + * Computes which option groups should be visible for the current session. + * + * A picker should show if and only if: + * 1. We can determine a session type (from session context OR delegate) + * 2. That session type has option groups registered + * 3. At least one option group has items AND passes its `when` clause + * + * This method also updates the `chatSessionHasOptions` context key, which controls + * whether the picker action is shown in the toolbar via its `when` clause. + * + * @returns The result containing visible group IDs and related context, or undefined + * if there are no visible option groups */ - private refreshChatSessionPickers(): void { - const sessionResource = this._widget?.viewModel?.model.sessionResource; - const hideAll = () => { + private computeVisibleOptionGroups(): { + visibleGroupIds: Set; + optionGroups: IChatSessionProviderOptionGroup[]; + ctx: IChatSessionContext | undefined; + effectiveSessionType: string; + } | undefined { + const setNoOptions = () => { this.chatSessionHasOptions.set(false); - this.chatSessionOptionsValid.set(true); // No options means nothing to validate - this.hideAllSessionPickerWidgets(); + this.chatSessionOptionsValid.set(true); }; - if (!sessionResource) { - return hideAll(); - } - const ctx = this.chatService.getChatSessionFromInternalUri(sessionResource); - if (!ctx) { - return hideAll(); + // Step 1: Determine the session type + // - Panel/Editor: Use actual session's type (ctx available) + // - Welcome view: Use delegate's type (ctx may not exist yet) + const sessionResource = this._widget?.viewModel?.model.sessionResource; + const ctx = sessionResource ? this.chatService.getChatSessionFromInternalUri(sessionResource) : undefined; + const delegateSessionType = this.options.sessionTypePickerDelegate?.getActiveSessionProvider?.(); + const effectiveSessionType = delegateSessionType ?? ctx?.chatSessionType; + + if (!effectiveSessionType) { + setNoOptions(); + return undefined; } - const effectiveSessionType = this.getEffectiveSessionType(ctx, this.options.sessionTypePickerDelegate); - const usingDelegateSessionType = effectiveSessionType !== ctx.chatSessionType; + // Step 2: Get option groups for this session type const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(effectiveSessionType); if (!optionGroups || optionGroups.length === 0) { - return hideAll(); - } - - // For delegate-provided session types, we don't require the actual session to have options - // because the actual session might be local while the delegate selects a different type - if (!usingDelegateSessionType && !this.chatSessionsService.hasAnySessionOptions(ctx.chatSessionResource)) { - return hideAll(); + setNoOptions(); + return undefined; } - // First update all context keys with current values (before evaluating visibility) - for (const optionGroup of optionGroups) { - const currentOption = this.chatSessionsService.getSessionOption(ctx.chatSessionResource, optionGroup.id); - if (currentOption) { - const optionId = typeof currentOption === 'string' ? currentOption : currentOption.id; - this.updateOptionContextKey(optionGroup.id, optionId); - } else { - this.logService.trace(`[ChatInputPart] No session option set for group '${optionGroup.id}'`); + // Update context keys with current option values before evaluating `when` clauses. + // This ensures interdependent `when` expressions work correctly. + if (ctx) { + for (const optionGroup of optionGroups) { + const currentOption = this.chatSessionsService.getSessionOption(ctx.chatSessionResource, optionGroup.id); + if (currentOption) { + const optionId = typeof currentOption === 'string' ? currentOption : currentOption.id; + this.updateOptionContextKey(optionGroup.id, optionId); + } } } - // Compute which option groups should be visible based on when expressions + // Step 3: Filter to visible groups (has items AND passes `when` clause) const visibleGroupIds = new Set(); for (const optionGroup of optionGroups) { - if (!this.chatSessionsService.getSessionOption(ctx.chatSessionResource, optionGroup.id)) { - continue; - } - if (this.evaluateOptionGroupVisibility(optionGroup)) { + const hasItems = optionGroup.items.length > 0; + const passesWhenClause = this.evaluateOptionGroupVisibility(optionGroup); + + if (hasItems && passesWhenClause) { visibleGroupIds.add(optionGroup.id); } } - // Only show the picker if there are visible option groups if (visibleGroupIds.size === 0) { - return hideAll(); + setNoOptions(); + return undefined; } - // Validate that all selected options exist in their respective option group items + // Validate selected options exist in their respective groups let allOptionsValid = true; - for (const optionGroup of optionGroups) { - const currentOption = this.chatSessionsService.getSessionOption(ctx.chatSessionResource, optionGroup.id); - if (currentOption) { - const currentOptionId = typeof currentOption === 'string' ? currentOption : currentOption.id; - const isValidOption = optionGroup.items.some(item => item.id === currentOptionId); - if (!isValidOption) { - this.logService.trace(`[ChatInputPart] Selected option '${currentOptionId}' is not valid for group '${optionGroup.id}'`); - allOptionsValid = false; + if (ctx) { + for (const groupId of visibleGroupIds) { + const optionGroup = optionGroups.find(g => g.id === groupId); + const currentOption = this.chatSessionsService.getSessionOption(ctx.chatSessionResource, groupId); + if (optionGroup && currentOption) { + const currentOptionId = typeof currentOption === 'string' ? currentOption : currentOption.id; + if (!optionGroup.items.some(item => item.id === currentOptionId)) { + allOptionsValid = false; + break; + } } } } - this.chatSessionOptionsValid.set(allOptionsValid); this.chatSessionHasOptions.set(true); + this.chatSessionOptionsValid.set(allOptionsValid); - const currentWidgetGroupIds = new Set(this.chatSessionPickerWidgets.keys()); + return { visibleGroupIds, optionGroups, ctx, effectiveSessionType }; + } + + /** + * Refresh all registered option groups for the current chat session. + * Fires events for each option group with their current selection. + */ + private refreshChatSessionPickers(): void { + // Use the shared helper to compute visibility and update context keys + const result = this.computeVisibleOptionGroups(); + if (!result) { + // No visible options - helper already updated context keys + this.hideAllSessionPickerWidgets(); + return; + } + + const { visibleGroupIds, optionGroups, ctx } = result; + + // Check if widgets need recreation (different set of visible groups) + const currentWidgetGroupIds = new Set(this.chatSessionPickerWidgets.keys()); const needsRecreation = currentWidgetGroupIds.size !== visibleGroupIds.size || !Array.from(visibleGroupIds).every(id => currentWidgetGroupIds.has(id)); @@ -1492,20 +1485,24 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.chatSessionPickerContainer.style.display = ''; } - for (const [optionGroupId] of this.chatSessionPickerWidgets.entries()) { - const currentOption = this.chatSessionsService.getSessionOption(ctx.chatSessionResource, optionGroupId); - if (currentOption) { - const optionGroup = optionGroups.find(g => g.id === optionGroupId); - if (optionGroup) { - const currentOptionId = typeof currentOption === 'string' ? currentOption : currentOption.id; - const item = optionGroup.items.find(m => m.id === currentOptionId); - if (item) { - // If currentOption is an object (not a string ID), it represents a complete option item and should be used directly. - // Otherwise, if it's a string ID, look up the corresponding item and use that. - if (typeof currentOption === 'string') { - this.getOrCreateOptionEmitter(optionGroupId).fire(item); - } else { - this.getOrCreateOptionEmitter(optionGroupId).fire(currentOption); + // Fire option change events for existing widgets to sync their state + // (only if we have a session context - in welcome view, options aren't persisted yet) + if (ctx) { + for (const [optionGroupId] of this.chatSessionPickerWidgets.entries()) { + const currentOption = this.chatSessionsService.getSessionOption(ctx.chatSessionResource, optionGroupId); + if (currentOption) { + const optionGroup = optionGroups.find(g => g.id === optionGroupId); + if (optionGroup) { + const currentOptionId = typeof currentOption === 'string' ? currentOption : currentOption.id; + const item = optionGroup.items.find((m: IChatSessionProviderOptionItem) => m.id === currentOptionId); + if (item) { + // If currentOption is an object (not a string ID), it represents a complete option item and should be used directly. + // Otherwise, if it's a string ID, look up the corresponding item and use that. + if (typeof currentOption === 'string') { + this.getOrCreateOptionEmitter(optionGroupId).fire(item); + } else { + this.getOrCreateOptionEmitter(optionGroupId).fire(currentOption); + } } } } @@ -1630,6 +1627,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge render(container: HTMLElement, initialValue: string, widget: IChatWidget) { this._widget = widget; + this.computeVisibleOptionGroups(); this._register(widget.onDidChangeViewModel(() => { // Update agentSessionType when view model changes diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts index fffb5a27aa879..3f8e0e9d7a987 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts @@ -43,6 +43,7 @@ export interface IChatEditorOptions extends IEditorOptions { preferred?: string; fallback?: string; }; + expanded?: boolean; } export class ChatEditor extends EditorPane { diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 10963bf00ea55..7824c4fcf272e 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -47,7 +47,7 @@ import { IChatModelReference, IChatService } from '../../../common/chatService/c import { IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; import { LocalChatSessionUri, getChatSessionType } from '../../../common/model/chatUri.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../../common/constants.js'; -import { AgentSessionsControl } from '../../agentSessions/agentSessionsControl.js'; +import { AgentSessionsControl, AgentSessionsControlSource } from '../../agentSessions/agentSessionsControl.js'; import { AgentSessionsListDelegate } from '../../agentSessions/agentSessionsViewer.js'; import { ACTION_ID_NEW_CHAT } from '../../actions/chatActions.js'; import { ChatWidget } from '../../widget/chatWidget.js'; @@ -394,6 +394,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { // Sessions Control this.sessionsControlContainer = append(sessionsContainer, $('.agent-sessions-control-container')); const sessionsControl = this.sessionsControl = this._register(this.instantiationService.createInstance(AgentSessionsControl, this.sessionsControlContainer, { + source: AgentSessionsControlSource.ChatViewPane, filter: sessionsFilter, overrideStyles: this.getLocationBasedColors().listOverrideStyles, getHoverPosition: () => { diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 2a5fc753855db..736f368942072 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -109,6 +109,8 @@ export namespace ChatContextKeys { export const isKatexMathElement = new RawContextKey('chatIsKatexMathElement', false, { type: 'boolean', description: localize('chatIsKatexMathElement', "True when focusing a KaTeX math element.") }); export const inAgentSessionProjection = new RawContextKey('chatInAgentSessionProjection', false, { type: 'boolean', description: localize('chatInAgentSessionProjection', "True when the workbench is in agent session projection mode for reviewing an agent session.") }); + + export const agentStatusHasNotifications = new RawContextKey('agentStatusHasNotifications', false, { type: 'boolean', description: localize('agentStatusHasNotifications', "True when the agent status widget has unread or in-progress sessions.") }); } export namespace ChatContextKeyExprs { diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index ba407ce37febf..075e980921f11 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -16,6 +16,7 @@ export enum ChatConfiguration { ExtensionToolsEnabled = 'chat.extensionTools.enabled', RepoInfoEnabled = 'chat.repoInfo.enabled', EditRequests = 'chat.editRequests', + InlineReferencesStyle = 'chat.inlineReferences.style', GlobalAutoApprove = 'chat.tools.global.autoApprove', AutoApproveEdits = 'chat.tools.edits.autoApprove', AutoApprovedUrls = 'chat.tools.urls.autoApprove', diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts index 4363f93ea7c8c..531c838fe0489 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts @@ -30,7 +30,7 @@ import { ChatModel, ISerializableChatData, ISerializableChatDataIn, ISerializabl import { ChatSessionOperationLog } from './chatSessionOperationLog.js'; import { LocalChatSessionUri } from './chatUri.js'; -const maxPersistedSessions = 25; +const maxPersistedSessions = 50; const ChatIndexStorageKey = 'chat.ChatSessionStore.index'; const ChatTransferIndexStorageKey = 'ChatSessionStore.transferIndex'; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts index 59d21a4755aaa..33094047675e1 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts @@ -346,6 +346,9 @@ export class PromptBody { // Match markdown links: [text](link) const linkMatch = line.matchAll(/\[(.*?)\]\((.+?)\)/g); for (const match of linkMatch) { + if (match.index > 0 && line[match.index - 1] === '!') { + continue; // skip image links + } const linkEndOffset = match.index + match[0].length - 1; // before the parenthesis const linkStartOffset = match.index + match[0].length - match[2].length - 1; const range = new Range(i + 1, linkStartOffset + 1, i + 1, linkEndOffset + 1); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts index b08976de84bab..cb5fed7747bd0 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts @@ -22,7 +22,7 @@ suite('NewPromptsParser', () => { /* 04 */`tools: ['tool1', 'tool2']`, /* 05 */'---', /* 06 */'This is an agent test.', - /* 07 */'Here is a #tool:tool1 variable (and one with closing parenthesis after: #tool:tool-2) and a #file:./reference1.md as well as a [reference](./reference2.md).', + /* 07 */'Here is a #tool:tool1 variable (and one with closing parenthesis after: #tool:tool-2) and a #file:./reference1.md as well as a [reference](./reference2.md) and an image ![image](./image.png).', ].join('\n'); const result = new PromptFileParser().parse(uri, content); assert.deepEqual(result.uri, uri); @@ -42,7 +42,7 @@ suite('NewPromptsParser', () => { ]); assert.deepEqual(result.body.range, { startLineNumber: 6, startColumn: 1, endLineNumber: 8, endColumn: 1 }); assert.equal(result.body.offset, 75); - assert.equal(result.body.getContent(), 'This is an agent test.\nHere is a #tool:tool1 variable (and one with closing parenthesis after: #tool:tool-2) and a #file:./reference1.md as well as a [reference](./reference2.md).'); + assert.equal(result.body.getContent(), 'This is an agent test.\nHere is a #tool:tool1 variable (and one with closing parenthesis after: #tool:tool-2) and a #file:./reference1.md as well as a [reference](./reference2.md) and an image ![image](./image.png).'); assert.deepEqual(result.body.fileReferences, [ { range: new Range(7, 99, 7, 114), content: './reference1.md', isMarkdownLink: false }, diff --git a/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts index b820d539d1f83..ed1689fb2e2c6 100644 --- a/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts +++ b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts @@ -40,7 +40,7 @@ import { AgentSessionsWelcomeEditorOptions, AgentSessionsWelcomeInput } from './ import { IChatService } from '../../chat/common/chatService/chatService.js'; import { IChatModel } from '../../chat/common/model/chatModel.js'; import { ISessionTypePickerDelegate } from '../../chat/browser/chat.js'; -import { AgentSessionsControl, IAgentSessionsControlOptions } from '../../chat/browser/agentSessions/agentSessionsControl.js'; +import { AgentSessionsControl, AgentSessionsControlSource, IAgentSessionsControlOptions } from '../../chat/browser/agentSessions/agentSessionsControl.js'; import { IAgentSessionsFilter } from '../../chat/browser/agentSessions/agentSessionsViewer.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { IResolvedWalkthrough, IWalkthroughsService } from '../../welcomeGettingStarted/browser/gettingStartedService.js'; @@ -222,6 +222,11 @@ export class AgentSessionsWelcomePage extends EditorPane { this.chatWidget.render(chatWidgetContainer); this.chatWidget.setVisible(true); + // Schedule initial layout at next animation frame to ensure proper input sizing + this.contentDisposables.add(scheduleAtNextAnimationFrame(getWindow(chatWidgetContainer), () => { + this.layoutChatWidget(); + })); + // Start a chat session so the widget has a viewModel // This is necessary for actions like mode switching to work properly this.chatModelRef = this.chatService.startSession(ChatAgentLocation.Chat); @@ -275,6 +280,7 @@ export class AgentSessionsWelcomePage extends EditorPane { filter, getHoverPosition: () => HoverPosition.BELOW, trackActiveEditorSession: () => false, + source: AgentSessionsControlSource.WelcomeView, }; this.sessionsControl = this.sessionsControlDisposables.add(this.instantiationService.createInstance( @@ -291,7 +297,12 @@ export class AgentSessionsWelcomePage extends EditorPane { // "Open Agent Sessions" link const openButton = append(container, $('button.agentSessionsWelcome-openSessionsButton')); openButton.textContent = localize('openAgentSessions', "Open Agent Sessions"); - openButton.onclick = () => this.commandService.executeCommand('workbench.action.chat.open'); + openButton.onclick = () => { + this.commandService.executeCommand('workbench.action.chat.open'); + if (!this.layoutService.isAuxiliaryBarMaximized()) { + this.layoutService.toggleMaximizedAuxiliaryBar(); + } + }; } private buildWalkthroughs(container: HTMLElement): void { @@ -381,13 +392,8 @@ export class AgentSessionsWelcomePage extends EditorPane { this.container.style.height = `${dimension.height}px`; this.container.style.width = `${dimension.width}px`; - // Layout chat widget with height for input area - if (this.chatWidget) { - const chatWidth = Math.min(800, dimension.width - 80); - // Use a reasonable height for the input part - the CSS will hide the list area - const inputHeight = 150; - this.chatWidget.layout(inputHeight, chatWidth); - } + // Layout chat widget + this.layoutChatWidget(); // Layout sessions control this.layoutSessionsControl(); @@ -395,6 +401,17 @@ export class AgentSessionsWelcomePage extends EditorPane { this.scrollableElement?.scanDomNode(); } + private layoutChatWidget(): void { + if (!this.chatWidget || !this.lastDimension) { + return; + } + + const chatWidth = Math.min(800, this.lastDimension.width - 80); + // Use a reasonable height for the input part - the CSS will hide the list area + const inputHeight = 150; + this.chatWidget.layout(inputHeight, chatWidth); + } + private layoutSessionsControl(): void { if (!this.sessionsControl || !this.sessionsControlContainer || !this.lastDimension) { return;