diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityActions.ts index 51badfa9692ae..118e7128b4ecb 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityActions.ts @@ -81,7 +81,10 @@ class OpenThinkingAccessibleViewAction extends Action2 { f1: true, keybinding: { weight: KeybindingWeight.WorkbenchContrib, - primary: KeyMod.Alt | KeyMod.Shift | KeyCode.F3, + primary: KeyMod.Alt | KeyMod.Shift | KeyCode.F2, + linux: { + primary: KeyMod.Alt | KeyMod.Shift | KeyCode.F3, + }, when: ChatContextKeys.inChatSession } }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 884cc180f4c83..0d297efe2ffdf 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -479,9 +479,13 @@ export class ChatSessionPrimaryPickerAction extends Action2 { order: 4, group: 'navigation', when: - ContextKeyExpr.and( + ContextKeyExpr.or( + ChatContextKeys.chatSessionHasModels, ChatContextKeys.lockedToCodingAgent, - ChatContextKeys.chatSessionHasModels + ContextKeyExpr.and( + ChatContextKeys.inAgentSessionsWelcome, + ChatContextKeys.chatSessionType.notEqualsTo('local') + ) ) } }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index 20fd654cbc5ce..a72ab1cd2e6df 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -71,6 +71,7 @@ export interface IAgentSessionsControl { refresh(): void; openFind(): void; reveal(sessionResource: URI): void; + setGridMarginOffset(offset: number): void; } export const agentSessionReadIndicatorForeground = registerColor( diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index 0777b28e33ce5..1022548399abb 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -313,4 +313,10 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo this.sessionsList.setFocus([session]); this.sessionsList.setSelection([session]); } + + setGridMarginOffset(offset: number): void { + if (this.sessionsContainer) { + this.sessionsContainer.style.marginBottom = `-${offset}px`; + } + } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentStatusWidget.ts index 9bdcdd03e0298..4c3887db7ba36 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentStatusWidget.ts @@ -17,7 +17,7 @@ import { ICommandService } from '../../../../../platform/commands/common/command import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; import { ExitAgentSessionProjectionAction } from './agentSessionProjectionActions.js'; import { IAgentSessionsService } from './agentSessionsService.js'; -import { IAgentSession, isSessionInProgressStatus } from './agentSessionsModel.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 { ILabelService } from '../../../../../platform/label/common/label.js'; @@ -55,6 +55,9 @@ export class AgentStatusWidget extends BaseActionViewItem { /** The currently displayed in-progress session (if any) - clicking pill opens this */ private _displayedSession: IAgentSession | undefined; + /** Cached render state to avoid unnecessary DOM rebuilds */ + private _lastRenderState: string | undefined; + constructor( action: IAction, options: IBaseActionViewItemOptions | undefined, @@ -113,6 +116,45 @@ export class AgentStatusWidget extends BaseActionViewItem { return; } + // Compute current render state to avoid unnecessary DOM rebuilds + const mode = this.agentStatusService.mode; + const sessionInfo = this.agentStatusService.sessionInfo; + const { activeSessions, unreadSessions, attentionNeededSessions } = this._getSessionStats(); + + // Get attention session info for state computation + const attentionSession = attentionNeededSessions.length > 0 + ? [...attentionNeededSessions].sort((a, b) => { + const timeA = a.timing.lastRequestStarted ?? a.timing.created; + const timeB = b.timing.lastRequestStarted ?? b.timing.created; + return timeB - timeA; + })[0] + : undefined; + + const attentionText = attentionSession?.description + ? (typeof attentionSession.description === 'string' + ? attentionSession.description + : renderAsPlaintext(attentionSession.description)) + : attentionSession?.label; + + const label = this._getLabel(); + + // Build state key for comparison + const stateKey = JSON.stringify({ + mode, + sessionTitle: sessionInfo?.title, + activeCount: activeSessions.length, + unreadCount: unreadSessions.length, + attentionCount: attentionNeededSessions.length, + attentionText, + label, + }); + + // Skip re-render if state hasn't changed + if (this._lastRenderState === stateKey) { + return; + } + this._lastRenderState = stateKey; + // Clear existing content reset(this._container); @@ -128,80 +170,113 @@ export class AgentStatusWidget extends BaseActionViewItem { } } + // #region Session Statistics + + /** + * Get computed session statistics for rendering. + */ + private _getSessionStats(): { + activeSessions: IAgentSession[]; + unreadSessions: IAgentSession[]; + attentionNeededSessions: IAgentSession[]; + hasActiveSessions: boolean; + hasUnreadSessions: boolean; + hasAttentionNeeded: boolean; + } { + const sessions = this.agentSessionsService.model.sessions; + const activeSessions = sessions.filter(s => isSessionInProgressStatus(s.status)); + const unreadSessions = sessions.filter(s => !s.isRead()); + // Sessions that need user attention (approval/confirmation/input) + const attentionNeededSessions = sessions.filter(s => s.status === AgentSessionStatus.NeedsInput); + + return { + activeSessions, + unreadSessions, + attentionNeededSessions, + hasActiveSessions: activeSessions.length > 0, + hasUnreadSessions: unreadSessions.length > 0, + hasAttentionNeeded: attentionNeededSessions.length > 0, + }; + } + + // #endregion + + // #region Mode Renderers + private _renderChatInputMode(disposables: DisposableStore): void { if (!this._container) { return; } - // Get agent session statistics - const sessions = this.agentSessionsService.model.sessions; - const activeSessions = sessions.filter(s => isSessionInProgressStatus(s.status)); - const unreadSessions = sessions.filter(s => !s.isRead()); - const hasActiveSessions = activeSessions.length > 0; - const hasUnreadSessions = unreadSessions.length > 0; + const { activeSessions, unreadSessions, attentionNeededSessions, hasAttentionNeeded } = this._getSessionStats(); - // Create pill - add 'has-active' class when sessions are in progress + // Create pill const pill = $('div.agent-status-pill.chat-input-mode'); - if (hasActiveSessions) { - pill.classList.add('has-active'); - } else if (hasUnreadSessions) { - pill.classList.add('has-unread'); + if (hasAttentionNeeded) { + pill.classList.add('needs-attention'); } pill.setAttribute('role', 'button'); pill.setAttribute('aria-label', localize('openQuickChat', "Open Quick Chat")); pill.tabIndex = 0; this._container.appendChild(pill); - // Left side indicator (status) - const leftIndicator = $('span.agent-status-indicator'); - if (hasActiveSessions) { - // Running indicator when there are active sessions - const runningIcon = $('span.agent-status-icon'); - reset(runningIcon, renderIcon(Codicon.sessionInProgress)); - leftIndicator.appendChild(runningIcon); - const runningCount = $('span.agent-status-text'); - runningCount.textContent = String(activeSessions.length); - leftIndicator.appendChild(runningCount); - } else if (hasUnreadSessions) { - // Unread indicator when there are unread sessions - const unreadIcon = $('span.agent-status-icon'); - reset(unreadIcon, renderIcon(Codicon.circleFilled)); - leftIndicator.appendChild(unreadIcon); - const unreadCount = $('span.agent-status-text'); - unreadCount.textContent = String(unreadSessions.length); - leftIndicator.appendChild(unreadCount); + // Left icon container (sparkle by default, report+count when attention needed, search on hover) + const leftIcon = $('span.agent-status-left-icon'); + if (hasAttentionNeeded) { + // Show report icon + count when sessions need attention + const reportIcon = renderIcon(Codicon.report); + const countSpan = $('span.agent-status-attention-count'); + countSpan.textContent = String(attentionNeededSessions.length); + reset(leftIcon, reportIcon, countSpan); + leftIcon.classList.add('has-attention'); } else { - // Keyboard shortcut when idle (show quick chat keybinding - matches click action) - const kb = this.keybindingService.lookupKeybinding(ACTION_ID)?.getLabel(); - if (kb) { - const kbLabel = $('span.agent-status-keybinding'); - kbLabel.textContent = kb; - leftIndicator.appendChild(kbLabel); - } + reset(leftIcon, renderIcon(Codicon.searchSparkle)); } - pill.appendChild(leftIndicator); + pill.appendChild(leftIcon); - // Show label - either progress from most recent active session, or workspace name + // Label (workspace name by default, placeholder on hover) + // Show attention progress or default label const label = $('span.agent-status-label'); - const { session: activeSession, progress: progressText } = this._getMostRecentActiveSession(activeSessions); - this._displayedSession = activeSession; + const { session: attentionSession, progress: progressText } = this._getSessionNeedingAttention(attentionNeededSessions); + this._displayedSession = attentionSession; + + const defaultLabel = progressText ?? this._getLabel(); + if (progressText) { - // Show progress with fade-in animation label.classList.add('has-progress'); - label.textContent = progressText; - } else { - label.textContent = this._getLabel(); } + + const hoverLabel = localize('askAnythingPlaceholder', "Ask anything or describe what to build next"); + + label.textContent = defaultLabel; pill.appendChild(label); - // Send icon (right side) - only show when not streaming progress + // Send icon (hidden by default, shown on hover - only when not showing attention message) + const sendIcon = $('span.agent-status-send'); + reset(sendIcon, renderIcon(Codicon.send)); + sendIcon.classList.add('hidden'); + pill.appendChild(sendIcon); + + // Hover behavior - swap icon and label (only when showing default state). + // When progressText is defined (e.g. sessions need attention), keep the attention/progress + // message visible and do not replace it with the generic placeholder on hover. if (!progressText) { - const sendIcon = $('span.agent-status-send'); - reset(sendIcon, renderIcon(Codicon.send)); - pill.appendChild(sendIcon); + disposables.add(addDisposableListener(pill, EventType.MOUSE_ENTER, () => { + reset(leftIcon, renderIcon(Codicon.searchSparkle)); + leftIcon.classList.remove('has-attention'); + label.textContent = hoverLabel; + label.classList.remove('has-progress'); + sendIcon.classList.remove('hidden'); + })); + + disposables.add(addDisposableListener(pill, EventType.MOUSE_LEAVE, () => { + reset(leftIcon, renderIcon(Codicon.searchSparkle)); + label.textContent = defaultLabel; + sendIcon.classList.add('hidden'); + })); } - // Setup hover - show session name when displaying progress, otherwise show keybinding + // Setup hover tooltip const hoverDelegate = getDefaultHoverDelegate('mouse'); disposables.add(this.hoverService.setupManagedHover(hoverDelegate, pill, () => { if (this._displayedSession) { @@ -229,8 +304,8 @@ export class AgentStatusWidget extends BaseActionViewItem { } })); - // Search button (right of pill) - this._renderSearchButton(disposables); + // Status badge (separate rectangle on right) - always rendered for smooth transitions + this._renderStatusBadge(disposables, activeSessions, unreadSessions); } private _renderSessionMode(disposables: DisposableStore): void { @@ -238,68 +313,53 @@ export class AgentStatusWidget extends BaseActionViewItem { return; } + const { activeSessions, unreadSessions } = this._getSessionStats(); + const pill = $('div.agent-status-pill.session-mode'); this._container.appendChild(pill); - // Session title (left/center) + // Search button (left side, inside pill) + this._renderSearchButton(disposables, pill); + + // Session title (center) const titleLabel = $('span.agent-status-title'); const sessionInfo = this.agentStatusService.sessionInfo; titleLabel.textContent = sessionInfo?.title ?? localize('agentSessionProjection', "Agent Session Projection"); pill.appendChild(titleLabel); - // Escape button (right side) - serves as both keybinding hint and close button - const escButton = $('span.agent-status-esc-button'); - escButton.textContent = 'Esc'; - escButton.setAttribute('role', 'button'); - escButton.setAttribute('aria-label', localize('exitAgentSessionProjection', "Exit Agent Session Projection")); - escButton.tabIndex = 0; - pill.appendChild(escButton); + // Escape button (right side) + this._renderEscapeButton(disposables, pill); - // Setup hovers + // Setup pill hover const hoverDelegate = getDefaultHoverDelegate('mouse'); - disposables.add(this.hoverService.setupManagedHover(hoverDelegate, escButton, localize('exitAgentSessionProjectionTooltip', "Exit Agent Session Projection (Escape)"))); disposables.add(this.hoverService.setupManagedHover(hoverDelegate, pill, () => { const sessionInfo = this.agentStatusService.sessionInfo; return sessionInfo ? localize('agentSessionProjectionTooltip', "Agent Session Projection: {0}", sessionInfo.title) : localize('agentSessionProjection', "Agent Session Projection"); })); - // Esc button click handler - disposables.add(addDisposableListener(escButton, EventType.MOUSE_DOWN, (e) => { - e.preventDefault(); - e.stopPropagation(); - this.commandService.executeCommand(ExitAgentSessionProjectionAction.ID); - })); - - disposables.add(addDisposableListener(escButton, EventType.CLICK, (e) => { - e.preventDefault(); - e.stopPropagation(); - this.commandService.executeCommand(ExitAgentSessionProjectionAction.ID); - })); + // Status badge (separate rectangle on right) - always rendered for smooth transitions + this._renderStatusBadge(disposables, activeSessions, unreadSessions); + } - // Esc button keyboard handler - disposables.add(addDisposableListener(escButton, EventType.KEY_DOWN, (e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); - this.commandService.executeCommand(ExitAgentSessionProjectionAction.ID); - } - })); + // #endregion - // Search button (right of pill) - this._renderSearchButton(disposables); - } + // #region Reusable Components - private _renderSearchButton(disposables: DisposableStore): void { - if (!this._container) { + /** + * Render the search button. If parent is provided, appends to parent; otherwise appends to container. + */ + private _renderSearchButton(disposables: DisposableStore, parent?: HTMLElement): void { + const container = parent ?? this._container; + if (!container) { return; } const searchButton = $('span.agent-status-search'); - reset(searchButton, renderIcon(Codicon.search)); + reset(searchButton, renderIcon(Codicon.searchSparkle)); searchButton.setAttribute('role', 'button'); searchButton.setAttribute('aria-label', localize('openQuickOpen', "Open Quick Open")); searchButton.tabIndex = 0; - this._container.appendChild(searchButton); + container.appendChild(searchButton); // Setup hover const hoverDelegate = getDefaultHoverDelegate('mouse'); @@ -326,6 +386,110 @@ 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. + */ + private _renderStatusBadge(disposables: DisposableStore, activeSessions: IAgentSession[], unreadSessions: IAgentSession[]): void { + if (!this._container) { + return; + } + + const hasActiveSessions = activeSessions.length > 0; + const hasUnreadSessions = unreadSessions.length > 0; + const hasContent = hasActiveSessions || hasUnreadSessions; + + const badge = $('div.agent-status-badge'); + if (!hasContent) { + badge.classList.add('empty'); + } + this._container.appendChild(badge); + + // Unread section (blue dot + count) + if (hasUnreadSessions) { + const unreadSection = $('span.agent-status-badge-section.unread'); + const unreadIcon = $('span.agent-status-icon'); + reset(unreadIcon, renderIcon(Codicon.circleFilled)); + unreadSection.appendChild(unreadIcon); + const unreadCount = $('span.agent-status-text'); + unreadCount.textContent = String(unreadSessions.length); + unreadSection.appendChild(unreadCount); + badge.appendChild(unreadSection); + } + + // In-progress section (session-in-progress icon + count) + if (hasActiveSessions) { + const activeSection = $('span.agent-status-badge-section.active'); + const runningIcon = $('span.agent-status-icon'); + reset(runningIcon, renderIcon(Codicon.sessionInProgress)); + activeSection.appendChild(runningIcon); + const runningCount = $('span.agent-status-text'); + runningCount.textContent = String(activeSessions.length); + activeSection.appendChild(runningCount); + badge.appendChild(activeSection); + } + + // Setup hover with combined tooltip + const hoverDelegate = getDefaultHoverDelegate('mouse'); + disposables.add(this.hoverService.setupManagedHover(hoverDelegate, badge, () => { + const parts: string[] = []; + if (hasUnreadSessions) { + parts.push(unreadSessions.length === 1 + ? localize('unreadSessionsTooltip1', "{0} unread session", unreadSessions.length) + : localize('unreadSessionsTooltip', "{0} unread sessions", unreadSessions.length)); + } + if (hasActiveSessions) { + parts.push(activeSessions.length === 1 + ? localize('activeSessionsTooltip1', "{0} session in progress", activeSessions.length) + : localize('activeSessionsTooltip', "{0} sessions in progress", activeSessions.length)); + } + return parts.join(', '); + })); + } + + /** + * Render the escape button for exiting session projection mode. + */ + private _renderEscapeButton(disposables: DisposableStore, parent: HTMLElement): void { + const escButton = $('span.agent-status-esc-button'); + escButton.textContent = 'Esc'; + escButton.setAttribute('role', 'button'); + escButton.setAttribute('aria-label', localize('exitAgentSessionProjection', "Exit Agent Session Projection")); + escButton.tabIndex = 0; + parent.appendChild(escButton); + + // Setup hover + const hoverDelegate = getDefaultHoverDelegate('mouse'); + disposables.add(this.hoverService.setupManagedHover(hoverDelegate, escButton, localize('exitAgentSessionProjectionTooltip', "Exit Agent Session Projection (Escape)"))); + + // Click handler + disposables.add(addDisposableListener(escButton, EventType.MOUSE_DOWN, (e) => { + e.preventDefault(); + e.stopPropagation(); + this.commandService.executeCommand(ExitAgentSessionProjectionAction.ID); + })); + + disposables.add(addDisposableListener(escButton, EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this.commandService.executeCommand(ExitAgentSessionProjectionAction.ID); + })); + + // Keyboard handler + disposables.add(addDisposableListener(escButton, EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this.commandService.executeCommand(ExitAgentSessionProjectionAction.ID); + } + })); + } + + // #endregion + + // #region Click Handlers + /** * Handle pill click - opens the displayed session if showing progress, otherwise executes default action */ @@ -337,17 +501,21 @@ export class AgentStatusWidget extends BaseActionViewItem { } } + // #endregion + + // #region Session Helpers + /** - * Get the most recently interacted active session and its progress text. - * Returns undefined session if no active sessions. + * Get the session most urgently needing user attention (approval/confirmation/input). + * Returns undefined if no sessions need attention. */ - private _getMostRecentActiveSession(activeSessions: IAgentSession[]): { session: IAgentSession | undefined; progress: string | undefined } { - if (activeSessions.length === 0) { + private _getSessionNeedingAttention(attentionNeededSessions: IAgentSession[]): { session: IAgentSession | undefined; progress: string | undefined } { + if (attentionNeededSessions.length === 0) { return { session: undefined, progress: undefined }; } // Sort by most recently started request - const sorted = [...activeSessions].sort((a, b) => { + const sorted = [...attentionNeededSessions].sort((a, b) => { const timeA = a.timing.lastRequestStarted ?? a.timing.created; const timeB = b.timing.lastRequestStarted ?? b.timing.created; return timeB - timeA; @@ -355,7 +523,7 @@ export class AgentStatusWidget extends BaseActionViewItem { const mostRecent = sorted[0]; if (!mostRecent.description) { - return { session: mostRecent, progress: undefined }; + return { session: mostRecent, progress: mostRecent.label }; } // Convert markdown to plain text if needed @@ -366,6 +534,10 @@ export class AgentStatusWidget extends BaseActionViewItem { return { session: mostRecent, progress }; } + // #endregion + + // #region Label Helpers + /** * Compute the label to display, matching the command center behavior. * Includes prefix and suffix decorations (remote host, extension dev host, etc.) @@ -419,4 +591,6 @@ export class AgentStatusWidget extends BaseActionViewItem { return { prefix, suffix }; } + + // #endregion } 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 541e3bf02a5ad..0b7063507a4b4 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentStatusWidget.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentStatusWidget.css @@ -76,7 +76,7 @@ Agent Status Widget - Titlebar control } .agent-status-pill.chat-input-mode.has-active .agent-status-label { - color: var(--vscode-progressBar-background); + color: var(--vscode-foreground); opacity: 1; } @@ -85,6 +85,22 @@ Agent Status Widget - Titlebar control font-size: 8px; } +/* Needs attention state - session requires user approval/confirmation/input */ +.agent-status-pill.chat-input-mode.needs-attention { + background-color: color-mix(in srgb, var(--vscode-progressBar-background) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--vscode-progressBar-background) 50%, transparent); +} + +.agent-status-pill.chat-input-mode.needs-attention:hover { + background-color: color-mix(in srgb, var(--vscode-progressBar-background) 25%, transparent); + border-color: color-mix(in srgb, var(--vscode-progressBar-background) 70%, transparent); +} + +.agent-status-pill.chat-input-mode.needs-attention .agent-status-label { + color: var(--vscode-foreground); + opacity: 1; +} + /* Session mode (viewing a session) */ .agent-status-pill.session-mode { background-color: color-mix(in srgb, var(--vscode-progressBar-background) 15%, transparent); @@ -111,7 +127,7 @@ Agent Status Widget - Titlebar control /* Progress label - fade in animation when showing session progress */ .agent-status-label.has-progress { animation: agentStatusFadeIn 0.3s ease-out; - color: var(--vscode-progressBar-background); + color: var(--vscode-foreground); opacity: 1; } @@ -159,6 +175,11 @@ Agent Status Widget - Titlebar control align-items: center; color: var(--vscode-foreground); opacity: 0.7; + flex-shrink: 0; +} + +.agent-status-send.hidden { + display: none; } .agent-status-pill.has-active .agent-status-send { @@ -166,6 +187,28 @@ Agent Status Widget - Titlebar control opacity: 1; } +/* Left icon (sparkle default, report+count when attention needed, search on hover) */ +.agent-status-left-icon { + display: flex; + align-items: center; + justify-content: center; + gap: 3px; + color: var(--vscode-foreground); + opacity: 0.7; + flex-shrink: 0; +} + +/* Left icon with attention - show report icon + count */ +.agent-status-left-icon.has-attention { + color: var(--vscode-foreground); + opacity: 1; +} + +.agent-status-left-icon.has-attention .agent-status-attention-count { + font-size: 11px; + font-weight: 500; +} + /* Session title */ .agent-status-title { flex: 1; @@ -208,26 +251,78 @@ Agent Status Widget - Titlebar control outline-offset: 1px; } -/* Search button (right of pill) */ +/* Search button (inside pill on left) */ .agent-status-search { display: flex; align-items: center; justify-content: center; - width: 22px; - height: 22px; - border-radius: 4px; - cursor: pointer; color: var(--vscode-foreground); opacity: 0.7; -webkit-app-region: no-drag; + flex-shrink: 0; } .agent-status-search:hover { opacity: 1; - background-color: var(--vscode-commandCenter-activeBackground, rgba(0, 0, 0, 0.1)); } .agent-status-search:focus { outline: 1px solid var(--vscode-focusBorder); outline-offset: -1px; } + +/* Status badge (separate rectangle on right of pill) */ +.agent-status-badge { + display: flex; + align-items: center; + gap: 0; + height: 22px; + border-radius: 6px; + overflow: hidden; + background-color: color-mix(in srgb, var(--vscode-progressBar-background) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--vscode-progressBar-background) 50%, transparent); + 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 */ +.agent-status-badge.empty { + opacity: 0; + pointer-events: none; + background-color: transparent; + border-color: transparent; +} + +/* Badge section (for split UI) */ +.agent-status-badge-section { + display: flex; + align-items: center; + gap: 4px; + padding: 0 8px; + height: 100%; +} + +/* 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); +} + +/* Unread section styling */ +.agent-status-badge-section.unread { + color: var(--vscode-foreground); +} + +.agent-status-badge-section.unread .agent-status-icon { + font-size: 8px; + color: var(--vscode-notificationsInfoIcon-foreground); +} + +/* Active/in-progress section styling */ +.agent-status-badge-section.active { + color: var(--vscode-foreground); +} diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 2edca9a2966bd..d4ac8d70efa68 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -27,6 +27,27 @@ import { IChatEditorOptions } from './widgetHosts/editor/chatEditor.js'; import { ChatInputPart } from './widget/input/chatInputPart.js'; import { ChatWidget, IChatWidgetContrib } from './widget/chatWidget.js'; import { ICodeBlockActionContext } from './widget/chatContentParts/codeBlockPart.js'; +import { AgentSessionProviders } from './agentSessions/agentSessions.js'; + +/** + * Delegate interface for the session target picker. + * Allows consumers to get and optionally set the active session provider. + */ +export interface ISessionTypePickerDelegate { + getActiveSessionProvider(): AgentSessionProviders | undefined; + /** + * Optional setter for the active session provider. + * When provided, the picker will call this instead of executing the openNewChatSessionInPlace command. + * This allows the welcome view to maintain independent state from the main chat panel. + */ + setActiveSessionProvider?(provider: AgentSessionProviders): void; + /** + * Optional event that fires when the active session provider changes. + * When provided, listeners (like chatInputPart) can react to session type changes + * and update pickers accordingly. + */ + onDidChangeActiveSessionProvider?: Event; +} export const IChatWidgetService = createDecorator('chatWidgetService'); @@ -183,6 +204,13 @@ export interface IChatWidgetViewOptions { supportsChangingModes?: boolean; dndContainer?: HTMLElement; defaultMode?: IChatMode; + /** + * Optional delegate for the session target picker. + * When provided, allows the widget to maintain independent state for the selected session type. + * This is useful for contexts like the welcome view where target selection should not + * immediately open a new session. + */ + sessionTypePickerDelegate?: ISessionTypePickerDelegate; } export interface IChatViewViewContext { @@ -276,6 +304,7 @@ export interface IChatWidget { clear(): Promise; getViewState(): IChatModelInputState | undefined; lockToCodingAgent(name: string, displayName: string, agentId?: string): void; + unlockFromCodingAgent(): void; handleDelegationExitIfNeeded(sourceAgent: Pick | undefined, targetAgent: IChatAgentData | undefined): Promise; delegateScrollFromMouseWheelEvent(event: IMouseWheelEvent): void; diff --git a/src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts b/src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts index 61636774433ba..695e5151d6bab 100644 --- a/src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts +++ b/src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts @@ -103,6 +103,10 @@ function determineChangeType(resource: ISCMResource, groupId: string): 'added' | /** * Generates a unified diff string compatible with `git apply`. + * + * Note: This implementation has a known limitation - if the only change between + * files is the presence/absence of a trailing newline (content otherwise identical), + * no diff will be generated because VS Code's diff algorithm treats the lines as equal. */ async function generateUnifiedDiff( fileService: IFileService, @@ -137,6 +141,21 @@ async function generateUnifiedDiff( const originalLines = originalContent.split('\n'); const modifiedLines = modifiedContent.split('\n'); + + // Track whether files end with newline for git apply compatibility + // split('\n') on "line1\nline2\n" gives ["line1", "line2", ""] + // split('\n') on "line1\nline2" gives ["line1", "line2"] + const originalEndsWithNewline = originalContent.length > 0 && originalContent.endsWith('\n'); + const modifiedEndsWithNewline = modifiedContent.length > 0 && modifiedContent.endsWith('\n'); + + // Remove trailing empty element if file ends with newline + if (originalEndsWithNewline && originalLines.length > 0 && originalLines[originalLines.length - 1] === '') { + originalLines.pop(); + } + if (modifiedEndsWithNewline && modifiedLines.length > 0 && modifiedLines[modifiedLines.length - 1] === '') { + modifiedLines.pop(); + } + const diffLines: string[] = []; const aPath = changeType === 'added' ? '/dev/null' : `a/${relPath}`; const bPath = changeType === 'deleted' ? '/dev/null' : `b/${relPath}`; @@ -150,6 +169,9 @@ async function generateUnifiedDiff( for (const line of modifiedLines) { diffLines.push(`+${line}`); } + if (!modifiedEndsWithNewline) { + diffLines.push('\\ No newline at end of file'); + } } } else if (changeType === 'deleted') { if (originalLines.length > 0) { @@ -157,9 +179,12 @@ async function generateUnifiedDiff( for (const line of originalLines) { diffLines.push(`-${line}`); } + if (!originalEndsWithNewline) { + diffLines.push('\\ No newline at end of file'); + } } } else { - const hunks = computeDiffHunks(originalLines, modifiedLines); + const hunks = computeDiffHunks(originalLines, modifiedLines, originalEndsWithNewline, modifiedEndsWithNewline); for (const hunk of hunks) { diffLines.push(hunk); } @@ -175,7 +200,12 @@ async function generateUnifiedDiff( * Computes unified diff hunks using VS Code's diff algorithm. * Merges adjacent/overlapping hunks to produce a valid patch. */ -function computeDiffHunks(originalLines: string[], modifiedLines: string[]): string[] { +function computeDiffHunks( + originalLines: string[], + modifiedLines: string[], + originalEndsWithNewline: boolean, + modifiedEndsWithNewline: boolean +): string[] { const contextSize = 3; const result: string[] = []; @@ -227,6 +257,10 @@ function computeDiffHunks(originalLines: string[], modifiedLines: string[]): str const hunkModStart = Math.max(1, firstChange.modified.startLineNumber - contextSize); const hunkLines: string[] = []; + // Track which line in hunkLines corresponds to the last line of each file + let lastOriginalLineIndex = -1; + let lastModifiedLineIndex = -1; + let origLineNum = hunkOrigStart; let origCount = 0; let modCount = 0; @@ -240,7 +274,16 @@ function computeDiffHunks(originalLines: string[], modifiedLines: string[]): str // Emit context lines before this change while (origLineNum < origStart) { + const idx = hunkLines.length; hunkLines.push(` ${originalLines[origLineNum - 1]}`); + // Context lines are in both files + if (origLineNum === originalLines.length) { + lastOriginalLineIndex = idx; + } + const modLineNum = hunkModStart + modCount; + if (modLineNum === modifiedLines.length) { + lastModifiedLineIndex = idx; + } origLineNum++; origCount++; modCount++; @@ -248,28 +291,67 @@ function computeDiffHunks(originalLines: string[], modifiedLines: string[]): str // Emit deleted lines for (let i = origStart; i < origEnd; i++) { + const idx = hunkLines.length; hunkLines.push(`-${originalLines[i - 1]}`); + if (i === originalLines.length) { + lastOriginalLineIndex = idx; + } origLineNum++; origCount++; } // Emit added lines for (let i = modStart; i < modEnd; i++) { + const idx = hunkLines.length; hunkLines.push(`+${modifiedLines[i - 1]}`); + if (i === modifiedLines.length) { + lastModifiedLineIndex = idx; + } modCount++; } } // Emit trailing context lines while (origLineNum <= hunkOrigEnd) { + const idx = hunkLines.length; hunkLines.push(` ${originalLines[origLineNum - 1]}`); + // Context lines are in both files + if (origLineNum === originalLines.length) { + lastOriginalLineIndex = idx; + } + const modLineNum = hunkModStart + modCount; + if (modLineNum === modifiedLines.length) { + lastModifiedLineIndex = idx; + } origLineNum++; origCount++; modCount++; } result.push(`@@ -${hunkOrigStart},${origCount} +${hunkModStart},${modCount} @@`); - result.push(...hunkLines); + + // Add "No newline at end of file" markers for git apply compatibility + // The marker must appear immediately after the line that lacks a newline + for (let i = 0; i < hunkLines.length; i++) { + result.push(hunkLines[i]); + + const isLastOriginal = i === lastOriginalLineIndex; + const isLastModified = i === lastModifiedLineIndex; + + if (isLastOriginal && isLastModified) { + // Context line is the last line of both files + // If either lacks newline, we need a marker (but only one) + if (!originalEndsWithNewline || !modifiedEndsWithNewline) { + result.push('\\ No newline at end of file'); + } + } else if (isLastOriginal && !originalEndsWithNewline) { + // Deletion or context line that's only the last of original + result.push('\\ No newline at end of file'); + } else if (isLastModified && !modifiedEndsWithNewline) { + // Addition or context line that's only the last of modified + result.push('\\ No newline at end of file'); + } + } } return result; 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 c9510266cbcdb..3fb5d98607c7b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -756,6 +756,9 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ public async getChatSessionItems(providersToResolve: readonly string[] | undefined, token: CancellationToken): Promise> { const results: Array<{ readonly chatSessionType: string; readonly items: IChatSessionItem[] }> = []; + const resolvedProviderTypes = new Set(); + + // First, iterate over extension point contributions for (const contrib of this.getAllChatSessionContributions()) { if (providersToResolve && !providersToResolve.includes(contrib.type)) { continue; // skip: not considered for resolving @@ -774,6 +777,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ const providerSessions = await raceCancellationError(provider.provideChatSessionItems(token), token); this._logService.trace(`[ChatSessionsService] Resolved ${providerSessions.length} sessions for provider ${provider.chatSessionType}`); results.push({ chatSessionType: provider.chatSessionType, items: providerSessions }); + resolvedProviderTypes.add(provider.chatSessionType); } catch (error) { // Log error but continue with other providers this._logService.error(`[ChatSessionsService] Failed to resolve sessions for provider ${provider.chatSessionType}`, error); @@ -781,6 +785,26 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } } + // Also include registered items providers that don't have corresponding contributions + // (e.g., the local session provider which is built-in and not an extension contribution) + for (const [chatSessionType, provider] of this._itemsProviders) { + if (resolvedProviderTypes.has(chatSessionType)) { + continue; // already resolved via contribution + } + if (providersToResolve && !providersToResolve.includes(chatSessionType)) { + continue; // skip: not considered for resolving + } + + try { + const providerSessions = await raceCancellationError(provider.provideChatSessionItems(token), token); + this._logService.trace(`[ChatSessionsService] Resolved ${providerSessions.length} sessions for built-in provider ${chatSessionType}`); + results.push({ chatSessionType, items: providerSessions }); + } catch (error) { + this._logService.error(`[ChatSessionsService] Failed to resolve sessions for built-in provider ${chatSessionType}`, error); + continue; + } + } + return results; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index 6a5a55caa2a8e..752a69d67fe80 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -18,6 +18,7 @@ import { IChatContentPartRenderContext } from '../chatContentParts.js'; import { ChatMarkdownContentPart, type IChatMarkdownContentPartOptions } from '../chatMarkdownContentPart.js'; import { ChatProgressSubPart } from '../chatProgressContentPart.js'; import { BaseChatToolInvocationSubPart } from './chatToolInvocationSubPart.js'; +import { TerminalToolAutoExpand } from './terminalToolAutoExpand.js'; import { ChatCollapsibleContentPart } from '../chatCollapsibleContentPart.js'; import { IChatRendererContent } from '../../../../common/model/chatViewModel.js'; import '../media/chatTerminalToolProgressPart.css'; @@ -217,7 +218,6 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart private readonly _isSerializedInvocation: boolean; private _terminalInstance: ITerminalInstance | undefined; private readonly _decoration: TerminalCommandDecoration; - private _autoExpandTimeout: ReturnType | undefined; private _userToggledOutput: boolean = false; private _isInThinkingContainer: boolean = false; private _thinkingCollapsibleWrapper: ChatTerminalThinkingCollapsibleWrapper | undefined; @@ -559,29 +559,38 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart } const store = new DisposableStore(); + + const hasRealOutput = (): boolean => { + // Check for snapshot output + if (this._terminalData.terminalCommandOutput?.text?.trim()) { + return true; + } + // Check for live output (cursor moved past executed marker) + const command = this._getResolvedCommand(terminalInstance); + if (!command?.executedMarker || terminalInstance.isDisposed) { + return false; + } + const buffer = terminalInstance.xterm?.raw.buffer.active; + if (!buffer) { + return false; + } + const cursorLine = buffer.baseY + buffer.cursorY; + return cursorLine > command.executedMarker.line; + }; + + // Use the extracted auto-expand logic + const autoExpand = store.add(new TerminalToolAutoExpand({ + commandDetection, + onWillData: terminalInstance.onWillData, + shouldAutoExpand: () => !this._outputView.isExpanded && !this._userToggledOutput && !this._store.isDisposed, + hasRealOutput, + })); + store.add(autoExpand.onDidRequestExpand(() => this._toggleOutput(true))); + store.add(commandDetection.onCommandExecuted(() => { this._addActions(terminalInstance, this._terminalData.terminalToolSessionId); - // Auto-expand if there's output, checking periodically for up to 1 second - if (!this._outputView.isExpanded && !this._userToggledOutput && !this._autoExpandTimeout) { - let attempts = 0; - const maxAttempts = 5; - const checkForOutput = () => { - this._autoExpandTimeout = undefined; - if (this._store.isDisposed || this._outputView.isExpanded || this._userToggledOutput) { - return; - } - if (this._hasOutput(terminalInstance)) { - this._toggleOutput(true); - return; - } - attempts++; - if (attempts < maxAttempts) { - this._autoExpandTimeout = setTimeout(checkForOutput, 200); - } - }; - this._autoExpandTimeout = setTimeout(checkForOutput, 200); - } })); + store.add(commandDetection.onCommandFinished(() => { this._addActions(terminalInstance, this._terminalData.terminalToolSessionId); const resolvedCommand = this._getResolvedCommand(terminalInstance); @@ -690,10 +699,6 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart } private _handleDispose(): void { - if (this._autoExpandTimeout) { - clearTimeout(this._autoExpandTimeout); - this._autoExpandTimeout = undefined; - } this._terminalOutputContextKey.reset(); this._terminalChatService.clearFocusedProgressPart(this); } @@ -747,24 +752,6 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart this._focusChatInput(); } - private _hasOutput(terminalInstance: ITerminalInstance): boolean { - // Check for snapshot - if (this._terminalData.terminalCommandOutput?.text?.trim()) { - return true; - } - // Check for live output (cursor moved past executed marker) - const command = this._getResolvedCommand(terminalInstance); - if (!command?.executedMarker || terminalInstance.isDisposed) { - return false; - } - const buffer = terminalInstance.xterm?.raw.buffer.active; - if (!buffer) { - return false; - } - const cursorLine = buffer.baseY + buffer.cursorY; - return cursorLine > command.executedMarker.line; - } - private _resolveCommand(instance: ITerminalInstance): ITerminalCommand | undefined { if (instance.isDisposed) { return undefined; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/terminalToolAutoExpand.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/terminalToolAutoExpand.ts new file mode 100644 index 0000000000000..bf23d8519c19b --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/terminalToolAutoExpand.ts @@ -0,0 +1,121 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore, IDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../../../../../base/common/event.js'; +import type { ICommandDetectionCapability } from '../../../../../../../platform/terminal/common/capabilities/capabilities.js'; +import { disposableTimeout } from '../../../../../../../base/common/async.js'; + +/** + * The auto-expand algorithm for terminal tool progress parts. + * + * The algorithm is: + * 1. When command executes, kick off 500ms timeout - if hit without data events, expand only if there's real output + * 2. On first data event, wait 50ms and expand if command not yet finished + * 3. Fast commands (finishing quickly) should NOT auto-expand to prevent flickering + */ +export interface ITerminalToolAutoExpandOptions { + /** + * The command detection capability to listen for command events. + */ + readonly commandDetection: ICommandDetectionCapability; + + /** + * Event fired when data is received from the terminal. + */ + readonly onWillData: Event; + + /** + * Check if the output should auto-expand (e.g. not already expanded, user hasn't toggled). + */ + shouldAutoExpand(): boolean; + + /** + * Check if there is real output (not just shell integration sequences). + */ + hasRealOutput(): boolean; +} + +/** + * Timeout constants for the auto-expand algorithm. + */ +export const enum TerminalToolAutoExpandTimeout { + /** + * Timeout in milliseconds to wait when no data events are received before checking for auto-expand. + */ + NoData = 500, + /** + * Timeout in milliseconds to wait after first data event before checking for auto-expand. + * This prevents flickering for fast commands like `ls` that finish quickly. + */ + DataEvent = 50, +} + +export class TerminalToolAutoExpand extends Disposable { + private _commandFinished = false; + private _receivedData = false; + private _dataEventTimeout: IDisposable | undefined; + private _noDataTimeout: IDisposable | undefined; + + private readonly _onDidRequestExpand = this._register(new Emitter()); + readonly onDidRequestExpand: Event = this._onDidRequestExpand.event; + + constructor( + private readonly _options: ITerminalToolAutoExpandOptions, + ) { + super(); + this._setupListeners(); + } + + private _setupListeners(): void { + const store = this._register(new DisposableStore()); + + const commandDetection = this._options.commandDetection; + + store.add(commandDetection.onCommandExecuted(() => { + // Auto-expand for long-running commands: + if (this._options.shouldAutoExpand() && !this._noDataTimeout) { + this._noDataTimeout = disposableTimeout(() => { + this._noDataTimeout = undefined; + if (!this._receivedData && this._options.shouldAutoExpand() && this._options.hasRealOutput()) { + this._onDidRequestExpand.fire(); + } + }, TerminalToolAutoExpandTimeout.NoData, store); + } + })); + + // 2. Wait for first data event - when hit, wait 50ms and expand if command not yet finished + // Also checks for real output since shell integration sequences trigger onWillData + store.add(this._options.onWillData(() => { + if (this._receivedData) { + return; + } + this._receivedData = true; + this._noDataTimeout?.dispose(); + this._noDataTimeout = undefined; + // Wait 50ms and expand if command hasn't finished yet and has real output + if (this._options.shouldAutoExpand() && !this._dataEventTimeout) { + this._dataEventTimeout = disposableTimeout(() => { + this._dataEventTimeout = undefined; + if (!this._commandFinished && this._options.shouldAutoExpand() && this._options.hasRealOutput()) { + this._onDidRequestExpand.fire(); + } + }, TerminalToolAutoExpandTimeout.DataEvent, store); + } + })); + + store.add(commandDetection.onCommandFinished(() => { + this._commandFinished = true; + this._clearAutoExpandTimeouts(); + })); + } + + private _clearAutoExpandTimeouts(): void { + this._dataEventTimeout?.dispose(); + this._dataEventTimeout = undefined; + this._noDataTimeout?.dispose(); + this._noDataTimeout = undefined; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 42f602ace9f19..2298702f2b7c5 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -1851,7 +1851,8 @@ export class ChatWidget extends Disposable implements IChatWidget { supportsChangingModes: this.viewOptions.supportsChangingModes, dndContainer: this.viewOptions.dndContainer, widgetViewKindTag: this.getWidgetViewKindTag(), - defaultMode: this.viewOptions.defaultMode + defaultMode: this.viewOptions.defaultMode, + sessionTypePickerDelegate: this.viewOptions.sessionTypePickerDelegate }; if (this.viewModel?.editing) { 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 add94becac89c..c150ff9e16be0 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -83,7 +83,7 @@ import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { IChatModelInputState, IChatRequestModeInfo, IInputModel } from '../../../common/model/chatModel.js'; import { ChatMode, IChatMode, IChatModeService } from '../../../common/chatModes.js'; -import { IChatFollowup, IChatService } from '../../../common/chatService/chatService.js'; +import { IChatFollowup, IChatService, IChatSessionContext } from '../../../common/chatService/chatService.js'; import { IChatSessionFileChange, IChatSessionProviderOptionItem, IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; import { getChatSessionType } from '../../../common/model/chatUri.js'; import { ChatRequestVariableSet, IChatRequestVariableEntry, isElementVariableEntry, isImageVariableEntry, isNotebookOutputVariableEntry, isPasteVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry, isSCMHistoryItemChangeRangeVariableEntry, isSCMHistoryItemChangeVariableEntry, isSCMHistoryItemVariableEntry, isStringImplicitContextValue, isStringVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; @@ -96,7 +96,7 @@ import { ActionLocation, ChatContinueInSessionActionItem, ContinueChatInSessionA import { ChatSessionPrimaryPickerAction, ChatSubmitAction, IChatExecuteActionContext, OpenModelPickerAction, OpenModePickerAction, OpenSessionTargetPickerAction } from '../../actions/chatExecuteActions.js'; import { IAgentSessionsService } from '../../agentSessions/agentSessionsService.js'; import { ImplicitContextAttachmentWidget } from '../../attachments/implicitContextAttachment.js'; -import { IChatWidget, isIChatResourceViewContext } from '../../chat.js'; +import { IChatWidget, ISessionTypePickerDelegate, isIChatResourceViewContext } from '../../chat.js'; import { ChatAttachmentModel } from '../../attachments/chatAttachmentModel.js'; import { DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmentWidget, ImageAttachmentWidget, NotebookCellOutputChatAttachmentWidget, PasteAttachmentWidget, PromptFileAttachmentWidget, PromptTextAttachmentWidget, SCMHistoryItemAttachmentWidget, SCMHistoryItemChangeAttachmentWidget, SCMHistoryItemChangeRangeAttachmentWidget, TerminalCommandAttachmentWidget, ToolSetOrToolItemAttachmentWidget } from '../../attachments/chatAttachmentWidgets.js'; import { IDisposableReference } from '../chatContentParts/chatCollections.js'; @@ -114,7 +114,7 @@ import { ChatRelatedFiles } from '../../attachments/chatInputRelatedFilesContrib import { resizeImage } from '../../chatImageUtils.js'; import { IModelPickerDelegate, ModelPickerActionItem } from './modelPickerActionItem.js'; import { IModePickerDelegate, ModePickerActionItem } from './modePickerActionItem.js'; -import { ISessionTypePickerDelegate, SessionTypePickerActionItem } from './sessionTargetPickerActionItem.js'; +import { SessionTypePickerActionItem } from './sessionTargetPickerActionItem.js'; import { IChatInputPickerOptions } from './chatInputPickerActionItem.js'; import { getAgentSessionProvider } from '../../agentSessions/agentSessions.js'; import { SearchableOptionPickerActionItem } from '../../chatSessions/searchableOptionPickerActionItem.js'; @@ -147,6 +147,11 @@ export interface IChatInputPartOptions { supportsChangingModes?: boolean; dndContainer?: HTMLElement; widgetViewKindTag: string; + /** + * Optional delegate for the session target picker. + * When provided, allows the input part to maintain independent state for the selected session type. + */ + sessionTypePickerDelegate?: ISessionTypePickerDelegate; } export interface IWorkingSetEntry { @@ -334,6 +339,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private filePartOfEditSessionKey: IContextKey; private chatSessionHasOptions: IContextKey; private chatSessionOptionsValid: IContextKey; + private agentSessionTypeKey: IContextKey; private modelWidget: ModelPickerActionItem | undefined; private modeWidget: ModePickerActionItem | undefined; private sessionTargetWidget: SessionTypePickerActionItem | undefined; @@ -503,12 +509,22 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const sessionResource = this._widget?.viewModel?.model.sessionResource; if (sessionResource) { const ctx = this.chatService.getChatSessionFromInternalUri(sessionResource); - if (ctx?.chatSessionType === chatSessionType) { + const delegateSessionType = this.options.sessionTypePickerDelegate?.getActiveSessionProvider?.(); + if (ctx?.chatSessionType === chatSessionType || delegateSessionType === chatSessionType) { this.refreshChatSessionPickers(); } } })); + // Listen for session type changes from the welcome page delegate + if (this.options.sessionTypePickerDelegate?.onDidChangeActiveSessionProvider) { + this._register(this.options.sessionTypePickerDelegate.onDidChangeActiveSessionProvider(async (newSessionType) => { + this.agentSessionTypeKey.set(newSessionType); + this.updateWidgetLockStateFromSessionType(newSessionType); + this.refreshChatSessionPickers(); + })); + } + this._attachmentModel = this._register(this.instantiationService.createInstance(ChatAttachmentModel)); this._register(this._attachmentModel.onDidChange(() => this._syncInputStateToModel())); this.selectedToolsModel = this._register(this.instantiationService.createInstance(ChatSelectedTools, this.currentModeObs)); @@ -524,6 +540,15 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.filePartOfEditSessionKey = ChatContextKeys.filePartOfEditSession.bindTo(contextKeyService); this.chatSessionHasOptions = ChatContextKeys.chatSessionHasModels.bindTo(contextKeyService); this.chatSessionOptionsValid = ChatContextKeys.chatSessionOptionsValid.bindTo(contextKeyService); + this.agentSessionTypeKey = ChatContextKeys.agentSessionType.bindTo(contextKeyService); + + // Initialize agentSessionType from delegate if available + if (this.options.sessionTypePickerDelegate?.getActiveSessionProvider) { + const initialSessionType = this.options.sessionTypePickerDelegate.getActiveSessionProvider(); + if (initialSessionType) { + this.agentSessionTypeKey.set(initialSessionType); + } + } const chatToolCount = ChatContextKeys.chatToolCount.bindTo(contextKeyService); @@ -740,7 +765,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // Get all option groups for the current session type const ctx = resolveChatSessionContext(); - const optionGroups = ctx ? this.chatSessionsService.getOptionGroupsForSessionType(ctx.chatSessionType) : undefined; + 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) { return []; } @@ -762,16 +789,21 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const widgets: (ChatSessionPickerActionItem | SearchableOptionPickerActionItem)[] = []; for (const optionGroup of optionGroups) { - if (!ctx) { + // For delegate session types, we don't require ctx or session values + if (!usingDelegateSessionType && !ctx) { continue; } - const hasSessionValue = this.chatSessionsService.getSessionOption(ctx.chatSessionResource, optionGroup.id); + const hasSessionValue = ctx ? this.chatSessionsService.getSessionOption(ctx.chatSessionResource, optionGroup.id) : undefined; const hasItems = optionGroup.items.length > 0; - if (!hasSessionValue && !hasItems) { + // 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)) { continue; @@ -785,28 +817,27 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge getCurrentOption: () => this.getCurrentOptionForGroup(optionGroup.id), onDidChangeOption: this.getOrCreateOptionEmitter(optionGroup.id).event, setOption: (option: IChatSessionProviderOptionItem) => { - const ctx = resolveChatSessionContext(); - if (!ctx) { - return; - } // Update context key for this option group this.updateOptionContextKey(optionGroup.id, option.id); - this.getOrCreateOptionEmitter(optionGroup.id).fire(option); - this.chatSessionsService.notifySessionOptionsChange( - ctx.chatSessionResource, - [{ optionId: optionGroup.id, value: option }] - ).catch(err => this.logService.error(`Failed to notify extension of ${optionGroup.id} change:`, err)); + + // Only notify session options change if we have an actual session (not delegate-only) + const ctx = resolveChatSessionContext(); + if (ctx && !usingDelegateSessionType) { + this.chatSessionsService.notifySessionOptionsChange( + ctx.chatSessionResource, + [{ optionId: optionGroup.id, value: option }] + ).catch(err => this.logService.error(`Failed to notify extension of ${optionGroup.id} change:`, err)); + } // Refresh pickers to re-evaluate visibility of other option groups this.refreshChatSessionPickers(); }, getOptionGroup: () => { - const ctx = resolveChatSessionContext(); - if (!ctx) { - return undefined; - } - const groups = this.chatSessionsService.getOptionGroupsForSessionType(ctx.chatSessionType); + // 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; return groups?.find(g => g.id === optionGroup.id); } }; @@ -1383,12 +1414,17 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (!ctx) { return hideAll(); } - const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(ctx.chatSessionType); + + const effectiveSessionType = this.getEffectiveSessionType(ctx, this.options.sessionTypePickerDelegate); + const usingDelegateSessionType = effectiveSessionType !== ctx.chatSessionType; + const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(effectiveSessionType); if (!optionGroups || optionGroups.length === 0) { return hideAll(); } - if (!this.chatSessionsService.hasAnySessionOptions(ctx.chatSessionResource)) { + // 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(); } @@ -1503,7 +1539,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (!ctx) { return; } - const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(ctx.chatSessionType); + + const effectiveSessionType = this.getEffectiveSessionType(ctx, this.options.sessionTypePickerDelegate); + const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(effectiveSessionType); const optionGroup = optionGroups?.find(g => g.id === optionGroupId); if (!optionGroup || optionGroup.items.length === 0) { return; @@ -1524,6 +1562,44 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } + private getEffectiveSessionType(ctx: IChatSessionContext | undefined, delegate: ISessionTypePickerDelegate | undefined): string { + return this.options.sessionTypePickerDelegate?.getActiveSessionProvider?.() || ctx?.chatSessionType || ''; + } + + /** + * Updates the agentSessionType context key based on delegate or actual session. + */ + private updateAgentSessionTypeContextKey(): void { + const sessionResource = this._widget?.viewModel?.model.sessionResource; + + // Determine effective session type: + // - If we have a delegate with a setter (e.g., welcome page), use the delegate's session type + // - Otherwise, use the actual session's type + const delegate = this.options.sessionTypePickerDelegate; + const delegateSessionType = delegate?.setActiveSessionProvider && delegate?.getActiveSessionProvider?.(); + const sessionType = delegateSessionType || (sessionResource ? getChatSessionType(sessionResource) : ''); + + this.agentSessionTypeKey.set(sessionType); + } + + /** + * Updates the widget lock state based on a session type. + * Local sessions unlock from coding agent mode, while remote/cloud sessions lock to coding agent mode. + */ + private updateWidgetLockStateFromSessionType(sessionType: string): void { + if (sessionType === localChatSessionType) { + this._widget?.unlockFromCodingAgent(); + return; + } + + const contribution = this.chatSessionsService.getChatSessionContribution(sessionType); + if (contribution) { + this._widget?.lockToCodingAgent(contribution.name, contribution.displayName, contribution.type); + } else { + this._widget?.unlockFromCodingAgent(); + } + } + /** * Updates the widget controller based on session type. */ @@ -1533,7 +1609,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return; } - const sessionType = getChatSessionType(sessionResource); + // Determine effective session type: + // - If we have a delegate with a setter (e.g., welcome page), use the delegate's session type + // - Otherwise, use the actual session's type + const delegate = this.options.sessionTypePickerDelegate; + const delegateSessionType = delegate?.setActiveSessionProvider && delegate?.getActiveSessionProvider?.(); + const sessionType = delegateSessionType || getChatSessionType(sessionResource); const isLocalSession = sessionType === localChatSessionType; if (!isLocalSession) { @@ -1551,6 +1632,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._widget = widget; this._register(widget.onDidChangeViewModel(() => { + // Update agentSessionType when view model changes + this.updateAgentSessionTypeContextKey(); this.refreshChatSessionPickers(); this.tryUpdateWidgetController(); })); @@ -1796,7 +1879,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }; return this.modeWidget = this.instantiationService.createInstance(ModePickerActionItem, action, delegate, pickerOptions); } else if (action.id === OpenSessionTargetPickerAction.ID && action instanceof MenuItemAction) { - const delegate: ISessionTypePickerDelegate = { + // Use provided delegate if available, otherwise create default delegate + const delegate: ISessionTypePickerDelegate = this.options.sessionTypePickerDelegate ?? { getActiveSessionProvider: () => { const sessionResource = this._widget?.viewModel?.sessionResource; return sessionResource ? getAgentSessionProvider(sessionResource) : undefined; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts index 3991359ce7447..8e26298839a53 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts @@ -19,10 +19,7 @@ import { IOpenerService } from '../../../../../../platform/opener/common/opener. import { IChatSessionsService } from '../../../common/chatSessionsService.js'; import { AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderIcon, getAgentSessionProviderName } from '../../agentSessions/agentSessions.js'; import { ChatInputPickerActionViewItem, IChatInputPickerOptions } from './chatInputPickerActionItem.js'; - -export interface ISessionTypePickerDelegate { - getActiveSessionProvider(): AgentSessionProviders | undefined; -} +import { ISessionTypePickerDelegate } from '../../chat.js'; interface ISessionTypeItem { type: AgentSessionProviders; @@ -65,7 +62,11 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { icon: getAgentSessionProviderIcon(sessionTypeItem.type), enabled: true, run: async () => { - this.commandService.executeCommand(sessionTypeItem.commandId, this.chatSessionPosition); + if (this.delegate.setActiveSessionProvider) { + this.delegate.setActiveSessionProvider(sessionTypeItem.type); + } else { + this.commandService.executeCommand(sessionTypeItem.commandId, this.chatSessionPosition); + } if (this.element) { this.renderLabel(this.element); } diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 8a651031c410b..2a5fc753855db 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -61,6 +61,8 @@ export namespace ChatContextKeys { export const inputHasAgent = new RawContextKey('chatInputHasAgent', false); export const location = new RawContextKey('chatLocation', undefined); export const inQuickChat = new RawContextKey('quickChatHasFocus', false, { type: 'boolean', description: localize('inQuickChat', "True when the quick chat UI has focus, false otherwise.") }); + export const inAgentSessionsWelcome = new RawContextKey('inAgentSessionsWelcome', false, { type: 'boolean', description: localize('inAgentSessionsWelcome', "True when the chat input is within the agent sessions welcome page.") }); + export const chatSessionType = new RawContextKey('chatSessionType', '', { type: 'string', description: localize('chatSessionType', "The type of the current chat session.") }); export const hasFileAttachments = new RawContextKey('chatHasFileAttachments', false, { type: 'boolean', description: localize('chatHasFileAttachments', "True when the chat has file attachments.") }); export const chatSessionIsEmpty = new RawContextKey('chatSessionIsEmpty', true, { type: 'boolean', description: localize('chatSessionIsEmpty', "True when the current chat session has no requests.") }); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts index 6952e85834f01..6179489270ec3 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts @@ -21,7 +21,7 @@ interface IRawChatFileContribution { readonly description?: string; } -type ChatContributionPoint = 'chatPromptFiles' | 'chatInstructions' | 'chatAgents'; +type ChatContributionPoint = 'chatPromptFiles' | 'chatInstructions' | 'chatAgents' | 'chatSkills'; function registerChatFilesExtensionPoint(point: ChatContributionPoint) { return extensionsRegistry.ExtensionsRegistry.registerExtensionPoint({ @@ -62,12 +62,14 @@ function registerChatFilesExtensionPoint(point: ChatContributionPoint) { const epPrompt = registerChatFilesExtensionPoint('chatPromptFiles'); const epInstructions = registerChatFilesExtensionPoint('chatInstructions'); const epAgents = registerChatFilesExtensionPoint('chatAgents'); +const epSkills = registerChatFilesExtensionPoint('chatSkills'); function pointToType(contributionPoint: ChatContributionPoint): PromptsType { switch (contributionPoint) { case 'chatPromptFiles': return PromptsType.prompt; case 'chatInstructions': return PromptsType.instructions; case 'chatAgents': return PromptsType.agent; + case 'chatSkills': return PromptsType.skill; } } @@ -86,6 +88,7 @@ export class ChatPromptFilesExtensionPointHandler implements IWorkbenchContribut this.handle(epPrompt, 'chatPromptFiles'); this.handle(epInstructions, 'chatInstructions'); this.handle(epAgents, 'chatAgents'); + this.handle(epSkills, 'chatSkills'); } private handle(extensionPoint: extensionsRegistry.IExtensionPoint, contributionPoint: ChatContributionPoint) { @@ -136,15 +139,16 @@ CommandsRegistry.registerCommand('_listExtensionPromptFiles', async (accessor): const promptsService = accessor.get(IPromptsService); // Get extension prompt files for all prompt types in parallel - const [agents, instructions, prompts] = await Promise.all([ + const [agents, instructions, prompts, skills] = await Promise.all([ promptsService.listPromptFiles(PromptsType.agent, CancellationToken.None), promptsService.listPromptFiles(PromptsType.instructions, CancellationToken.None), promptsService.listPromptFiles(PromptsType.prompt, CancellationToken.None), + promptsService.listPromptFiles(PromptsType.skill, CancellationToken.None), ]); // Combine all files and collect extension-contributed ones const result: IExtensionPromptFileResult[] = []; - for (const file of [...agents, ...instructions, ...prompts]) { + for (const file of [...agents, ...instructions, ...prompts, ...skills]) { if (file.storage === PromptsStorage.extension) { result.push({ uri: file.uri.toJSON(), type: file.type }); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 442eae116917f..2be3f234d1fa4 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -37,6 +37,37 @@ import { Delayer } from '../../../../../../base/common/async.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { IChatPromptContentStore } from '../chatPromptContentStore.js'; +/** + * Error thrown when a skill file is missing the required name attribute. + */ +export class SkillMissingNameError extends Error { + constructor(public readonly uri: URI) { + super('Skill file must have a name attribute'); + } +} + +/** + * Error thrown when a skill file is missing the required description attribute. + */ +export class SkillMissingDescriptionError extends Error { + constructor(public readonly uri: URI) { + super('Skill file must have a description attribute'); + } +} + +/** + * Error thrown when a skill's name does not match its parent folder name. + */ +export class SkillNameMismatchError extends Error { + constructor( + public readonly uri: URI, + public readonly skillName: string, + public readonly folderName: string + ) { + super(`Skill name must match folder name: expected "${folderName}" but got "${skillName}"`); + } +} + /** * Provides prompt services. */ @@ -537,6 +568,19 @@ export class PromptsService extends Disposable implements IPromptsService { return Disposable.None; } const entryPromise = (async () => { + // For skills, validate that the file follows the required structure + if (type === PromptsType.skill) { + try { + const validated = await this.validateAndSanitizeSkillFile(uri, CancellationToken.None); + name = validated.name; + description = validated.description; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + this.logger.error(`[registerContributedFile] Extension '${extension.identifier.value}' failed to validate skill file: ${uri}`, msg); + throw e; + } + } + try { await this.filesConfigService.updateReadonly(uri, true); } catch (e) { @@ -647,6 +691,40 @@ export class PromptsService extends Disposable implements IPromptsService { return text.replace(/<[^>]+>/g, ''); } + /** + * Validates and sanitizes a skill file. Throws an error if validation fails. + * @returns The sanitized name and description + */ + private async validateAndSanitizeSkillFile(uri: URI, token: CancellationToken): Promise<{ name: string; description: string | undefined }> { + const parsedFile = await this.parseNew(uri, token); + const name = parsedFile.header?.name; + + if (!name) { + this.logger.error(`[validateAndSanitizeSkillFile] Agent skill file missing name attribute: ${uri}`); + throw new SkillMissingNameError(uri); + } + + const description = parsedFile.header?.description; + if (!description) { + this.logger.error(`[validateAndSanitizeSkillFile] Agent skill file missing description attribute: ${uri}`); + throw new SkillMissingDescriptionError(uri); + } + + // Sanitize the name first (remove XML tags and truncate) + const sanitizedName = this.truncateAgentSkillName(name, uri); + + // Validate that the sanitized name matches the parent folder name (per agentskills.io specification) + const skillFolderUri = dirname(uri); + const folderName = basename(skillFolderUri); + if (sanitizedName !== folderName) { + this.logger.error(`[validateAndSanitizeSkillFile] Agent skill name "${sanitizedName}" does not match folder name "${folderName}": ${uri}`); + throw new SkillNameMismatchError(uri, sanitizedName, folderName); + } + + const sanitizedDescription = this.truncateAgentSkillDescription(parsedFile.header?.description, uri); + return { name: sanitizedName, description: sanitizedDescription }; + } + private truncateAgentSkillName(name: string, uri: URI): string { const MAX_NAME_LENGTH = 64; const sanitized = this.sanitizeAgentSkillText(name); @@ -685,6 +763,7 @@ export class PromptsService extends Disposable implements IPromptsService { const seenNames = new Set(); const skillTypes = new Map(); let skippedMissingName = 0; + let skippedMissingDescription = 0; let skippedDuplicateName = 0; let skippedParseFailed = 0; let skippedNameMismatch = 0; @@ -725,8 +804,17 @@ export class PromptsService extends Disposable implements IPromptsService { // Track skill type skillTypes.set(source, (skillTypes.get(source) || 0) + 1); } catch (e) { - skippedParseFailed++; - this.logger.error(`[findAgentSkills] Failed to parse Agent skill file: ${uri}`, e instanceof Error ? e.message : String(e)); + if (e instanceof SkillMissingNameError) { + skippedMissingName++; + } else if (e instanceof SkillMissingDescriptionError) { + skippedMissingDescription++; + } else if (e instanceof SkillNameMismatchError) { + skippedNameMismatch++; + } else { + skippedParseFailed++; + } + const msg = e instanceof Error ? e.message : String(e); + this.logger.error(`[findAgentSkills] Failed to validate Agent skill file: ${uri}`, msg); } }; @@ -777,6 +865,7 @@ export class PromptsService extends Disposable implements IPromptsService { extensionAPI: number; skippedDuplicateName: number; skippedMissingName: number; + skippedMissingDescription: number; skippedNameMismatch: number; skippedParseFailed: number; }; @@ -793,6 +882,7 @@ export class PromptsService extends Disposable implements IPromptsService { extensionAPI: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of extension API provided skills.' }; skippedDuplicateName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of skills skipped due to duplicate names.' }; skippedMissingName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of skills skipped due to missing name attribute.' }; + skippedMissingDescription: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of skills skipped due to missing description attribute.' }; skippedNameMismatch: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of skills skipped due to name not matching folder name.' }; skippedParseFailed: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of skills skipped due to parse failures.' }; owner: 'pwang347'; @@ -811,6 +901,7 @@ export class PromptsService extends Disposable implements IPromptsService { extensionAPI: skillTypes.get(PromptFileSource.ExtensionAPI) ?? 0, skippedDuplicateName, skippedMissingName, + skippedMissingDescription, skippedNameMismatch, skippedParseFailed }); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts index 015c6c5404083..73848fc2fd9f7 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -64,6 +64,8 @@ export class PromptFilesLocator { : this.toAbsoluteLocations(configuredLocations, userHome); const paths = new ResourceSet(); + + // Search in config-based user locations (e.g., tilde paths like ~/.copilot/skills) for (const { uri, storage } of absoluteLocations) { if (storage !== PromptsStorage.user) { continue; @@ -79,6 +81,17 @@ export class PromptFilesLocator { } } + // Also search in the VS Code user data prompts folder (for all types except skills) + if (type !== PromptsType.skill) { + const userDataPromptsHome = this.userDataService.currentProfile.promptsHome; + const files = await this.resolveFilesAtLocation(userDataPromptsHome, type, token); + for (const file of files) { + if (getPromptFileType(file) === type) { + paths.add(file); + } + } + } + return [...paths]; } diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTerminalToolProgressPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTerminalToolProgressPart.test.ts new file mode 100644 index 0000000000000..ba9914a7d04b7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTerminalToolProgressPart.test.ts @@ -0,0 +1,267 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter } from '../../../../../../../base/common/event.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../../../../base/test/common/timeTravelScheduler.js'; +import { timeout } from '../../../../../../../base/common/async.js'; +import { TerminalToolAutoExpand, TerminalToolAutoExpandTimeout } from '../../../../browser/widget/chatContentParts/toolInvocationParts/terminalToolAutoExpand.js'; +import type { ICommandDetectionCapability } from '../../../../../../../platform/terminal/common/capabilities/capabilities.js'; + +suite('ChatTerminalToolProgressPart Auto-Expand Logic', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + // Mocked events + let onCommandExecuted: Emitter; + let onCommandFinished: Emitter; + let onWillData: Emitter; + + // State tracking + let isExpanded: boolean; + let userToggledOutput: boolean; + let hasRealOutputValue: boolean; + + function shouldAutoExpand(): boolean { + return !isExpanded && !userToggledOutput; + } + + function hasRealOutput(): boolean { + return hasRealOutputValue; + } + + function setupAutoExpandLogic(): void { + // Create a mock command detection capability + const mockCommandDetection = { + onCommandExecuted: onCommandExecuted.event, + onCommandFinished: onCommandFinished.event, + } as Pick as ICommandDetectionCapability; + + // Use the real TerminalToolAutoExpand class + const autoExpand = store.add(new TerminalToolAutoExpand({ + commandDetection: mockCommandDetection, + onWillData: onWillData.event, + shouldAutoExpand, + hasRealOutput, + })); + store.add(autoExpand.onDidRequestExpand(() => { + isExpanded = true; + })); + } + + setup(() => { + onCommandExecuted = store.add(new Emitter()); + onCommandFinished = store.add(new Emitter()); + onWillData = store.add(new Emitter()); + + isExpanded = false; + userToggledOutput = false; + hasRealOutputValue = false; + }); + + test('fast command without data should not auto-expand (finishes before timeout)', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + setupAutoExpandLogic(); + + // Command executes + onCommandExecuted.fire(undefined); + + // Command finishes quickly (before timeout) + onCommandFinished.fire(undefined); + + // Wait past all timeouts (faked timers advance instantly) + await timeout(TerminalToolAutoExpandTimeout.NoData + 100); + + assert.strictEqual(isExpanded, false, 'Should NOT expand for fast command without data'); + })); + + test('fast command with quick data should not auto-expand (data + finish before timeout)', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + setupAutoExpandLogic(); + + // Command executes + onCommandExecuted.fire(undefined); + + // Data arrives + onWillData.fire('output'); + + // Command finishes quickly (before timeout) + onCommandFinished.fire(undefined); + + // Wait past all timeouts (faked timers advance instantly) + await timeout(TerminalToolAutoExpandTimeout.DataEvent + 100); + + assert.strictEqual(isExpanded, false, 'Should NOT expand when command finishes within timeout of first data'); + })); + + test('long-running command with data should auto-expand (data received, command still running after timeout)', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + hasRealOutputValue = true; // Has real output + setupAutoExpandLogic(); + + // Command executes + onCommandExecuted.fire(undefined); + + // Data arrives + onWillData.fire('output'); + + // Wait for timeout to fire (faked timers advance instantly) + await timeout(TerminalToolAutoExpandTimeout.DataEvent + 100); + + assert.strictEqual(isExpanded, true, 'Should expand when command still running after first data timeout'); + + onCommandFinished.fire(undefined); + })); + + test('long-running command with data but no real output should NOT auto-expand (like sleep with shell sequences)', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + hasRealOutputValue = false; // Shell integration sequences, not real output + setupAutoExpandLogic(); + + // Command executes + onCommandExecuted.fire(undefined); + + // Shell integration data arrives (not real output) + onWillData.fire('shell-sequence'); + + // Wait for timeout to fire (faked timers advance instantly) + await timeout(TerminalToolAutoExpandTimeout.DataEvent + 100); + + assert.strictEqual(isExpanded, false, 'Should NOT expand when data is shell sequences, not real output'); + + onCommandFinished.fire(undefined); + })); + + test('long-running command without data should NOT auto-expand if no real output (like sleep)', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + hasRealOutputValue = false; // No real output like `sleep 1` + setupAutoExpandLogic(); + + // Command executes + onCommandExecuted.fire(undefined); + + // Wait for timeout to fire (faked timers advance instantly) + await timeout(TerminalToolAutoExpandTimeout.NoData + 100); + + assert.strictEqual(isExpanded, false, 'Should NOT expand when no real output even after timeout'); + + onCommandFinished.fire(undefined); + })); + + test('long-running command without data SHOULD auto-expand if real output exists', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + hasRealOutputValue = true; // Has real output in buffer + setupAutoExpandLogic(); + + // Command executes + onCommandExecuted.fire(undefined); + + // Wait for timeout to fire (faked timers advance instantly) + await timeout(TerminalToolAutoExpandTimeout.NoData + 100); + + assert.strictEqual(isExpanded, true, 'Should expand when real output exists after timeout'); + + onCommandFinished.fire(undefined); + })); + + test('data arriving after command finish should not trigger expand', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + setupAutoExpandLogic(); + + // Command executes and finishes immediately + onCommandExecuted.fire(undefined); + onCommandFinished.fire(undefined); + + // Data arrives after command finished + onWillData.fire('late output'); + + // Wait past all timeouts (faked timers advance instantly) + await timeout(TerminalToolAutoExpandTimeout.NoData + 100); + + assert.strictEqual(isExpanded, false, 'Should NOT expand when data arrives after command finished'); + })); + + test('user toggled output prevents auto-expand', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + userToggledOutput = true; + setupAutoExpandLogic(); + + // Command executes + onCommandExecuted.fire(undefined); + + // Data arrives + onWillData.fire('output'); + + // Wait past all timeouts (faked timers advance instantly) + await timeout(TerminalToolAutoExpandTimeout.NoData + 100); + + assert.strictEqual(isExpanded, false, 'Should NOT expand when user has manually toggled output'); + onCommandFinished.fire(undefined); + })); + + test('already expanded output prevents additional auto-expand', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + isExpanded = true; + + // Create a mock command detection capability + const mockCommandDetection = { + onCommandExecuted: onCommandExecuted.event, + onCommandFinished: onCommandFinished.event, + } as Pick as ICommandDetectionCapability; + + // Track if event was fired + let eventFired = false; + const autoExpand = store.add(new TerminalToolAutoExpand({ + commandDetection: mockCommandDetection, + onWillData: onWillData.event, + shouldAutoExpand: () => !isExpanded && !userToggledOutput, + hasRealOutput: () => hasRealOutputValue, + })); + store.add(autoExpand.onDidRequestExpand(() => { + eventFired = true; + })); + + // Command executes + onCommandExecuted.fire(undefined); + + // Data arrives + onWillData.fire('output'); + + // Wait past all timeouts (faked timers advance instantly) + await timeout(TerminalToolAutoExpandTimeout.NoData + 100); + + assert.strictEqual(eventFired, false, 'Should NOT fire expand event when already expanded'); + onCommandFinished.fire(undefined); + })); + + test('data arriving cancels no-data timeout', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + hasRealOutputValue = true; // Would have expanded if no-data timeout fired + setupAutoExpandLogic(); + + // Command executes + onCommandExecuted.fire(undefined); + + // Data arrives (cancels no-data timeout) + onWillData.fire('output'); + + // Command finishes immediately after data (before data timeout would fire) + onCommandFinished.fire(undefined); + + // Wait past all timeouts (faked timers advance instantly) + await timeout(TerminalToolAutoExpandTimeout.NoData + 100); + + assert.strictEqual(isExpanded, false, 'No-data timeout should be cancelled when data arrives'); + })); + + test('multiple data events only trigger one timeout', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + hasRealOutputValue = true; // Has real output + setupAutoExpandLogic(); + + // Command executes + onCommandExecuted.fire(undefined); + + // Multiple data events + onWillData.fire('output 1'); + onWillData.fire('output 2'); + onWillData.fire('output 3'); + + // Wait for timeout to fire (faked timers advance instantly) + await timeout(TerminalToolAutoExpandTimeout.DataEvent + 100); + + assert.strictEqual(isExpanded, true, 'Should expand exactly once after first data'); + onCommandFinished.fire(undefined); + })); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/terminalToolAutoExpand.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/terminalToolAutoExpand.test.ts new file mode 100644 index 0000000000000..32361b27dfcf3 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/terminalToolAutoExpand.test.ts @@ -0,0 +1,267 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter } from '../../../../../../../base/common/event.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../../../../base/test/common/timeTravelScheduler.js'; +import { timeout } from '../../../../../../../base/common/async.js'; +import { TerminalToolAutoExpand, TerminalToolAutoExpandTimeout } from '../../../../browser/widget/chatContentParts/toolInvocationParts/terminalToolAutoExpand.js'; +import type { ICommandDetectionCapability } from '../../../../../../../platform/terminal/common/capabilities/capabilities.js'; + +suite('TerminalToolAutoExpand', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + // Mocked events + let onCommandExecuted: Emitter; + let onCommandFinished: Emitter; + let onWillData: Emitter; + + // State tracking + let isExpanded: boolean; + let userToggledOutput: boolean; + let hasRealOutputValue: boolean; + + function shouldAutoExpand(): boolean { + return !isExpanded && !userToggledOutput; + } + + function hasRealOutput(): boolean { + return hasRealOutputValue; + } + + function setupAutoExpandLogic(): void { + // Create a mock command detection capability + const mockCommandDetection = { + onCommandExecuted: onCommandExecuted.event, + onCommandFinished: onCommandFinished.event, + } as Pick as ICommandDetectionCapability; + + // Use the real TerminalToolAutoExpand class + const autoExpand = store.add(new TerminalToolAutoExpand({ + commandDetection: mockCommandDetection, + onWillData: onWillData.event, + shouldAutoExpand, + hasRealOutput, + })); + store.add(autoExpand.onDidRequestExpand(() => { + isExpanded = true; + })); + } + + setup(() => { + onCommandExecuted = store.add(new Emitter()); + onCommandFinished = store.add(new Emitter()); + onWillData = store.add(new Emitter()); + + isExpanded = false; + userToggledOutput = false; + hasRealOutputValue = false; + }); + + test('fast command without data should not auto-expand (finishes before timeout)', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + setupAutoExpandLogic(); + + // Command executes + onCommandExecuted.fire(undefined); + + // Command finishes quickly (before timeout) + onCommandFinished.fire(undefined); + + // Wait past all timeouts (faked timers advance instantly) + await timeout(TerminalToolAutoExpandTimeout.NoData + 100); + + assert.strictEqual(isExpanded, false, 'Should NOT expand for fast command without data'); + })); + + test('fast command with quick data should not auto-expand (data + finish before timeout)', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + setupAutoExpandLogic(); + + // Command executes + onCommandExecuted.fire(undefined); + + // Data arrives + onWillData.fire('output'); + + // Command finishes quickly (before timeout) + onCommandFinished.fire(undefined); + + // Wait past all timeouts (faked timers advance instantly) + await timeout(TerminalToolAutoExpandTimeout.DataEvent + 100); + + assert.strictEqual(isExpanded, false, 'Should NOT expand when command finishes within timeout of first data'); + })); + + test('long-running command with data should auto-expand (data received, command still running after timeout)', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + hasRealOutputValue = true; // Has real output + setupAutoExpandLogic(); + + // Command executes + onCommandExecuted.fire(undefined); + + // Data arrives + onWillData.fire('output'); + + // Wait for timeout to fire (faked timers advance instantly) + await timeout(TerminalToolAutoExpandTimeout.DataEvent + 100); + + assert.strictEqual(isExpanded, true, 'Should expand when command still running after first data timeout'); + + onCommandFinished.fire(undefined); + })); + + test('long-running command with data but no real output should NOT auto-expand (like sleep with shell sequences)', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + hasRealOutputValue = false; // Shell integration sequences, not real output + setupAutoExpandLogic(); + + // Command executes + onCommandExecuted.fire(undefined); + + // Shell integration data arrives (not real output) + onWillData.fire('shell-sequence'); + + // Wait for timeout to fire (faked timers advance instantly) + await timeout(TerminalToolAutoExpandTimeout.DataEvent + 100); + + assert.strictEqual(isExpanded, false, 'Should NOT expand when data is shell sequences, not real output'); + + onCommandFinished.fire(undefined); + })); + + test('long-running command without data should NOT auto-expand if no real output (like sleep)', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + hasRealOutputValue = false; // No real output like `sleep 1` + setupAutoExpandLogic(); + + // Command executes + onCommandExecuted.fire(undefined); + + // Wait for timeout to fire (faked timers advance instantly) + await timeout(TerminalToolAutoExpandTimeout.NoData + 100); + + assert.strictEqual(isExpanded, false, 'Should NOT expand when no real output even after timeout'); + + onCommandFinished.fire(undefined); + })); + + test('long-running command without data SHOULD auto-expand if real output exists', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + hasRealOutputValue = true; // Has real output in buffer + setupAutoExpandLogic(); + + // Command executes + onCommandExecuted.fire(undefined); + + // Wait for timeout to fire (faked timers advance instantly) + await timeout(TerminalToolAutoExpandTimeout.NoData + 100); + + assert.strictEqual(isExpanded, true, 'Should expand when real output exists after timeout'); + + onCommandFinished.fire(undefined); + })); + + test('data arriving after command finish should not trigger expand', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + setupAutoExpandLogic(); + + // Command executes and finishes immediately + onCommandExecuted.fire(undefined); + onCommandFinished.fire(undefined); + + // Data arrives after command finished + onWillData.fire('late output'); + + // Wait past all timeouts (faked timers advance instantly) + await timeout(TerminalToolAutoExpandTimeout.NoData + 100); + + assert.strictEqual(isExpanded, false, 'Should NOT expand when data arrives after command finished'); + })); + + test('user toggled output prevents auto-expand', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + userToggledOutput = true; + setupAutoExpandLogic(); + + // Command executes + onCommandExecuted.fire(undefined); + + // Data arrives + onWillData.fire('output'); + + // Wait past all timeouts (faked timers advance instantly) + await timeout(TerminalToolAutoExpandTimeout.NoData + 100); + + assert.strictEqual(isExpanded, false, 'Should NOT expand when user has manually toggled output'); + onCommandFinished.fire(undefined); + })); + + test('already expanded output prevents additional auto-expand', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + isExpanded = true; + + // Create a mock command detection capability + const mockCommandDetection = { + onCommandExecuted: onCommandExecuted.event, + onCommandFinished: onCommandFinished.event, + } as Pick as ICommandDetectionCapability; + + // Track if event was fired + let eventFired = false; + const autoExpand = store.add(new TerminalToolAutoExpand({ + commandDetection: mockCommandDetection, + onWillData: onWillData.event, + shouldAutoExpand: () => !isExpanded && !userToggledOutput, + hasRealOutput: () => hasRealOutputValue, + })); + store.add(autoExpand.onDidRequestExpand(() => { + eventFired = true; + })); + + // Command executes + onCommandExecuted.fire(undefined); + + // Data arrives + onWillData.fire('output'); + + // Wait past all timeouts (faked timers advance instantly) + await timeout(TerminalToolAutoExpandTimeout.NoData + 100); + + assert.strictEqual(eventFired, false, 'Should NOT fire expand event when already expanded'); + onCommandFinished.fire(undefined); + })); + + test('data arriving cancels no-data timeout', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + hasRealOutputValue = true; // Would have expanded if no-data timeout fired + setupAutoExpandLogic(); + + // Command executes + onCommandExecuted.fire(undefined); + + // Data arrives (cancels no-data timeout) + onWillData.fire('output'); + + // Command finishes immediately after data (before data timeout would fire) + onCommandFinished.fire(undefined); + + // Wait past all timeouts (faked timers advance instantly) + await timeout(TerminalToolAutoExpandTimeout.NoData + 100); + + assert.strictEqual(isExpanded, false, 'No-data timeout should be cancelled when data arrives'); + })); + + test('multiple data events only trigger one timeout', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + hasRealOutputValue = true; // Has real output + setupAutoExpandLogic(); + + // Command executes + onCommandExecuted.fire(undefined); + + // Multiple data events + onWillData.fire('output 1'); + onWillData.fire('output 2'); + onWillData.fire('output 3'); + + // Wait for timeout to fire (faked timers advance instantly) + await timeout(TerminalToolAutoExpandTimeout.DataEvent + 100); + + assert.strictEqual(isExpanded, true, 'Should expand exactly once after first data'); + onCommandFinished.fire(undefined); + })); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 230ace54a1d35..ac42dfb5c4bfa 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import * as sinon from 'sinon'; import { CancellationToken } from '../../../../../../../base/common/cancellation.js'; +import { Event } from '../../../../../../../base/common/event.js'; import { match } from '../../../../../../../base/common/glob.js'; import { ResourceSet } from '../../../../../../../base/common/map.js'; import { Schemas } from '../../../../../../../base/common/network.js'; @@ -32,6 +33,7 @@ import { testWorkspace } from '../../../../../../../platform/workspace/test/comm import { IWorkbenchEnvironmentService } from '../../../../../../services/environment/common/environmentService.js'; import { IFilesConfigurationService } from '../../../../../../services/filesConfiguration/common/filesConfigurationService.js'; import { IUserDataProfileService } from '../../../../../../services/userDataProfile/common/userDataProfile.js'; +import { toUserDataProfile } from '../../../../../../../platform/userDataProfile/common/userDataProfile.js'; import { TestContextService, TestUserDataProfileService } from '../../../../../../test/common/workbenchTestServices.js'; import { ChatRequestVariableSet, isPromptFileVariableEntry, toFileVariableEntry } from '../../../../common/attachments/chatVariableEntries.js'; import { ComputeAutomaticInstructions, newInstructionsCollectionEvent } from '../../../../common/promptSyntax/computeAutomaticInstructions.js'; @@ -1091,6 +1093,476 @@ suite('PromptsService', () => { 'Must get custom agents with .md extension from .github/agents/ folder.', ); }); + + test('agents from user data folder', async () => { + const rootFolderName = 'custom-agents-user-data'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + const userPromptsFolder = '/user-data/prompts'; + const userPromptsFolderUri = URI.file(userPromptsFolder); + + // Override the user data profile service to use a file:// URI that the InMemoryFileSystemProvider supports + const customUserDataProfileService = { + _serviceBrand: undefined, + onDidChangeCurrentProfile: Event.None, + currentProfile: { + ...toUserDataProfile('test', 'test', URI.file(userPromptsFolder).with({ path: '/user-data' }), URI.file('/cache')), + promptsHome: userPromptsFolderUri, + }, + updateCurrentProfile: async () => { } + }; + instaService.stub(IUserDataProfileService, customUserDataProfileService); + + // Recreate the service with the new stub + const testService = disposables.add(instaService.createInstance(PromptsService)); + + // Create agent files in both workspace and user data folder + await mockFiles(fileService, [ + // Workspace agent + { + path: `${rootFolder}/.github/agents/workspace-agent.agent.md`, + contents: [ + '---', + 'description: \'Workspace agent.\'', + '---', + 'I am a workspace agent.', + ] + }, + // User data agent + { + path: `${userPromptsFolder}/user-agent.agent.md`, + contents: [ + '---', + 'description: \'User data agent.\'', + 'tools: [ user-tool ]', + '---', + 'I am a user data agent.', + ] + }, + // Another user data agent without header + { + path: `${userPromptsFolder}/simple-user-agent.agent.md`, + contents: [ + 'A simple user agent without header.', + ] + } + ]); + + const result = (await testService.getCustomAgents(CancellationToken.None)).map(agent => ({ ...agent, uri: URI.from(agent.uri) })); + + // Should find agents from both workspace and user data + assert.strictEqual(result.length, 3, 'Should find 3 agents (1 workspace + 2 user data)'); + + const workspaceAgent = result.find(a => a.source.storage === PromptsStorage.local); + assert.ok(workspaceAgent, 'Should find workspace agent'); + assert.strictEqual(workspaceAgent.name, 'workspace-agent'); + assert.strictEqual(workspaceAgent.description, 'Workspace agent.'); + + const userAgents = result.filter(a => a.source.storage === PromptsStorage.user); + assert.strictEqual(userAgents.length, 2, 'Should find 2 user data agents'); + + const userAgentWithHeader = userAgents.find(a => a.name === 'user-agent'); + assert.ok(userAgentWithHeader, 'Should find user agent with header'); + assert.strictEqual(userAgentWithHeader.description, 'User data agent.'); + assert.deepStrictEqual(userAgentWithHeader.tools, ['user-tool']); + + const simpleUserAgent = userAgents.find(a => a.name === 'simple-user-agent'); + assert.ok(simpleUserAgent, 'Should find simple user agent'); + assert.strictEqual(simpleUserAgent.agentInstructions.content, 'A simple user agent without header.'); + }); + }); + + suite('listPromptFiles - prompts', () => { + test('prompts from user data folder', async () => { + const rootFolderName = 'prompts-user-data'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + const userPromptsFolder = '/user-data/prompts'; + const userPromptsFolderUri = URI.file(userPromptsFolder); + + // Override the user data profile service + const customUserDataProfileService = { + _serviceBrand: undefined, + onDidChangeCurrentProfile: Event.None, + currentProfile: { + ...toUserDataProfile('test', 'test', URI.file(userPromptsFolder).with({ path: '/user-data' }), URI.file('/cache')), + promptsHome: userPromptsFolderUri, + }, + updateCurrentProfile: async () => { } + }; + instaService.stub(IUserDataProfileService, customUserDataProfileService); + + // Recreate the service with the new stub + const testService = disposables.add(instaService.createInstance(PromptsService)); + + // Create prompt files in both workspace and user data folder + await mockFiles(fileService, [ + // Workspace prompt + { + path: `${rootFolder}/.github/prompts/workspace-prompt.prompt.md`, + contents: [ + '---', + 'description: \'Workspace prompt.\'', + '---', + 'I am a workspace prompt.', + ] + }, + // User data prompt + { + path: `${userPromptsFolder}/user-prompt.prompt.md`, + contents: [ + '---', + 'description: \'User data prompt.\'', + '---', + 'I am a user data prompt.', + ] + } + ]); + + const result = await testService.listPromptFiles(PromptsType.prompt, CancellationToken.None); + + // Should find prompts from both workspace and user data + assert.strictEqual(result.length, 2, 'Should find 2 prompts (1 workspace + 1 user data)'); + + const workspacePrompt = result.find(p => p.storage === PromptsStorage.local); + assert.ok(workspacePrompt, 'Should find workspace prompt'); + assert.ok(workspacePrompt.uri.path.includes('workspace-prompt.prompt.md')); + + const userPrompt = result.find(p => p.storage === PromptsStorage.user); + assert.ok(userPrompt, 'Should find user data prompt'); + assert.ok(userPrompt.uri.path.includes('user-prompt.prompt.md')); + }); + }); + + suite('listPromptFiles - instructions', () => { + test('instructions from user data folder', async () => { + const rootFolderName = 'instructions-user-data'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + const userPromptsFolder = '/user-data/prompts'; + const userPromptsFolderUri = URI.file(userPromptsFolder); + + // Override the user data profile service + const customUserDataProfileService = { + _serviceBrand: undefined, + onDidChangeCurrentProfile: Event.None, + currentProfile: { + ...toUserDataProfile('test', 'test', URI.file(userPromptsFolder).with({ path: '/user-data' }), URI.file('/cache')), + promptsHome: userPromptsFolderUri, + }, + updateCurrentProfile: async () => { } + }; + instaService.stub(IUserDataProfileService, customUserDataProfileService); + + // Recreate the service with the new stub + const testService = disposables.add(instaService.createInstance(PromptsService)); + + // Create instructions files in both workspace and user data folder + await mockFiles(fileService, [ + // Workspace instructions + { + path: `${rootFolder}/.github/instructions/workspace-instructions.instructions.md`, + contents: [ + '---', + 'description: \'Workspace instructions.\'', + 'applyTo: "**/*.ts"', + '---', + 'I am workspace instructions.', + ] + }, + // User data instructions + { + path: `${userPromptsFolder}/user-instructions.instructions.md`, + contents: [ + '---', + 'description: \'User data instructions.\'', + 'applyTo: "**/*.tsx"', + '---', + 'I am user data instructions.', + ] + } + ]); + + const result = await testService.listPromptFiles(PromptsType.instructions, CancellationToken.None); + + // Should find instructions from both workspace and user data + assert.strictEqual(result.length, 2, 'Should find 2 instructions (1 workspace + 1 user data)'); + + const workspaceInstructions = result.find(p => p.storage === PromptsStorage.local); + assert.ok(workspaceInstructions, 'Should find workspace instructions'); + assert.ok(workspaceInstructions.uri.path.includes('workspace-instructions.instructions.md')); + + const userInstructions = result.find(p => p.storage === PromptsStorage.user); + assert.ok(userInstructions, 'Should find user data instructions'); + assert.ok(userInstructions.uri.path.includes('user-instructions.instructions.md')); + }); + }); + + suite('listPromptFiles - skills', () => { + teardown(() => { + sinon.restore(); + }); + + test('should list skill files from workspace', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'list-skills-workspace'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/skills/skill1/SKILL.md`, + contents: [ + '---', + 'name: "Skill 1"', + 'description: "First skill"', + '---', + 'Skill 1 content', + ], + }, + { + path: `${rootFolder}/.claude/skills/skill2/SKILL.md`, + contents: [ + '---', + 'name: "Skill 2"', + 'description: "Second skill"', + '---', + 'Skill 2 content', + ], + }, + ]); + + const result = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + + assert.strictEqual(result.length, 2, 'Should find 2 skills'); + + const skill1 = result.find(s => s.uri.path.includes('skill1')); + assert.ok(skill1, 'Should find skill1'); + assert.strictEqual(skill1.type, PromptsType.skill); + assert.strictEqual(skill1.storage, PromptsStorage.local); + + const skill2 = result.find(s => s.uri.path.includes('skill2')); + assert.ok(skill2, 'Should find skill2'); + assert.strictEqual(skill2.type, PromptsType.skill); + assert.strictEqual(skill2.storage, PromptsStorage.local); + }); + + test('should list skill files from user home', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'list-skills-user-home'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: '/home/user/.copilot/skills/personal-skill/SKILL.md', + contents: [ + '---', + 'name: "Personal Skill"', + 'description: "A personal skill"', + '---', + 'Personal skill content', + ], + }, + { + path: '/home/user/.claude/skills/claude-personal/SKILL.md', + contents: [ + '---', + 'name: "Claude Personal Skill"', + 'description: "A Claude personal skill"', + '---', + 'Claude personal skill content', + ], + }, + ]); + + const result = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + + const personalSkills = result.filter(s => s.storage === PromptsStorage.user); + assert.strictEqual(personalSkills.length, 2, 'Should find 2 personal skills'); + + const copilotSkill = personalSkills.find(s => s.uri.path.includes('.copilot')); + assert.ok(copilotSkill, 'Should find copilot personal skill'); + + const claudeSkill = personalSkills.find(s => s.uri.path.includes('.claude')); + assert.ok(claudeSkill, 'Should find claude personal skill'); + }); + + test('should not list skills when not in skill folder structure', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + + const rootFolderName = 'no-skills'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // Create files in non-skill locations + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/prompts/SKILL.md`, + contents: [ + '---', + 'name: "Not a skill"', + '---', + 'This is in prompts folder, not skills', + ], + }, + { + path: `${rootFolder}/SKILL.md`, + contents: [ + '---', + 'name: "Root skill"', + '---', + 'This is in root, not skills folder', + ], + }, + ]); + + const result = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + + assert.strictEqual(result.length, 0, 'Should not find any skills in non-skill locations'); + }); + + test('should handle mixed workspace and user home skills', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'mixed-skills'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + // Workspace skills + { + path: `${rootFolder}/.github/skills/workspace-skill/SKILL.md`, + contents: [ + '---', + 'name: "Workspace Skill"', + 'description: "A workspace skill"', + '---', + 'Workspace skill content', + ], + }, + // User home skills + { + path: '/home/user/.copilot/skills/personal-skill/SKILL.md', + contents: [ + '---', + 'name: "Personal Skill"', + 'description: "A personal skill"', + '---', + 'Personal skill content', + ], + }, + ]); + + const result = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + + const workspaceSkills = result.filter(s => s.storage === PromptsStorage.local); + const userSkills = result.filter(s => s.storage === PromptsStorage.user); + + assert.strictEqual(workspaceSkills.length, 1, 'Should find 1 workspace skill'); + assert.strictEqual(userSkills.length, 1, 'Should find 1 user skill'); + }); + + test('should respect disabled default paths via config', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + // Disable .github/skills, only .claude/skills should be searched + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, { + '.github/skills': false, + '.claude/skills': true, + }); + + const rootFolderName = 'disabled-default-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/skills/github-skill/SKILL.md`, + contents: [ + '---', + 'name: "GitHub Skill"', + 'description: "Should NOT be found"', + '---', + 'This skill is in a disabled folder', + ], + }, + { + path: `${rootFolder}/.claude/skills/claude-skill/SKILL.md`, + contents: [ + '---', + 'name: "Claude Skill"', + 'description: "Should be found"', + '---', + 'This skill is in an enabled folder', + ], + }, + ]); + + const result = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + + assert.strictEqual(result.length, 1, 'Should find only 1 skill (from enabled folder)'); + assert.ok(result[0].uri.path.includes('.claude/skills'), 'Should only find skill from .claude/skills'); + assert.ok(!result[0].uri.path.includes('.github/skills'), 'Should not find skill from disabled .github/skills'); + }); + + test('should expand tilde paths in custom locations', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + // Add a tilde path as custom location + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, { + '.github/skills': false, + '.claude/skills': false, + '~/my-custom-skills': true, + }); + + const rootFolderName = 'tilde-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // The mock user home is /home/user, so ~/my-custom-skills should resolve to /home/user/my-custom-skills + await mockFiles(fileService, [ + { + path: '/home/user/my-custom-skills/custom-skill/SKILL.md', + contents: [ + '---', + 'name: "Custom Skill"', + 'description: "A skill from tilde path"', + '---', + 'Skill content from ~/my-custom-skills', + ], + }, + ]); + + const result = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); + + assert.strictEqual(result.length, 1, 'Should find 1 skill from tilde-expanded path'); + assert.ok(result[0].uri.path.includes('/home/user/my-custom-skills'), 'Path should be expanded from tilde'); + }); }); suite('listPromptFiles - skills', () => { diff --git a/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.contribution.ts b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.contribution.ts new file mode 100644 index 0000000000000..1a39d62ed6631 --- /dev/null +++ b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.contribution.ts @@ -0,0 +1,122 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../nls.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { registerWorkbenchContribution2, WorkbenchPhase, IWorkbenchContribution } from '../../../common/contributions.js'; +import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/editor.js'; +import { EditorExtensions, IEditorFactoryRegistry } from '../../../common/editor.js'; +import { IEditorResolverService, RegisteredEditorPriority } from '../../../services/editor/common/editorResolverService.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; +import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; +import { AgentSessionsWelcomeInput } from './agentSessionsWelcomeInput.js'; +import { AgentSessionsWelcomePage, AgentSessionsWelcomeInputSerializer } from './agentSessionsWelcome.js'; + +// Registration priority +const agentSessionsWelcomeInputTypeId = 'workbench.editors.agentSessionsWelcomeInput'; + +// Register editor serializer +Registry.as(EditorExtensions.EditorFactory) + .registerEditorSerializer(agentSessionsWelcomeInputTypeId, AgentSessionsWelcomeInputSerializer); + +// Register editor pane +Registry.as(EditorExtensions.EditorPane).registerEditorPane( + EditorPaneDescriptor.create( + AgentSessionsWelcomePage, + AgentSessionsWelcomePage.ID, + localize('agentSessionsWelcome', "Agent Sessions Welcome") + ), + [ + new SyncDescriptor(AgentSessionsWelcomeInput) + ] +); + +// Register resolver contribution +class AgentSessionsWelcomeEditorResolverContribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.agentSessionsWelcomeEditorResolver'; + + constructor( + @IEditorResolverService editorResolverService: IEditorResolverService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + this._register(editorResolverService.registerEditor( + `${AgentSessionsWelcomeInput.RESOURCE.scheme}:${AgentSessionsWelcomeInput.RESOURCE.authority}/**`, + { + id: AgentSessionsWelcomePage.ID, + label: localize('agentSessionsWelcome.displayName', "Agent Sessions Welcome"), + priority: RegisteredEditorPriority.builtin, + }, + { + singlePerResource: true, + canSupportResource: resource => + resource.scheme === AgentSessionsWelcomeInput.RESOURCE.scheme && + resource.authority === AgentSessionsWelcomeInput.RESOURCE.authority + }, + { + createEditorInput: () => { + return { + editor: instantiationService.createInstance(AgentSessionsWelcomeInput, {}), + }; + } + } + )); + } +} + +// Register command to open agent sessions welcome page +CommandsRegistry.registerCommand('workbench.action.openAgentSessionsWelcome', (accessor) => { + const editorService = accessor.get(IEditorService); + const instantiationService = accessor.get(IInstantiationService); + const input = instantiationService.createInstance(AgentSessionsWelcomeInput, {}); + return editorService.openEditor(input, { pinned: true }); +}); + +// Runner contribution - handles opening on startup +class AgentSessionsWelcomeRunnerContribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.agentSessionsWelcomeRunner'; + + constructor( + @IConfigurationService private readonly configurationService: IConfigurationService, + @IEditorService private readonly editorService: IEditorService, + @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + this.run(); + } + + private async run(): Promise { + // Get startup editor configuration + const startupEditor = this.configurationService.getValue('workbench.startupEditor'); + + // Only proceed if configured to show agent sessions welcome page + if (startupEditor !== 'agentSessionsWelcomePage') { + return; + } + + // Wait for editors to restore + await this.editorGroupsService.whenReady; + + // Don't open if there are already editors open + if (this.editorService.activeEditor) { + return; + } + + // Open the agent sessions welcome page + const input = this.instantiationService.createInstance(AgentSessionsWelcomeInput, {}); + await this.editorService.openEditor(input, { pinned: false }); + } +} + +// Register contributions +registerWorkbenchContribution2(AgentSessionsWelcomeEditorResolverContribution.ID, AgentSessionsWelcomeEditorResolverContribution, WorkbenchPhase.BlockStartup); +registerWorkbenchContribution2(AgentSessionsWelcomeRunnerContribution.ID, AgentSessionsWelcomeRunnerContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts new file mode 100644 index 0000000000000..b820d539d1f83 --- /dev/null +++ b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts @@ -0,0 +1,440 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/agentSessionsWelcome.css'; +import { $, addDisposableListener, append, clearNode, Dimension, getWindow, scheduleAtNextAnimationFrame } from '../../../../base/browser/dom.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { DomScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { Toggle } from '../../../../base/browser/ui/toggle/toggle.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { DisposableStore, IReference, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { ScrollbarVisibility } from '../../../../base/common/scrollable.js'; +import { localize } from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { editorBackground } from '../../../../platform/theme/common/colorRegistry.js'; +import { defaultToggleStyles, getListStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { EditorPane } from '../../../browser/parts/editor/editorPane.js'; +import { IEditorOpenContext, IEditorSerializer } from '../../../common/editor.js'; +import { SIDE_BAR_FOREGROUND } from '../../../common/theme.js'; +import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; +import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js'; +import { ChatAgentLocation, ChatModeKind } from '../../chat/common/constants.js'; +import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; +import { ChatWidget } from '../../chat/browser/widget/chatWidget.js'; +import { IAgentSessionsService } from '../../chat/browser/agentSessions/agentSessionsService.js'; +import { AgentSessionProviders } from '../../chat/browser/agentSessions/agentSessions.js'; +import { IAgentSession } from '../../chat/browser/agentSessions/agentSessionsModel.js'; +import { AgentSessionsWelcomeEditorOptions, AgentSessionsWelcomeInput } from './agentSessionsWelcomeInput.js'; +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 { IAgentSessionsFilter } from '../../chat/browser/agentSessions/agentSessionsViewer.js'; +import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; +import { IResolvedWalkthrough, IWalkthroughsService } from '../../welcomeGettingStarted/browser/gettingStartedService.js'; + +const configurationKey = 'workbench.startupEditor'; +const MAX_SESSIONS = 6; + +export class AgentSessionsWelcomePage extends EditorPane { + + static readonly ID = 'agentSessionsWelcomePage'; + + private container!: HTMLElement; + private contentContainer!: HTMLElement; + private scrollableElement: DomScrollableElement | undefined; + private chatWidget: ChatWidget | undefined; + private chatModelRef: IReference | undefined; + private sessionsControl: AgentSessionsControl | undefined; + private sessionsControlContainer: HTMLElement | undefined; + private readonly sessionsControlDisposables = this._register(new DisposableStore()); + private readonly contentDisposables = this._register(new DisposableStore()); + private contextService: IContextKeyService; + private walkthroughs: IResolvedWalkthrough[] = []; + private _selectedSessionProvider: AgentSessionProviders = AgentSessionProviders.Local; + + constructor( + group: IEditorGroup, + @ITelemetryService telemetryService: ITelemetryService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @ICommandService private readonly commandService: ICommandService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IProductService private readonly productService: IProductService, + @IWalkthroughsService private readonly walkthroughsService: IWalkthroughsService, + @IChatService private readonly chatService: IChatService, + ) { + super(AgentSessionsWelcomePage.ID, group, telemetryService, themeService, storageService); + + this.container = $('.agentSessionsWelcome', { + role: 'document', + tabindex: 0, + 'aria-label': localize('agentSessionsWelcomeAriaLabel', "Overview of agent sessions and how to get started.") + }); + + this.contextService = this._register(contextKeyService.createScoped(this.container)); + ChatContextKeys.inAgentSessionsWelcome.bindTo(this.contextService).set(true); + } + + protected createEditor(parent: HTMLElement): void { + parent.appendChild(this.container); + + // Create scrollable content + this.contentContainer = $('.agentSessionsWelcome-content'); + this.scrollableElement = this._register(new DomScrollableElement(this.contentContainer, { + className: 'agentSessionsWelcome-scrollable', + vertical: ScrollbarVisibility.Auto + })); + this.container.appendChild(this.scrollableElement.getDomNode()); + } + + override async setInput(input: AgentSessionsWelcomeInput, options: AgentSessionsWelcomeEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { + await super.setInput(input, options, context, token); + await this.buildContent(); + } + + private async buildContent(): Promise { + this.contentDisposables.clear(); + this.sessionsControlDisposables.clear(); + this.sessionsControl = undefined; + clearNode(this.contentContainer); + + // Get walkthroughs + this.walkthroughs = this.walkthroughsService.getWalkthroughs(); + + // Header + const header = append(this.contentContainer, $('.agentSessionsWelcome-header')); + append(header, $('h1.product-name', {}, this.productService.nameLong)); + + const startEntries = append(header, $('.agentSessionsWelcome-startEntries')); + this.buildStartEntries(startEntries); + + // Chat input section + const chatSection = append(this.contentContainer, $('.agentSessionsWelcome-chatSection')); + this.buildChatWidget(chatSection); + + // Sessions or walkthroughs + const sessions = this.agentSessionsService.model.sessions; + const sessionsSection = append(this.contentContainer, $('.agentSessionsWelcome-sessionsSection')); + if (sessions.length > 0) { + this.buildSessionsGrid(sessionsSection, sessions); + } else { + const walkthroughsSection = append(this.contentContainer, $('.agentSessionsWelcome-walkthroughsSection')); + this.buildWalkthroughs(walkthroughsSection); + } + + // Footer + const footer = append(this.contentContainer, $('.agentSessionsWelcome-footer')); + this.buildFooter(footer); + + // Listen for session changes - store reference to avoid querySelector + this.contentDisposables.add(this.agentSessionsService.model.onDidChangeSessions(() => { + clearNode(sessionsSection); + this.buildSessionsOrPrompts(sessionsSection); + })); + + this.scrollableElement?.scanDomNode(); + } + + private buildStartEntries(container: HTMLElement): void { + const entries = [ + { icon: Codicon.folderOpened, label: localize('openRecent', "Open Recent..."), command: 'workbench.action.openRecent' }, + { icon: Codicon.newFile, label: localize('newFile', "New file..."), command: 'workbench.action.files.newUntitledFile' }, + { icon: Codicon.repoClone, label: localize('cloneRepo', "Clone Git Repository..."), command: 'git.clone' }, + ]; + + for (const entry of entries) { + const button = append(container, $('button.agentSessionsWelcome-startEntry')); + button.appendChild(renderIcon(entry.icon)); + button.appendChild(document.createTextNode(entry.label)); + button.onclick = () => this.commandService.executeCommand(entry.command); + } + } + + private buildChatWidget(container: HTMLElement): void { + const chatWidgetContainer = append(container, $('.agentSessionsWelcome-chatWidget')); + + // Create editor overflow widgets container + const editorOverflowWidgetsDomNode = this.layoutService.getContainer(getWindow(chatWidgetContainer)).appendChild($('.chat-editor-overflow.monaco-editor')); + this.contentDisposables.add(toDisposable(() => editorOverflowWidgetsDomNode.remove())); + + // Create ChatWidget with scoped services + const scopedContextKeyService = this.contentDisposables.add(this.contextService.createScoped(chatWidgetContainer)); + const scopedInstantiationService = this.contentDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, scopedContextKeyService]))); + + // Create a delegate for the session target picker with independent local state + const onDidChangeActiveSessionProvider = this.contentDisposables.add(new Emitter()); + const sessionTypePickerDelegate: ISessionTypePickerDelegate = { + getActiveSessionProvider: () => this._selectedSessionProvider, + setActiveSessionProvider: (provider: AgentSessionProviders) => { + this._selectedSessionProvider = provider; + onDidChangeActiveSessionProvider.fire(provider); + }, + onDidChangeActiveSessionProvider: onDidChangeActiveSessionProvider.event + }; + + this.chatWidget = this.contentDisposables.add(scopedInstantiationService.createInstance( + ChatWidget, + ChatAgentLocation.Chat, + // TODO: @osortega should we have a completely different ID and check that context instead in chatInputPart? + {}, // Empty resource view context + { + autoScroll: mode => mode !== ChatModeKind.Ask, + renderFollowups: false, + supportsFileReferences: true, + renderInputOnTop: true, + rendererOptions: { + renderTextEditsAsSummary: () => true, + referencesExpandedWhenEmptyResponse: false, + progressMessageAtBottomOfResponse: mode => mode !== ChatModeKind.Ask, + }, + editorOverflowWidgetsDomNode, + enableImplicitContext: true, + enableWorkingSet: 'explicit', + supportsChangingModes: true, + sessionTypePickerDelegate, + }, + { + listForeground: SIDE_BAR_FOREGROUND, + listBackground: editorBackground, + overlayBackground: editorBackground, + inputEditorBackground: editorBackground, + resultEditorBackground: editorBackground, + } + )); + + this.chatWidget.render(chatWidgetContainer); + this.chatWidget.setVisible(true); + + // 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); + this.contentDisposables.add(this.chatModelRef); + if (this.chatModelRef.object) { + this.chatWidget.setModel(this.chatModelRef.object); + } + + // Focus the input when clicking anywhere in the chat widget area + // This ensures our widget becomes lastFocusedWidget for the chatWidgetService + this.contentDisposables.add(addDisposableListener(chatWidgetContainer, 'mousedown', () => { + this.chatWidget?.focusInput(); + })); + } + + private buildSessionsOrPrompts(container: HTMLElement): void { + // Clear previous sessions control + this.sessionsControlDisposables.clear(); + this.sessionsControl = undefined; + + const sessions = this.agentSessionsService.model.sessions; + + if (sessions.length > 0) { + this.buildSessionsGrid(container, sessions); + } + } + + + private buildSessionsGrid(container: HTMLElement, _sessions: IAgentSession[]): void { + this.sessionsControlContainer = append(container, $('.agentSessionsWelcome-sessionsGrid')); + + // Create a filter that limits results and excludes archived sessions + const onDidChangeEmitter = this.sessionsControlDisposables.add(new Emitter()); + const filter: IAgentSessionsFilter = { + onDidChange: onDidChangeEmitter.event, + limitResults: () => MAX_SESSIONS, + groupResults: () => false, + exclude: (session: IAgentSession) => session.isArchived(), + getExcludes: () => ({ + providers: [], + states: [], + archived: true, + read: false, + }), + }; + + const options: IAgentSessionsControlOptions = { + overrideStyles: getListStyles({ + listBackground: editorBackground, + }), + filter, + getHoverPosition: () => HoverPosition.BELOW, + trackActiveEditorSession: () => false, + }; + + this.sessionsControl = this.sessionsControlDisposables.add(this.instantiationService.createInstance( + AgentSessionsControl, + this.sessionsControlContainer, + options + )); + + // Schedule layout at next animation frame to ensure proper rendering + this.sessionsControlDisposables.add(scheduleAtNextAnimationFrame(getWindow(this.sessionsControlContainer), () => { + this.layoutSessionsControl(); + })); + + // "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'); + } + + private buildWalkthroughs(container: HTMLElement): void { + const activeWalkthroughs = this.walkthroughs.filter(w => + !w.when || this.contextService.contextMatchesRules(w.when) + ).slice(0, 3); + + if (activeWalkthroughs.length === 0) { + return; + } + + for (const walkthrough of activeWalkthroughs) { + const card = append(container, $('.agentSessionsWelcome-walkthroughCard')); + card.onclick = () => { + this.commandService.executeCommand('workbench.action.openWalkthrough', walkthrough.id); + }; + + // Icon + const iconContainer = append(card, $('.agentSessionsWelcome-walkthroughCard-icon')); + if (walkthrough.icon.type === 'icon') { + iconContainer.appendChild(renderIcon(walkthrough.icon.icon)); + } + + // Content + const content = append(card, $('.agentSessionsWelcome-walkthroughCard-content')); + const title = append(content, $('.agentSessionsWelcome-walkthroughCard-title')); + title.textContent = walkthrough.title; + + if (walkthrough.description) { + const desc = append(content, $('.agentSessionsWelcome-walkthroughCard-description')); + desc.textContent = walkthrough.description; + } + + // Navigation arrows container + const navContainer = append(card, $('.agentSessionsWelcome-walkthroughCard-nav')); + const prevButton = append(navContainer, $('button.nav-button')); + prevButton.appendChild(renderIcon(Codicon.chevronLeft)); + prevButton.onclick = (e) => { e.stopPropagation(); }; + + const nextButton = append(navContainer, $('button.nav-button')); + nextButton.appendChild(renderIcon(Codicon.chevronRight)); + nextButton.onclick = (e) => { e.stopPropagation(); }; + } + } + + private buildFooter(container: HTMLElement): void { + // Learning link + const learningLink = append(container, $('button.agentSessionsWelcome-footerLink')); + learningLink.appendChild(renderIcon(Codicon.mortarBoard)); + learningLink.appendChild(document.createTextNode(localize('exploreHelp', "Explore Learning & Help Resources"))); + learningLink.onclick = () => this.commandService.executeCommand('workbench.action.openWalkthrough'); + + // Show on startup checkbox + const showOnStartupContainer = append(container, $('.agentSessionsWelcome-showOnStartup')); + const showOnStartupCheckbox = this.contentDisposables.add(new Toggle({ + icon: Codicon.check, + actionClassName: 'agentSessionsWelcome-checkbox', + isChecked: this.configurationService.getValue(configurationKey) === 'agentSessionsWelcomePage', + title: localize('checkboxTitle', "When checked, this page will be shown on startup."), + ...defaultToggleStyles + })); + showOnStartupCheckbox.domNode.id = 'showOnStartup'; + const showOnStartupLabel = $('label.caption', { for: 'showOnStartup' }, localize('showOnStartup', "Show welcome page on startup")); + + const onShowOnStartupChanged = () => { + if (showOnStartupCheckbox.checked) { + this.configurationService.updateValue(configurationKey, 'agentSessionsWelcomePage'); + } else { + this.configurationService.updateValue(configurationKey, 'none'); + } + }; + + this.contentDisposables.add(showOnStartupCheckbox.onChange(() => onShowOnStartupChanged())); + this.contentDisposables.add(addDisposableListener(showOnStartupLabel, 'click', () => { + showOnStartupCheckbox.checked = !showOnStartupCheckbox.checked; + onShowOnStartupChanged(); + })); + + showOnStartupContainer.appendChild(showOnStartupCheckbox.domNode); + showOnStartupContainer.appendChild(showOnStartupLabel); + } + + private lastDimension: Dimension | undefined; + + override layout(dimension: Dimension): void { + this.lastDimension = dimension; + 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 sessions control + this.layoutSessionsControl(); + + this.scrollableElement?.scanDomNode(); + } + + private layoutSessionsControl(): void { + if (!this.sessionsControl || !this.sessionsControlContainer || !this.lastDimension) { + return; + } + + // TODO: @osortega this is a weird way of doing this, maybe we handle the 2-colum layout in the control itself? + const sessionsWidth = Math.min(800, this.lastDimension.width - 80); + // Calculate height based on actual visible sessions (capped at MAX_SESSIONS) + // Use 52px per item from AgentSessionsListDelegate.ITEM_HEIGHT + // Give the list FULL height so virtualization renders all items + // CSS transforms handle the 2-column visual layout + const visibleSessions = Math.min( + this.agentSessionsService.model.sessions.filter(s => !s.isArchived()).length, + MAX_SESSIONS + ); + const sessionsHeight = visibleSessions * 52; + this.sessionsControl.layout(sessionsHeight, sessionsWidth); + + // Set margin offset for 2-column layout: actual height - visual height + // Visual height = ceil(n/2) * 52, so offset = floor(n/2) * 52 + const marginOffset = Math.floor(visibleSessions / 2) * 52; + this.sessionsControl.setGridMarginOffset(marginOffset); + } + + override focus(): void { + super.focus(); + this.chatWidget?.focusInput(); + } +} + +export class AgentSessionsWelcomeInputSerializer implements IEditorSerializer { + canSerialize(editorInput: AgentSessionsWelcomeInput): boolean { + return true; + } + + serialize(editorInput: AgentSessionsWelcomeInput): string { + return JSON.stringify({}); + } + + deserialize(instantiationService: IInstantiationService, serializedEditorInput: string): AgentSessionsWelcomeInput { + return new AgentSessionsWelcomeInput({}); + } +} diff --git a/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcomeInput.ts b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcomeInput.ts new file mode 100644 index 0000000000000..5b5cdf097c3c6 --- /dev/null +++ b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcomeInput.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../nls.js'; +import { EditorInput } from '../../../common/editor/editorInput.js'; +import { URI } from '../../../../base/common/uri.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { IUntypedEditorInput } from '../../../common/editor.js'; +import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; + +export const agentSessionsWelcomeInputTypeId = 'workbench.editors.agentSessionsWelcomeInput'; + +export interface AgentSessionsWelcomeEditorOptions extends IEditorOptions { + showTelemetryNotice?: boolean; +} + +export class AgentSessionsWelcomeInput extends EditorInput { + + static readonly ID = agentSessionsWelcomeInputTypeId; + static readonly RESOURCE = URI.from({ scheme: Schemas.walkThrough, authority: 'vscode_agent_sessions_welcome' }); + + private _showTelemetryNotice: boolean; + + override get typeId(): string { + return AgentSessionsWelcomeInput.ID; + } + + override get editorId(): string | undefined { + return this.typeId; + } + + override toUntyped(): IUntypedEditorInput { + return { + resource: AgentSessionsWelcomeInput.RESOURCE, + options: { + override: AgentSessionsWelcomeInput.ID, + pinned: false + } + }; + } + + get resource(): URI | undefined { + return AgentSessionsWelcomeInput.RESOURCE; + } + + override matches(other: EditorInput | IUntypedEditorInput): boolean { + if (super.matches(other)) { + return true; + } + + return other instanceof AgentSessionsWelcomeInput; + } + + constructor(options: AgentSessionsWelcomeEditorOptions = {}) { + super(); + this._showTelemetryNotice = !!options.showTelemetryNotice; + } + + override getName() { + return localize('agentSessionsWelcome', "Welcome"); + } + + get showTelemetryNotice(): boolean { + return this._showTelemetryNotice; + } + + set showTelemetryNotice(value: boolean) { + this._showTelemetryNotice = value; + } +} diff --git a/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css b/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css new file mode 100644 index 0000000000000..35d56a9feabdc --- /dev/null +++ b/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css @@ -0,0 +1,360 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.agentSessionsWelcome { + display: flex; + flex-direction: column; + height: 100%; + background-color: var(--vscode-welcomePage-background); + overflow: hidden; +} + +.agentSessionsWelcome-scrollable { + height: 100%; +} + +.agentSessionsWelcome-content { + display: flex; + flex-direction: column; + align-items: center; + padding: 40px 40px 80px; + max-width: 900px; + margin: 0 auto; + width: 100%; + box-sizing: border-box; +} + +/* Header */ +.agentSessionsWelcome-header { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 24px; + width: 100%; +} + +.agentSessionsWelcome-header h1.product-name { + font-size: 32px; + font-weight: 400; + margin: 0 0 12px 0; + color: var(--vscode-foreground); +} + +.agentSessionsWelcome-startEntries { + display: flex; + gap: 16px; + flex-wrap: wrap; + justify-content: center; +} + +.agentSessionsWelcome-startEntry { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border: none; + border-radius: 4px; + background-color: transparent; + color: var(--vscode-foreground); + font-size: 13px; + cursor: pointer; +} + +.agentSessionsWelcome-startEntry:hover { + color: var(--vscode-textLink-foreground); +} + +.agentSessionsWelcome-startEntry .codicon { + color: var(--vscode-descriptionForeground); +} + +/* Chat widget section */ +.agentSessionsWelcome-chatSection { + width: 100%; + max-width: 800px; + margin-bottom: 24px; +} + +/* Hide the chat tree/list - we only want the input */ +.agentSessionsWelcome-chatWidget .interactive-list { + display: none !important; +} + +/* Hide the welcome message containers */ +.agentSessionsWelcome-chatWidget .chat-welcome-view, +.agentSessionsWelcome-chatWidget .chat-welcome-view-container { + display: none !important; +} + +/* Constrain the chat container height - let it size to content */ +.agentSessionsWelcome-chatWidget .interactive-session { + height: auto !important; +} + +/* Input part styling - match chat panel */ +.agentSessionsWelcome-chatWidget .interactive-input-part { + margin: 0; + padding: 16px 0; +} + +/* Suggested prompts */ +.agentSessionsWelcome-sessionsSection { + width: 100%; + max-width: 800px; + margin-bottom: 24px; +} + +.agentSessionsWelcome-suggestedPrompts { + display: flex; + gap: 12px; + justify-content: center; + flex-wrap: wrap; +} + +.agentSessionsWelcome-suggestedPrompt { + padding: 8px 16px; + border: 1px solid var(--vscode-button-border, var(--vscode-contrastBorder, transparent)); + border-radius: 20px; + background-color: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + font-size: 13px; + cursor: pointer; + transition: background-color 0.1s; +} + +.agentSessionsWelcome-suggestedPrompt:hover { + background-color: var(--vscode-button-secondaryHoverBackground); +} + +/* Sessions grid */ +.agentSessionsWelcome-sessionsGrid { + margin-bottom: 12px; + width: 100%; + overflow: hidden; +} + +/* Style the agent sessions control within welcome page - 2 column layout */ +.agentSessionsWelcome-sessionsGrid .agent-sessions-viewer { + height: auto; + min-height: 0; +} + +.agentSessionsWelcome-sessionsGrid .agent-sessions-viewer .monaco-list { + background: transparent !important; +} + +.agentSessionsWelcome-sessionsGrid .agent-sessions-viewer .monaco-list-rows { + background: transparent !important; +} + +/* Hide scrollbars in welcome page sessions list */ +.agentSessionsWelcome-sessionsGrid .agent-sessions-viewer .monaco-scrollable-element > .scrollbar { + display: none !important; +} + +/* 2-column grid layout using CSS transforms on virtualized list */ +.agentSessionsWelcome-sessionsGrid .monaco-list-row { + width: 50% !important; +} + +/* + * Transform items into 2-column layout: + * - Items 0,1 form visual row 1 (top: 0) + * - Items 2,3 form visual row 2 (top: 52) + * - Items 4,5 form visual row 3 (top: 104) + * Left column (even): items stay in place or move up + * Right column (odd): items move right and up + */ + +/* Item 1 (index 1): move to right column of row 1 */ +.agentSessionsWelcome-sessionsGrid .monaco-list-row:nth-child(2) { + transform: translateX(100%) translateY(-52px); +} + +/* Item 2 (index 2): move up to row 2 left column */ +.agentSessionsWelcome-sessionsGrid .monaco-list-row:nth-child(3) { + transform: translateY(-52px); +} + +/* Item 3 (index 3): move to right column of row 2 */ +.agentSessionsWelcome-sessionsGrid .monaco-list-row:nth-child(4) { + transform: translateX(100%) translateY(-104px); +} + +/* Item 4 (index 4): move up to row 3 left column */ +.agentSessionsWelcome-sessionsGrid .monaco-list-row:nth-child(5) { + transform: translateY(-104px); +} + +/* Item 5 (index 5): move to right column of row 3 */ +.agentSessionsWelcome-sessionsGrid .monaco-list-row:nth-child(6) { + transform: translateX(100%) translateY(-156px); +} + +/* Clip the extra space caused by transforms - margin set directly by JS */ +.agentSessionsWelcome-sessionsGrid .agent-sessions-viewer .monaco-scrollable-element { + /* margin-bottom is set programmatically in layoutSessionsControl() */ +} + +/* Style individual session items in the welcome page */ +.agentSessionsWelcome-sessionsGrid .agent-session-item { + border-radius: 4px; +} + +.agentSessionsWelcome-sessionsGrid .agent-session-item:hover { + background-color: var(--vscode-list-hoverBackground); +} + +/* Hide toolbar on items in welcome page for cleaner look */ +.agentSessionsWelcome-sessionsGrid .agent-session-title-toolbar { + display: none !important; +} + +.agentSessionsWelcome-openSessionsButton { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + width: 100%; + padding: 8px 16px; + border: none; + border-radius: 4px; + background-color: transparent; + color: var(--vscode-foreground); + font-size: 13px; + cursor: pointer; +} + +.agentSessionsWelcome-openSessionsButton:hover { + color: var(--vscode-textLink-foreground); +} + +/* Walkthroughs section */ +.agentSessionsWelcome-walkthroughsSection { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + max-width: 800px; + margin-bottom: 32px; +} + +.agentSessionsWelcome-walkthroughCard { + display: flex; + align-items: center; + padding: 16px; + border: 1px solid var(--vscode-welcomePage-tileBorder, var(--vscode-contrastBorder, transparent)); + border-radius: 8px; + background-color: var(--vscode-welcomePage-tileBackground); + cursor: pointer; + transition: background-color 0.1s; + gap: 16px; +} + +.agentSessionsWelcome-walkthroughCard:hover { + background-color: var(--vscode-welcomePage-tileHoverBackground); +} + +.agentSessionsWelcome-walkthroughCard-icon { + flex-shrink: 0; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; +} + +.agentSessionsWelcome-walkthroughCard-icon .codicon { + font-size: 28px; + color: var(--vscode-welcomePage-progress-foreground, var(--vscode-foreground)); +} + +.agentSessionsWelcome-walkthroughCard-content { + flex: 1; + min-width: 0; +} + +.agentSessionsWelcome-walkthroughCard-title { + font-size: 14px; + font-weight: 600; + color: var(--vscode-foreground); + margin-bottom: 4px; +} + +.agentSessionsWelcome-walkthroughCard-description { + font-size: 12px; + color: var(--vscode-descriptionForeground); +} + +.agentSessionsWelcome-walkthroughCard-nav { + display: flex; + gap: 4px; +} + +.agentSessionsWelcome-walkthroughCard-nav .nav-button { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 4px; + background-color: transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; +} + +.agentSessionsWelcome-walkthroughCard-nav .nav-button:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +/* Footer */ +.agentSessionsWelcome-footer { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + width: 100%; + max-width: 800px; + margin-top: 16px; +} + +.agentSessionsWelcome-footerLink { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border: none; + border-radius: 4px; + background-color: transparent; + color: var(--vscode-foreground); + font-size: 13px; + cursor: pointer; +} + +.agentSessionsWelcome-footerLink:hover { + color: var(--vscode-textLink-foreground); +} + +.agentSessionsWelcome-footerLink .codicon { + color: var(--vscode-descriptionForeground); +} + +.agentSessionsWelcome-showOnStartup { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--vscode-descriptionForeground); +} + +.agentSessionsWelcome-showOnStartup label { + cursor: pointer; +} + +.agentSessionsWelcome-checkbox { + width: 16px; + height: 16px; +} diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.ts b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.ts index b63e894b1eab3..0158c0614d83e 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.ts @@ -306,7 +306,7 @@ configurationRegistry.registerConfiguration({ 'workbench.startupEditor': { 'scope': ConfigurationScope.RESOURCE, 'type': 'string', - 'enum': ['none', 'welcomePage', 'readme', 'newUntitledFile', 'welcomePageInEmptyWorkbench', 'terminal'], + 'enum': ['none', 'welcomePage', 'readme', 'newUntitledFile', 'welcomePageInEmptyWorkbench', 'terminal', 'agentSessionsWelcomePage'], 'enumDescriptions': [ localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'workbench.startupEditor.none' }, "Start without an editor."), localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'workbench.startupEditor.welcomePage' }, "Open the Welcome page, with content to aid in getting started with VS Code and extensions."), @@ -314,6 +314,7 @@ configurationRegistry.registerConfiguration({ localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'workbench.startupEditor.newUntitledFile' }, "Open a new untitled text file (only applies when opening an empty window)."), localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'workbench.startupEditor.welcomePageInEmptyWorkbench' }, "Open the Welcome page when opening an empty workbench."), localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'workbench.startupEditor.terminal' }, "Open a new terminal in the editor area."), + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'workbench.startupEditor.agentSessionsWelcomePage' }, "Open the Agent Sessions Welcome page."), ], 'default': 'welcomePage', 'description': localize('workbench.startupEditor', "Controls which editor is shown at startup, if none are restored from the previous session.") diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index e7c16a7de5344..3159df0f6ceda 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -350,6 +350,7 @@ import './contrib/surveys/browser/languageSurveys.contribution.js'; // Welcome import './contrib/welcomeGettingStarted/browser/gettingStarted.contribution.js'; +import './contrib/welcomeAgentSessions/browser/agentSessionsWelcome.contribution.js'; import './contrib/welcomeWalkthrough/browser/walkThrough.contribution.js'; import './contrib/welcomeViews/common/viewsWelcome.contribution.js'; import './contrib/welcomeViews/common/newFile.contribution.js';