From 87173b4915155cbd725b0e19438beaac41a9019c Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:38:57 -0800 Subject: [PATCH 01/20] Remove `IToolInvocationContext.sessionId` For #274403 Confirmed that this is not used in copilot --- .../api/common/extHostLanguageModelTools.ts | 2 -- .../workbench/api/common/extHostTypeConverters.ts | 2 +- .../browser/tools/languageModelToolsService.ts | 3 +-- .../common/tools/languageModelToolsService.ts | 6 +----- .../tools/languageModelToolsService.test.ts | 2 -- .../common/mcpLanguageModelToolContribution.ts | 2 +- .../tools/runInTerminalConfirmationTool.ts | 13 ------------- .../browser/tools/runInTerminalTool.ts | 15 +++++++++------ .../test/browser/outputMonitor.test.ts | 2 +- .../vscode.proposed.chatParticipantPrivate.d.ts | 4 ---- 10 files changed, 14 insertions(+), 37 deletions(-) diff --git a/src/vs/workbench/api/common/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index 6095aca4be16a..f3d03c9d08a01 100644 --- a/src/vs/workbench/api/common/extHostLanguageModelTools.ts +++ b/src/vs/workbench/api/common/extHostLanguageModelTools.ts @@ -186,7 +186,6 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape if (isProposedApiEnabled(item.extension, 'chatParticipantPrivate')) { options.chatRequestId = dto.chatRequestId; options.chatInteractionId = dto.chatInteractionId; - options.chatSessionId = dto.context?.sessionId; options.chatSessionResource = URI.revive(dto.context?.sessionResource); options.subAgentInvocationId = dto.subAgentInvocationId; } @@ -289,7 +288,6 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape const options: vscode.LanguageModelToolInvocationPrepareOptions = { input: context.parameters, chatRequestId: context.chatRequestId, - chatSessionId: context.chatSessionId, chatSessionResource: context.chatSessionResource, chatInteractionId: context.chatInteractionId, forceConfirmationReason: context.forceConfirmationReason diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 3bddf605a3055..e4cf7e8f86097 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -3428,7 +3428,7 @@ export namespace ChatAgentRequest { acceptedConfirmationData: request.acceptedConfirmationData, rejectedConfirmationData: request.rejectedConfirmationData, location2, - toolInvocationToken: Object.freeze({ sessionId, sessionResource: request.sessionResource }) as never, + toolInvocationToken: Object.freeze({ sessionResource: request.sessionResource }) as never, tools, model, editedFileEvents: request.editedFileEvents, diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 4bc871cae8938..0de4af3ef6383 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -659,7 +659,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo 'languageModelToolInvoked', { result, - chatSessionId: dto.context?.sessionId, + chatSessionId: dto.context?.sessionResource ? chatSessionResourceToId(dto.context.sessionResource) : undefined, toolId: tool.data.id, toolExtensionId: tool.data.source.type === 'extension' ? tool.data.source.extensionId.value : undefined, toolSourceKind: tool.data.source.type, @@ -753,7 +753,6 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo parameters: dto.parameters, toolCallId: dto.callId, chatRequestId: dto.chatRequestId, - chatSessionId: dto.context?.sessionId, chatSessionResource: dto.context?.sessionResource, chatInteractionId: dto.chatInteractionId, modelId: dto.modelId, diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index 9153ccc9facfb..e7e2d7a6658a4 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -199,14 +199,12 @@ export interface IToolInvocation { } export interface IToolInvocationContext { - /** @deprecated Use {@link sessionResource} instead */ - readonly sessionId: string; readonly sessionResource: URI; } // eslint-disable-next-line @typescript-eslint/no-explicit-any export function isToolInvocationContext(obj: any): obj is IToolInvocationContext { - return typeof obj === 'object' && typeof obj.sessionId === 'string' && URI.isUri(obj.sessionResource); + return typeof obj === 'object' && URI.isUri(obj.sessionResource); } export interface IToolInvocationPreparationContext { @@ -214,8 +212,6 @@ export interface IToolInvocationPreparationContext { parameters: any; toolCallId: string; chatRequestId?: string; - /** @deprecated Use {@link chatSessionResource} instead */ - chatSessionId?: string; chatSessionResource: URI | undefined; chatInteractionId?: string; modelId?: string; diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts index 3b53ed241bae1..3f34962a5af76 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts @@ -77,7 +77,6 @@ function registerToolForTest(service: LanguageModelToolsService, store: any, id: tokenBudget: 100, parameters, context: context ? { - sessionId: context.sessionId, sessionResource: LocalChatSessionUri.forSession(context.sessionId), } : undefined, }), @@ -2902,7 +2901,6 @@ suite('LanguageModelToolsService', () => { tokenBudget: 100, parameters: { test: 1 }, context: { - sessionId, sessionResource: LocalChatSessionUri.forSession(sessionId), }, chatStreamToolCallId: 'stream-call-id', // This should correlate diff --git a/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts b/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts index 25e9080dd4c2d..f8a82ce9db3a1 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts @@ -251,7 +251,7 @@ class McpToolImplementation implements IToolImpl { content: [] }; - const callResult = await this._tool.callWithProgress(invocation.parameters as Record, progress, { chatRequestId: invocation.chatRequestId, chatSessionId: invocation.context?.sessionId }, token); + const callResult = await this._tool.callWithProgress(invocation.parameters as Record, progress, { chatRequestId: invocation.chatRequestId }, token); const details: Mutable = { input: JSON.stringify(invocation.parameters, undefined, 2), output: [], diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts index 9432fe208d714..65a0a56506888 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts @@ -5,7 +5,6 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; import { CountTokensCallback, IPreparedToolInvocation, IToolData, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolInvocationPresentation, ToolProgress } from '../../../../chat/common/tools/languageModelToolsService.js'; import { RunInTerminalTool } from './runInTerminalTool.js'; @@ -60,18 +59,6 @@ export const ConfirmTerminalCommandToolData: IToolData = { export class ConfirmTerminalCommandTool extends RunInTerminalTool { override async prepareToolInvocation(context: IToolInvocationPreparationContext, token: CancellationToken): Promise { - // Safe-guard: If session is the chat provider specific id - // then convert it to the session id understood by chat service - try { - const sessionUri = context.chatSessionId ? URI.parse(context.chatSessionId) : undefined; - const sessionId = sessionUri ? this._chatService.getSession(sessionUri)?.sessionId : undefined; - if (sessionId) { - context.chatSessionId = sessionId; - } - } - catch { - // Ignore parse errors or session lookup failures; fallback to using the original chatSessionId. - } const preparedInvocation = await super.prepareToolInvocation(context, token); if (preparedInvocation) { preparedInvocation.presentation = ToolInvocationPresentation.HiddenAfterComplete; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 8fad759ebe12e..e9b28acf901f6 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -429,7 +429,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { async prepareToolInvocation(context: IToolInvocationPreparationContext, token: CancellationToken): Promise { const args = context.parameters as IRunInTerminalInputParams; - const chatSessionResource = context.chatSessionResource ?? (context.chatSessionId ? LocalChatSessionUri.forSession(context.chatSessionId) : undefined); + const chatSessionResource = context.chatSessionResource; let instance: ITerminalInstance | undefined; if (chatSessionResource) { const toolTerminal = this._sessionTerminalAssociations.get(chatSessionResource); @@ -658,6 +658,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { if (!toolSpecificData) { throw new Error('toolSpecificData must be provided for this tool'); } + if (!invocation.context) { + throw new Error('Invocation context must be provided for this tool'); + } + const commandId = toolSpecificData.terminalCommandId; if (toolSpecificData.alternativeRecommendation) { return { @@ -672,8 +676,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { this._logService.debug(`RunInTerminalTool: Invoking with options ${JSON.stringify(args)}`); let toolResultMessage: string | IMarkdownString | undefined; - const chatSessionResource = invocation.context?.sessionResource ?? LocalChatSessionUri.forSession(invocation.context?.sessionId ?? 'no-chat-session'); - const chatSessionId = chatSessionResourceToId(chatSessionResource); + const chatSessionResource = invocation.context.sessionResource; const command = toolSpecificData.commandLine.userEdited ?? toolSpecificData.commandLine.toolEdited ?? toolSpecificData.commandLine.original; const didUserEditCommand = ( toolSpecificData.commandLine.userEdited !== undefined && @@ -699,7 +702,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const store = new DisposableStore(); // Unified terminal initialization - this._logService.debug(`RunInTerminalTool: Creating ${args.isBackground ? 'background' : 'foreground'} terminal. termId=${termId}, chatSessionId=${chatSessionId}`); + this._logService.debug(`RunInTerminalTool: Creating ${args.isBackground ? 'background' : 'foreground'} terminal. termId=${termId}, chatSessionResource=${chatSessionResource}`); const toolTerminal = await this._initTerminal(chatSessionResource, termId, terminalToolSessionId, args.isBackground, token); this._handleTerminalVisibility(toolTerminal, chatSessionResource); @@ -775,7 +778,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { // Create unified ActiveTerminalExecution (creates and owns the strategy) const execution = this._instantiationService.createInstance( ActiveTerminalExecution, - chatSessionId, + chatSessionResource, termId, toolTerminal, commandDetection!, @@ -1229,7 +1232,7 @@ class ActiveTerminalExecution extends Disposable implements IActiveTerminalExecu } constructor( - readonly sessionId: string, + readonly sessionResource: URI, readonly termId: string, toolTerminal: IToolTerminal, commandDetection: ICommandDetectionCapability, diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts index e7eeceda2e794..cb4aa2c1dc081 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts @@ -362,5 +362,5 @@ suite('OutputMonitor', () => { }); function createTestContext(id: string): IToolInvocationContext { - return { sessionId: id, sessionResource: LocalChatSessionUri.forSession(id) }; + return { sessionResource: LocalChatSessionUri.forSession(id) }; } diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index ad37a1404ceea..b8423da500317 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -262,8 +262,6 @@ declare module 'vscode' { export interface LanguageModelToolInvocationOptions { chatRequestId?: string; - /** @deprecated Use {@link chatSessionResource} instead */ - chatSessionId?: string; chatSessionResource?: Uri; chatInteractionId?: string; terminalCommand?: string; @@ -289,8 +287,6 @@ declare module 'vscode' { */ input: T; chatRequestId?: string; - /** @deprecated Use {@link chatSessionResource} instead */ - chatSessionId?: string; chatSessionResource?: Uri; chatInteractionId?: string; /** From cb6f5653e7f26983acefcbb0e19bb41f7da102f8 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:55:11 -0800 Subject: [PATCH 02/20] Update src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../contrib/chat/common/tools/languageModelToolsService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index e7e2d7a6658a4..21615b6939771 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -204,7 +204,7 @@ export interface IToolInvocationContext { // eslint-disable-next-line @typescript-eslint/no-explicit-any export function isToolInvocationContext(obj: any): obj is IToolInvocationContext { - return typeof obj === 'object' && URI.isUri(obj.sessionResource); + return obj !== null && typeof obj === 'object' && URI.isUri((obj as any).sessionResource); } export interface IToolInvocationPreparationContext { From 696342d4c6065170d8bf38d7ce297ac697e93607 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:00:00 -0800 Subject: [PATCH 03/20] Better fixes for mcp --- .../workbench/contrib/mcp/browser/mcpElicitationService.ts | 5 ++--- src/vs/workbench/contrib/mcp/common/mcpServer.ts | 5 +++-- src/vs/workbench/contrib/mcp/common/mcpTypes.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/mcp/browser/mcpElicitationService.ts b/src/vs/workbench/contrib/mcp/browser/mcpElicitationService.ts index 2b1b21e5bb0e5..622dc36f19850 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpElicitationService.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpElicitationService.ts @@ -19,7 +19,6 @@ import { IQuickInputService, IQuickPick, IQuickPickItem } from '../../../../plat import { ChatElicitationRequestPart } from '../../chat/common/model/chatProgressTypes/chatElicitationRequestPart.js'; import { ChatModel } from '../../chat/common/model/chatModel.js'; import { ElicitationState, IChatService } from '../../chat/common/chatService/chatService.js'; -import { LocalChatSessionUri } from '../../chat/common/model/chatUri.js'; import { ElicitationKind, ElicitResult, IFormModeElicitResult, IMcpElicitationService, IMcpServer, IMcpToolCallContext, IUrlModeElicitResult, McpConnectionState, MpcResponseError } from '../common/mcpTypes.js'; import { mcpServerToSourceData } from '../common/mcpTypesUtils.js'; import { MCP } from '../common/modelContextProtocol.js'; @@ -85,7 +84,7 @@ export class McpElicitationService implements IMcpElicitationService { private async _elicitForm(server: IMcpServer, context: IMcpToolCallContext | undefined, elicitation: MCP.ElicitRequestFormParams | Pre20251125ElicitationParams, token: CancellationToken): Promise { const store = new DisposableStore(); const value = await new Promise(resolve => { - const chatModel = context?.chatSessionId && this._chatService.getSession(LocalChatSessionUri.forSession(context.chatSessionId)); + const chatModel = context?.chatSessionResource && this._chatService.getSession(context.chatSessionResource); if (chatModel instanceof ChatModel) { const request = chatModel.getRequests().at(-1); if (request) { @@ -152,7 +151,7 @@ export class McpElicitationService implements IMcpElicitationService { const store = new DisposableStore(); const value = await new Promise(resolve => { - const chatModel = context?.chatSessionId && this._chatService.getSession(LocalChatSessionUri.forSession(context.chatSessionId)); + const chatModel = context?.chatSessionResource && this._chatService.getSession(context.chatSessionResource); if (chatModel instanceof ChatModel) { const request = chatModel.getRequests().at(-1); if (request) { diff --git a/src/vs/workbench/contrib/mcp/common/mcpServer.ts b/src/vs/workbench/contrib/mcp/common/mcpServer.ts index 0c55918b43bf9..6452f5d62bd83 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServer.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServer.ts @@ -30,6 +30,7 @@ import { IEditorService } from '../../../services/editor/common/editorService.js import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; import { IOutputService } from '../../../services/output/common/output.js'; +import { chatSessionResourceToId } from '../../chat/common/model/chatUri.js'; import { ToolProgress } from '../../chat/common/tools/languageModelToolsService.js'; import { mcpActivationEvent } from './mcpConfiguration.js'; import { McpDevModeServerAttache } from './mcpDevMode.js'; @@ -1069,8 +1070,8 @@ export class McpTool implements IMcpTool { } const meta: Record = { progressToken }; - if (context?.chatSessionId) { - meta['vscode.conversationId'] = context.chatSessionId; + if (context?.chatSessionResource) { + meta['vscode.conversationId'] = chatSessionResourceToId(context.chatSessionResource); } if (context?.chatRequestId) { meta['vscode.requestId'] = context.chatRequestId; diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index 9a38b10c35fff..7d35fa80ac796 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -434,7 +434,7 @@ export const mcpPromptPrefix = (definition: McpDefinitionReference) => export interface IMcpPromptMessage extends MCP.PromptMessage { } export interface IMcpToolCallContext { - chatSessionId?: string; + chatSessionResource: URI | undefined; chatRequestId?: string; } From 7d7012efbebce9e111eb61decff3465c0c55b0ef Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:03:10 -0800 Subject: [PATCH 04/20] Add missing change --- .../contrib/mcp/common/mcpLanguageModelToolContribution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts b/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts index f8a82ce9db3a1..f5f6687be947d 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts @@ -251,7 +251,7 @@ class McpToolImplementation implements IToolImpl { content: [] }; - const callResult = await this._tool.callWithProgress(invocation.parameters as Record, progress, { chatRequestId: invocation.chatRequestId }, token); + const callResult = await this._tool.callWithProgress(invocation.parameters as Record, progress, { chatRequestId: invocation.chatRequestId, chatSessionResource: undefined }, token); const details: Mutable = { input: JSON.stringify(invocation.parameters, undefined, 2), output: [], From 77ed81673371b6ada041bfca6503b9a7374f1c2b Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:32:51 -0800 Subject: [PATCH 05/20] Fix lint --- .../contrib/chat/common/tools/languageModelToolsService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index 21615b6939771..3316f148d3186 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -204,7 +204,7 @@ export interface IToolInvocationContext { // eslint-disable-next-line @typescript-eslint/no-explicit-any export function isToolInvocationContext(obj: any): obj is IToolInvocationContext { - return obj !== null && typeof obj === 'object' && URI.isUri((obj as any).sessionResource); + return obj !== null && typeof obj === 'object' && URI.isUri(obj.sessionResource); } export interface IToolInvocationPreparationContext { From a348a067d8da30bcc6d40f24c7343ab9862c50a5 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Tue, 17 Feb 2026 11:02:08 +0100 Subject: [PATCH 06/20] Avoid hardcoding models (#289694) --- .../chatEditingExplanationModelManager.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingExplanationModelManager.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingExplanationModelManager.ts index f2887d7900336..b26f5ced875aa 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingExplanationModelManager.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingExplanationModelManager.ts @@ -237,18 +237,8 @@ export class ChatEditingExplanationModelManager extends Disposable implements IC const totalChanges = fileChanges.reduce((sum, f) => sum + f.changes.length, 0); try { - // Select a high-end model for better understanding of all changes together - let models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', family: 'claude-3.5-sonnet' }); - if (!models.length) { - models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', family: 'gpt-4o' }); - } - if (!models.length) { - models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', family: 'gpt-4' }); - } - if (!models.length) { - // Fallback to any available model - models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot' }); - } + // Select a model for understanding all changes together + const models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', id: 'copilot-fast' }); if (!models.length) { for (const fileData of fileChanges) { this._updateUriStatePartial(fileData.uri, { From 9777d849727f2d84cce652023aceb66006cc60bc Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 17 Feb 2026 11:29:57 +0000 Subject: [PATCH 07/20] Enhance minimap autohide functionality and improve sticky widget styles --- extensions/theme-2026/themes/styles.css | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index f95851a38e33a..6bb5dd12397e4 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -565,13 +565,20 @@ left: 0; } +/* Minimap autohide: ensure opacity:0 overrides the 0.85 above */ +.monaco-workbench .monaco-editor .minimap:is(.minimap-autohide-mouseover, .minimap-autohide-scroll) { + opacity: 0; +} + +.monaco-workbench .monaco-editor .minimap:is(.minimap-autohide-mouseover:hover, .minimap-autohide-scroll.active) { + opacity: 0.85; +} + /* Sticky Scroll */ .monaco-workbench .monaco-editor .sticky-widget { box-shadow: var(--shadow-md) !important; border-bottom: var(--vscode-editorWidget-border) !important; background: transparent !important; - backdrop-filter: var(--backdrop-blur-md) !important; - -webkit-backdrop-filter: var(--backdrop-blur-md) !important; } .monaco-workbench .monaco-editor .sticky-widget > * { @@ -582,10 +589,11 @@ border-bottom: none !important; } -.monaco-workbench .monaco-editor .sticky-widget .sticky-widget-lines { - background: color-mix(in srgb, var(--vscode-editor-background) 40%, transparent) !important; +.monaco-workbench .monaco-editor .sticky-widget .sticky-widget-line-numbers, +.monaco-editor .sticky-widget .sticky-line-content { backdrop-filter: var(--backdrop-blur-md) !important; -webkit-backdrop-filter: var(--backdrop-blur-md) !important; + background: color-mix(in srgb, var(--vscode-editor-background) 40%, transparent) !important; } .monaco-editor .rename-box.preview { @@ -795,6 +803,7 @@ background: var(--vscode-editor-background) !important; } +.monaco-workbench.monaco-reduce-transparency .monaco-editor .sticky-widget .sticky-widget-line-numbers, .monaco-workbench.monaco-reduce-transparency .monaco-editor .sticky-widget .sticky-widget-lines { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; From 4960dbf34e0f02a2cec8df07f681043afa708faa Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 17 Feb 2026 11:36:30 +0000 Subject: [PATCH 08/20] Add hover background for sticky line content in editor --- extensions/theme-2026/themes/styles.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 6bb5dd12397e4..bda649f50054d 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -596,6 +596,10 @@ background: color-mix(in srgb, var(--vscode-editor-background) 40%, transparent) !important; } +.monaco-workbench .monaco-editor .sticky-widget .sticky-line-content:hover { + background: var(--vscode-editorHoverWidget-background) !important; +} + .monaco-editor .rename-box.preview { backdrop-filter: var(--backdrop-blur-lg) !important; -webkit-backdrop-filter: var(--backdrop-blur-lg) !important; From 152d29bd0c9b456d8708b591d7fe0f993fbd42a9 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 17 Feb 2026 11:38:34 +0000 Subject: [PATCH 09/20] Fix sticky widget styles for reduced transparency theme --- extensions/theme-2026/themes/styles.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index bda649f50054d..f76c36ae6cc29 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -808,7 +808,8 @@ } .monaco-workbench.monaco-reduce-transparency .monaco-editor .sticky-widget .sticky-widget-line-numbers, -.monaco-workbench.monaco-reduce-transparency .monaco-editor .sticky-widget .sticky-widget-lines { +.monaco-workbench.monaco-reduce-transparency .monaco-editor .sticky-widget .sticky-widget-lines, +.monaco-workbench.monaco-reduce-transparency .monaco-editor .sticky-widget .sticky-line-content { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; background: var(--vscode-editor-background) !important; From 2b42a80885eb3ae88f8175ca09644a86b7f148cd Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 17 Feb 2026 12:13:01 +0000 Subject: [PATCH 10/20] Enhance quick input list styles for better visibility and interaction --- extensions/theme-2026/themes/styles.css | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index f76c36ae6cc29..d4a629d9ac766 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -200,6 +200,27 @@ background-color: color-mix(in srgb, var(--vscode-list-hoverBackground) 95%, black) !important; } +.quick-input-list .quick-input-list-entry .quick-input-list-separator { + height: 16px; + margin-top: 2px; + display: flex; + align-items: center; + font-size: 11px; + padding: 0 4px 1px 4px; + border-radius: var(--vscode-cornerRadius-small) !important; + background: color-mix(in srgb, var(--vscode-badge-background) 50%, transparent) !important; + color: var(--vscode-badge-foreground) !important; + margin-right: 8px; +} + +.monaco-list-row.focused .quick-input-list-entry .quick-input-list-separator, +.monaco-list-row.selected .quick-input-list-entry .quick-input-list-separator, +.monaco-list-row:hover .quick-input-list-entry .quick-input-list-separator { + background: transparent !important; + color: inherit !important; + padding: 0; +} + .monaco-workbench .monaco-editor .suggest-widget .monaco-list { border-radius: var(--radius-lg); } From cebaef18d31a2cde91370c8f0cb8726de1c548e9 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 17 Feb 2026 12:20:25 +0000 Subject: [PATCH 11/20] Fix indentation --- extensions/theme-2026/themes/styles.css | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index d4a629d9ac766..cecc454cbf90c 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -202,12 +202,12 @@ .quick-input-list .quick-input-list-entry .quick-input-list-separator { height: 16px; - margin-top: 2px; - display: flex; - align-items: center; - font-size: 11px; - padding: 0 4px 1px 4px; - border-radius: var(--vscode-cornerRadius-small) !important; + margin-top: 2px; + display: flex; + align-items: center; + font-size: 11px; + padding: 0 4px 1px 4px; + border-radius: var(--vscode-cornerRadius-small) !important; background: color-mix(in srgb, var(--vscode-badge-background) 50%, transparent) !important; color: var(--vscode-badge-foreground) !important; margin-right: 8px; From 7520304216c5d5d70c6d6661863788ffcae9f5eb Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Tue, 17 Feb 2026 12:23:49 +0000 Subject: [PATCH 12/20] Add hover background for sticky scroll in dark and light themes --- extensions/theme-2026/themes/2026-dark.json | 1 + extensions/theme-2026/themes/2026-light.json | 1 + extensions/theme-2026/themes/styles.css | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/extensions/theme-2026/themes/2026-dark.json b/extensions/theme-2026/themes/2026-dark.json index 72120d9b95ea0..2ea5212320763 100644 --- a/extensions/theme-2026/themes/2026-dark.json +++ b/extensions/theme-2026/themes/2026-dark.json @@ -104,6 +104,7 @@ "editor.background": "#121314", "editor.foreground": "#BBBEBF", "editorStickyScroll.background": "#121314", + "editorStickyScrollHover.background": "#202122", "editorLineNumber.foreground": "#858889", "editorLineNumber.activeForeground": "#BBBEBF", "editorCursor.foreground": "#BBBEBF", diff --git a/extensions/theme-2026/themes/2026-light.json b/extensions/theme-2026/themes/2026-light.json index 0f8e2067f0240..95d65795f8b79 100644 --- a/extensions/theme-2026/themes/2026-light.json +++ b/extensions/theme-2026/themes/2026-light.json @@ -51,6 +51,7 @@ "widget.shadow": "#00000000", "widget.border": "#EEEEF1", "editorStickyScroll.shadow": "#00000000", + "editorStickyScrollHover.background": "#F0F0F3", "sideBarStickyScroll.shadow": "#00000000", "panelStickyScroll.shadow": "#00000000", "listFilterWidget.shadow": "#00000000", diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index cecc454cbf90c..8ea22bafb03a1 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -618,7 +618,7 @@ } .monaco-workbench .monaco-editor .sticky-widget .sticky-line-content:hover { - background: var(--vscode-editorHoverWidget-background) !important; + background: var(--vscode-editorStickyScrollHover-background) !important; } .monaco-editor .rename-box.preview { From 6b97e58b7908783d8ce014ecaf823bae016c0d4a Mon Sep 17 00:00:00 2001 From: Robo Date: Tue, 17 Feb 2026 22:50:35 +0900 Subject: [PATCH 13/20] chore: bump distro (#295761) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0e516e7fd5e38..c64345a7823b3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.110.0", - "distro": "6c461e78a091f00670d7513c1eeb1fc0f4d3e8b7", + "distro": "16be71799bd7ef33ea9b0206fb548ce74a47daa4", "author": { "name": "Microsoft Corporation" }, From b1009c98bb42b2990dbd467396589271881c018f Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 17 Feb 2026 15:14:39 +0100 Subject: [PATCH 14/20] Sessions exploration (#294912) --- .github/hooks/worktree.json | 21 + .../ai-customization.instructions.md | 224 +++ .github/instructions/sessions.instructions.md | 13 + .github/skills/agent-sessions-layout/SKILL.md | 80 + .github/skills/author-contributions/SKILL.md | 186 +++ .github/skills/sessions/SKILL.md | 277 ++++ build/buildfile.ts | 4 +- build/gulpfile.vscode.ts | 52 +- .../lib/stylelint/vscode-known-variables.json | 11 +- build/next/index.ts | 7 + eslint.config.js | 81 +- extensions/github/package.json | 18 + extensions/github/package.nls.json | 1 + extensions/github/src/commands.ts | 66 + package-lock.json | 6 +- package.json | 2 +- src/bootstrap-meta.ts | 13 + src/tsec.exemptions.json | 4 +- src/vs/base/browser/ui/button/button.ts | 13 +- src/vs/base/browser/ui/tree/abstractTree.ts | 2 +- src/vs/base/common/platform.ts | 1 + src/vs/code/electron-main/app.ts | 9 +- src/vs/code/node/cli.ts | 20 +- src/vs/platform/actions/browser/buttonbar.ts | 19 +- .../browser/menuEntryActionViewItem.ts | 7 +- src/vs/platform/actions/common/actions.ts | 1 + src/vs/platform/environment/common/argv.ts | 1 + src/vs/platform/environment/node/argv.ts | 1 + .../platform/environment/node/userDataPath.ts | 8 +- src/vs/platform/native/common/native.ts | 2 + .../electron-main/nativeHostMainService.ts | 7 + src/vs/platform/update/common/update.ts | 1 + .../electron-main/updateService.darwin.ts | 9 +- src/vs/platform/window/common/window.ts | 2 + .../windows/electron-main/windowImpl.ts | 2 + .../platform/windows/electron-main/windows.ts | 3 +- .../electron-main/windowsMainService.ts | 34 +- src/vs/platform/workspace/common/workspace.ts | 12 +- src/vs/sessions/AI_CUSTOMIZATIONS.md | 94 ++ src/vs/sessions/LAYOUT.md | 740 +++++++++ src/vs/sessions/README.md | 125 ++ src/vs/sessions/browser/layoutActions.ts | 149 ++ src/vs/sessions/browser/menus.ts | 26 + .../browser/paneCompositePartService.ts | 106 ++ .../browser/parts/auxiliaryBarPart.ts | 280 ++++ src/vs/sessions/browser/parts/chatBarPart.ts | 162 ++ src/vs/sessions/browser/parts/editorModal.ts | 194 +++ .../browser/parts/media/chatBarPart.css | 84 + .../browser/parts/media/projectBarPart.css | 263 +++ .../browser/parts/media/sidebarPart.css | 29 + .../browser/parts/media/titlebarpart.css | 40 + src/vs/sessions/browser/parts/panelPart.ts | 201 +++ src/vs/sessions/browser/parts/parts.ts | 8 + .../sessions/browser/parts/projectBarPart.ts | 584 +++++++ src/vs/sessions/browser/parts/sidebarPart.ts | 274 ++++ src/vs/sessions/browser/parts/titlebarPart.ts | 419 +++++ src/vs/sessions/browser/style.css | 167 ++ .../browser/widget/AGENTS_CHAT_WIDGET.md | 503 ++++++ src/vs/sessions/browser/workbench.ts | 1438 +++++++++++++++++ src/vs/sessions/common/contextkeys.ts | 15 + .../browser/account.contribution.ts | 281 ++++ .../browser/media/accountWidget.css | 71 + .../aiCustomizationManagement/browser/SPEC.md | 179 ++ .../browser/aiCustomizationListWidget.ts | 1066 ++++++++++++ .../aiCustomizationManagement.contribution.ts | 294 ++++ .../browser/aiCustomizationManagement.ts | 102 ++ .../aiCustomizationManagementEditor.ts | 804 +++++++++ .../aiCustomizationManagementEditorInput.ts | 73 + .../browser/aiCustomizationOverviewView.ts | 202 +++ .../browser/customizationCreatorService.ts | 217 +++ .../browser/mcpListWidget.ts | 363 +++++ .../media/aiCustomizationManagement.css | 763 +++++++++ .../aiCustomizationTreeView/browser/SPEC.md | 116 ++ .../aiCustomizationTreeView.contribution.ts | 98 ++ .../browser/aiCustomizationTreeView.ts | 35 + .../browser/aiCustomizationTreeViewIcons.ts | 63 + .../browser/aiCustomizationTreeViewViews.ts | 657 ++++++++ .../browser/media/aiCustomizationTreeView.css | 98 ++ .../browser/changesView.contribution.ts | 40 + .../changesView/browser/changesView.ts | 1031 ++++++++++++ .../changesView/browser/media/changesView.css | 237 +++ .../chat/browser/branchChatSessionAction.ts | 112 ++ .../contrib/chat/browser/chat.contribution.ts | 183 +++ .../chat/browser/media/chatWelcomePart.css | 268 +++ .../contrib/chat/browser/media/chatWidget.css | 117 ++ .../code-icon-agent-sessions-exploration.svg | 49 + .../code-icon-agent-sessions-insider.svg | 49 + .../media/code-icon-agent-sessions-stable.svg | 53 + .../media/code-icon-agent-sessions.svg | 53 + .../contrib/chat/browser/newChatViewPane.ts | 819 ++++++++++ .../contrib/chat/browser/promptsService.ts | 93 ++ .../contrib/chat/browser/runScriptAction.ts | 207 +++ .../browser/configuration.contribution.ts | 39 + .../browser/media/sessionsTitleBarWidget.css | 103 ++ .../browser/media/sessionsViewPane.css | 215 +++ .../sessions/browser/sessions.contribution.ts | 50 + .../browser/sessionsTitleBarWidget.ts | 400 +++++ .../sessions/browser/sessionsViewPane.ts | 488 ++++++ .../browser/sessionsWorkbenchService.ts | 310 ++++ .../electron-browser/sessions-dev.html | 77 + .../sessions/electron-browser/sessions.html | 78 + .../electron-browser/sessions.main.ts | 422 +++++ src/vs/sessions/electron-browser/sessions.ts | 323 ++++ src/vs/sessions/sessions.common.main.ts | 439 +++++ src/vs/sessions/sessions.desktop.main.ts | 198 +++ .../api/browser/mainThreadChatAgents2.ts | 16 +- .../api/browser/mainThreadWorkspace.ts | 1 - .../workbench/api/common/extHost.api.impl.ts | 2 +- .../workbench/api/common/extHost.protocol.ts | 1 + .../api/common/extHostChatAgents2.ts | 1 + .../workbench/api/common/extHostWorkspace.ts | 14 +- src/vs/workbench/browser/contextkeys.ts | 8 +- src/vs/workbench/browser/layout.ts | 2 +- src/vs/workbench/browser/panecomposite.ts | 2 + src/vs/workbench/common/contextkeys.ts | 2 +- src/vs/workbench/common/views.ts | 34 +- .../agentFeedback/agentFeedbackAttachment.ts | 166 ++ .../agentFeedbackAttachmentWidget.ts | 76 + .../agentFeedbackEditorActions.ts | 182 +++ .../agentFeedbackEditorOverlay.ts | 253 +++ .../agentFeedback/agentFeedbackEditorUtils.ts | 29 + .../agentFeedback/agentFeedbackHover.ts | 149 ++ .../agentFeedback/agentFeedbackService.ts | 570 +++++++ .../media/agentFeedbackAttachment.css | 91 ++ .../media/agentFeedbackEditorOverlay.css | 72 + .../agentSessions/agentSessionsControl.ts | 47 +- .../agentSessions/agentSessionsModel.ts | 5 +- .../contrib/chat/browser/chat.contribution.ts | 8 + .../chat/browser/sessionResourceMatching.ts | 38 + .../widgetHosts/editor/chatEditorInput.ts | 4 +- .../widgetHosts/viewPane/chatViewPane.ts | 12 +- .../common/attachments/chatVariableEntries.ts | 17 +- .../chat/common/chatService/chatService.ts | 1 + .../contrib/chat/common/model/chatModel.ts | 1 + .../promptSyntax/utils/promptFilesLocator.ts | 8 + .../agentSessions/agentSessionsActions.ts | 23 +- .../chat/test/common/model/mockChatModel.ts | 6 +- .../browser/relauncher.contribution.ts | 4 +- .../terminal/browser/terminal.contribution.ts | 4 +- .../contrib/terminal/browser/terminalGroup.ts | 2 + .../browser/configurationService.ts | 5 +- .../environment/browser/environmentService.ts | 3 + .../environment/common/environmentService.ts | 1 + .../electron-browser/environmentService.ts | 3 + .../browser/webWorkerExtensionHost.ts | 6 +- .../common/extensionHostProtocol.ts | 2 +- .../extensions/common/remoteExtensionHost.ts | 3 +- .../localProcessExtensionHost.ts | 6 +- .../services/label/common/labelService.ts | 4 - .../services/layout/browser/layoutService.ts | 1 + .../views/browser/viewDescriptorService.ts | 67 +- .../electron-browser/workbenchTestServices.ts | 2 + .../vscode.proposed.chatSessionsProvider.d.ts | 5 + 153 files changed, 20895 insertions(+), 120 deletions(-) create mode 100644 .github/hooks/worktree.json create mode 100644 .github/instructions/ai-customization.instructions.md create mode 100644 .github/instructions/sessions.instructions.md create mode 100644 .github/skills/agent-sessions-layout/SKILL.md create mode 100644 .github/skills/author-contributions/SKILL.md create mode 100644 .github/skills/sessions/SKILL.md create mode 100644 src/vs/sessions/AI_CUSTOMIZATIONS.md create mode 100644 src/vs/sessions/LAYOUT.md create mode 100644 src/vs/sessions/README.md create mode 100644 src/vs/sessions/browser/layoutActions.ts create mode 100644 src/vs/sessions/browser/menus.ts create mode 100644 src/vs/sessions/browser/paneCompositePartService.ts create mode 100644 src/vs/sessions/browser/parts/auxiliaryBarPart.ts create mode 100644 src/vs/sessions/browser/parts/chatBarPart.ts create mode 100644 src/vs/sessions/browser/parts/editorModal.ts create mode 100644 src/vs/sessions/browser/parts/media/chatBarPart.css create mode 100644 src/vs/sessions/browser/parts/media/projectBarPart.css create mode 100644 src/vs/sessions/browser/parts/media/sidebarPart.css create mode 100644 src/vs/sessions/browser/parts/media/titlebarpart.css create mode 100644 src/vs/sessions/browser/parts/panelPart.ts create mode 100644 src/vs/sessions/browser/parts/parts.ts create mode 100644 src/vs/sessions/browser/parts/projectBarPart.ts create mode 100644 src/vs/sessions/browser/parts/sidebarPart.ts create mode 100644 src/vs/sessions/browser/parts/titlebarPart.ts create mode 100644 src/vs/sessions/browser/style.css create mode 100644 src/vs/sessions/browser/widget/AGENTS_CHAT_WIDGET.md create mode 100644 src/vs/sessions/browser/workbench.ts create mode 100644 src/vs/sessions/common/contextkeys.ts create mode 100644 src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts create mode 100644 src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css create mode 100644 src/vs/sessions/contrib/aiCustomizationManagement/browser/SPEC.md create mode 100644 src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationListWidget.ts create mode 100644 src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.contribution.ts create mode 100644 src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.ts create mode 100644 src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagementEditor.ts create mode 100644 src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagementEditorInput.ts create mode 100644 src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationOverviewView.ts create mode 100644 src/vs/sessions/contrib/aiCustomizationManagement/browser/customizationCreatorService.ts create mode 100644 src/vs/sessions/contrib/aiCustomizationManagement/browser/mcpListWidget.ts create mode 100644 src/vs/sessions/contrib/aiCustomizationManagement/browser/media/aiCustomizationManagement.css create mode 100644 src/vs/sessions/contrib/aiCustomizationTreeView/browser/SPEC.md create mode 100644 src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts create mode 100644 src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.ts create mode 100644 src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewIcons.ts create mode 100644 src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts create mode 100644 src/vs/sessions/contrib/aiCustomizationTreeView/browser/media/aiCustomizationTreeView.css create mode 100644 src/vs/sessions/contrib/changesView/browser/changesView.contribution.ts create mode 100644 src/vs/sessions/contrib/changesView/browser/changesView.ts create mode 100644 src/vs/sessions/contrib/changesView/browser/media/changesView.css create mode 100644 src/vs/sessions/contrib/chat/browser/branchChatSessionAction.ts create mode 100644 src/vs/sessions/contrib/chat/browser/chat.contribution.ts create mode 100644 src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css create mode 100644 src/vs/sessions/contrib/chat/browser/media/chatWidget.css create mode 100644 src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-exploration.svg create mode 100644 src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-insider.svg create mode 100644 src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-stable.svg create mode 100644 src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions.svg create mode 100644 src/vs/sessions/contrib/chat/browser/newChatViewPane.ts create mode 100644 src/vs/sessions/contrib/chat/browser/promptsService.ts create mode 100644 src/vs/sessions/contrib/chat/browser/runScriptAction.ts create mode 100644 src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts create mode 100644 src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css create mode 100644 src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css create mode 100644 src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts create mode 100644 src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts create mode 100644 src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts create mode 100644 src/vs/sessions/contrib/sessions/browser/sessionsWorkbenchService.ts create mode 100644 src/vs/sessions/electron-browser/sessions-dev.html create mode 100644 src/vs/sessions/electron-browser/sessions.html create mode 100644 src/vs/sessions/electron-browser/sessions.main.ts create mode 100644 src/vs/sessions/electron-browser/sessions.ts create mode 100644 src/vs/sessions/sessions.common.main.ts create mode 100644 src/vs/sessions/sessions.desktop.main.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackAttachment.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackAttachmentWidget.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackEditorActions.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackEditorOverlay.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackEditorUtils.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackHover.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackService.ts create mode 100644 src/vs/workbench/contrib/chat/browser/agentFeedback/media/agentFeedbackAttachment.css create mode 100644 src/vs/workbench/contrib/chat/browser/agentFeedback/media/agentFeedbackEditorOverlay.css create mode 100644 src/vs/workbench/contrib/chat/browser/sessionResourceMatching.ts diff --git a/.github/hooks/worktree.json b/.github/hooks/worktree.json new file mode 100644 index 0000000000000..a9f68bd12b9f1 --- /dev/null +++ b/.github/hooks/worktree.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "bash": "npm install", + "powershell": "npm install", + "timeoutSec": 120 + } + ], + "sessionEnd": [ + { + "type": "command", + "bash": "npm run compile", + "powershell": "npm run compile", + "timeoutSec": 120 + } + ] + } +} diff --git a/.github/instructions/ai-customization.instructions.md b/.github/instructions/ai-customization.instructions.md new file mode 100644 index 0000000000000..8fe29e1663d06 --- /dev/null +++ b/.github/instructions/ai-customization.instructions.md @@ -0,0 +1,224 @@ +--- +description: Architecture documentation for VS Code AI Customization view. Use when working in `src/vs/workbench/contrib/chat/browser/aiCustomization` +applyTo: 'src/vs/workbench/contrib/chat/browser/aiCustomization/**' +--- + +# AI Customization View + +The AI Customization view provides a unified view for discovering and managing AI customization 'artifacts' (customizations that augment LLM prompts or behavior). + +Examples of these include: Custom Agents, Skills, Instructions, and Prompts. It surfaces prompt files that are typically hidden in `.github/` folders, user data directories, workspace settings, or exposed via extensions. + +## Overview + +The view displays a hierarchical tree structure: + +``` +AI Customization (View Container) +└── AI Customization (Tree View) + ├── Custom Agents (.agent.md files) + │ ├── Workspace + │ │ └── agent files... + │ ├── User + │ │ └── agent files... + │ └── Extensions + │ └── agent files... + ├── Skills (SKILL.md files) + │ └── (same storage structure) + ├── Instructions (.instructions.md files) + │ └── (same storage structure) + └── Prompts (.prompt.md files) + └── (same storage structure) +``` + +**Key Features:** +- 3-level tree hierarchy: Category → Storage Group → Files +- Auto-expands category nodes on initial load and refresh to show storage groups +- Symbol-based root element for type safety +- Double-click to open files in editor +- Context menu support with Open and Run Prompt actions +- Toolbar actions: New dropdown, Refresh, Collapse All +- Skill names parsed from frontmatter with fallback to folder name +- Responsive to IPromptsService change events + +## File Structure + +All files are located in `src/vs/workbench/contrib/chat/browser/aiCustomization/`: + +``` +aiCustomization/ +├── aiCustomization.ts # Constants, IDs, and MenuIds +├── aiCustomization.contribution.ts # View registration and actions +├── aiCustomizationViews.ts # Tree view pane implementation +├── aiCustomizationIcons.ts # Icon registrations +└── media/ + └── aiCustomization.css # Styling +``` + +## Key Constants (aiCustomization.ts) + +- `AI_CUSTOMIZATION_VIEWLET_ID`: View container ID for sidebar +- `AI_CUSTOMIZATION_VIEW_ID`: Unified tree view ID +- `AI_CUSTOMIZATION_STORAGE_ID`: State persistence key +- `AICustomizationItemMenuId`: Context menu ID +- `AICustomizationNewMenuId`: New item submenu ID + +## View Registration (aiCustomization.contribution.ts) + +### View Container + +Register sidebar container with: +- ViewPaneContainer with `mergeViewWithContainerWhenSingleView: true` +- Keyboard shortcut: Cmd+Shift+I +- Location: Sidebar +- Visibility: `when: ChatContextKeys.enabled` (respects AI disable setting) + +### View Descriptor + +Register single unified tree view: +- Constructor: `AICustomizationViewPane` +- Toggleable and moveable +- Gated by `ChatContextKeys.enabled` + +### Welcome Content + +Shows markdown links to create new items when tree is empty. + +## Toolbar Actions + +**New Item Dropdown** - Submenu in view title: +- Add icon in navigation group +- Submenu contains: New Agent, New Skill, New Instructions, New Prompt +- Each opens PromptFilePickers to guide user through creation + +**Refresh** - ViewAction that calls `view.refresh()` + +**Collapse All** - ViewAction that calls `view.collapseAll()` + +All actions use `ViewAction` pattern and are gated by `when: view === AI_CUSTOMIZATION_VIEW_ID`. + +## Tree View Implementation (aiCustomizationViews.ts) + +### Tree Item Types + +Discriminated union with `type` field: + +**ROOT_ELEMENT** - Symbol marker for type-safe root + +**IAICustomizationTypeItem** (`type: 'category'`) +- Represents: Custom Agents, Skills, Instructions, Prompts +- Contains: label, promptType, icon + +**IAICustomizationGroupItem** (`type: 'group'`) +- Represents: Workspace, User, Extensions +- Contains: label, storage, promptType, icon + +**IAICustomizationFileItem** (`type: 'file'`) +- Represents: Individual prompt files +- Contains: uri, name, description, storage, promptType + +### Data Source + +`UnifiedAICustomizationDataSource` implements `IAsyncDataSource`: + +**getChildren logic:** +- ROOT → 4 categories (agent, skill, instructions, prompt) +- category → storage groups (workspace, user, extensions) that have items +- group → files from `promptsService.listPromptFilesForStorage()` or `findAgentSkills()` + +**Skills special handling:** Uses `findAgentSkills()` to get names from frontmatter instead of filenames + +### Tree Renderers + +Three specialized renderers for category/group/file items: +- **Category**: Icon + bold label +- **Group**: Uppercase label with descriptionForeground color +- **File**: Icon + name with tooltip + +### View Pane + +`AICustomizationViewPane extends ViewPane`: + +**Injected services:** +- IPromptsService - data source +- IEditorService - open files +- IMenuService - context menus + +**Initialization:** +1. Subscribe to `onDidChangeCustomAgents` and `onDidChangeSlashCommands` events +2. Create WorkbenchAsyncDataTree with 3 renderers and data source +3. Register handlers: `onDidOpen` (double-click) → open file, `onContextMenu` → show menu +4. Set input to ROOT_ELEMENT and auto-expand categories + +**Auto-expansion:** +- After setInput, iterate root children and expand each category +- Reveals storage groups without user interaction +- Applied on both initial load and refresh + +**Public API:** +- `refresh()` - Reload tree and re-expand categories +- `collapseAll()` - Collapse all nodes +- `expandAll()` - Expand all nodes + +## Context Menu Actions + +Menu ID: `AICustomizationItemMenuId` + +**Actions:** +- **Open** - Opens file in editor using IEditorService +- **Run Prompt** - Only for prompt files, invokes chat with prompt + +**URI handling:** Actions must handle both URI objects and serialized strings +- Check `URI.isUri(context)` first +- Parse string variants with `URI.parse()` + +**Context passing:** +- Serialize context as `{ uri: string, name: string, promptType: PromptsType }` +- Use `shouldForwardArgs: true` in getMenuActions +- Only show context menu for file items (not categories/groups) + +## Icons (aiCustomizationIcons.ts) + +Themed icons using `registerIcon(id, codicon, label)`: + +**View/Types:** +- aiCustomizationViewIcon - Codicon.sparkle +- agentIcon - Codicon.copilot +- skillIcon - Codicon.lightbulb +- instructionsIcon - Codicon.book +- promptIcon - Codicon.bookmark + +**Storage:** +- workspaceIcon - Codicon.folder +- userIcon - Codicon.account +- extensionIcon - Codicon.extensions + +## Styling (media/aiCustomization.css) + +**Layout:** Full height view and tree container + +**Tree items:** Flex layout with 16px icon + text, ellipsis overflow + +**Categories:** Bold font-weight + +**Groups:** Uppercase, small font (11px), letter-spacing, descriptionForeground color + +## Integration Points + +**IPromptsService:** +- `listPromptFilesForStorage(type, storage)` - Get files for a type/storage combo +- `findAgentSkills()` - Get skills with names parsed from frontmatter +- `onDidChangeCustomAgents` - Refresh on agent changes +- `onDidChangeSlashCommands` - Refresh on command changes + +**PromptsType enum:** `instructions | prompt | agent | skill` + +**PromptsStorage enum:** `local` (workspace) | `user` | `extension` + +**AI Feature Gating:** View gated by `ChatContextKeys.enabled` (respects `chat.disableAIFeatures` setting) + +**Registration:** Import `./aiCustomization/aiCustomization.contribution.js` in `chat.contribution.ts` + +--- + +*Update this file when making architectural changes to the AI Customization view.* diff --git a/.github/instructions/sessions.instructions.md b/.github/instructions/sessions.instructions.md new file mode 100644 index 0000000000000..dc3f187e96c80 --- /dev/null +++ b/.github/instructions/sessions.instructions.md @@ -0,0 +1,13 @@ +--- +description: Architecture documentation for the Agent Sessions window — a sessions-first app built as a new top-level layer alongside vs/workbench. Covers layout, parts, chat widget, contributions, entry points, and development guidelines. Use when working in `src/vs/sessions` +applyTo: src/vs/sessions/** +--- + +# Agent Sessions Window + +The Agent Sessions window is a **standalone application** built as a new top-level layer (`vs/sessions`) in the VS Code architecture. It provides a sessions-first experience optimized for agent workflows — a simplified, fixed-layout workbench where chat is the primary interaction surface and editors appear as modal overlays. + +When working on files under `src/vs/sessions/`, use these skills for detailed guidance: + +- **`sessions`** skill — covers the full architecture: layering, folder structure, chat widget, menus, contributions, entry points, and development guidelines +- **`agent-sessions-layout`** skill — covers the fixed layout structure, grid configuration, part visibility, editor modal, titlebar, sidebar footer, and implementation requirements diff --git a/.github/skills/agent-sessions-layout/SKILL.md b/.github/skills/agent-sessions-layout/SKILL.md new file mode 100644 index 0000000000000..f818e2dc75e08 --- /dev/null +++ b/.github/skills/agent-sessions-layout/SKILL.md @@ -0,0 +1,80 @@ +--- +name: agent-sessions-layout +description: Agent Sessions workbench layout — covers the fixed layout structure, grid configuration, part visibility, editor modal, titlebar, sidebar footer, and implementation requirements. Use when implementing features or fixing issues in the Agent Sessions workbench layout. +--- + +When working on the Agent Sessions workbench layout, always follow these guidelines: + +## 1. Read the Specification First + +The authoritative specification for the Agent Sessions layout lives at: + +**`src/vs/sessions/LAYOUT.md`** + +Before making any changes to the layout code, read and understand the current spec. It defines: + +- The fixed layout structure (grid tree, part positions, default sizes) +- Which parts are included/excluded and their visibility defaults +- Titlebar configuration and custom menu IDs +- Editor modal overlay behavior and sizing +- Part visibility API and events +- Agent session part classes and storage keys +- Workbench contributions and lifecycle +- CSS classes and file structure + +## 2. Keep the Spec in Sync + +If you modify the layout implementation, you **must** update `LAYOUT.md` to reflect those changes. The spec should always match the code. This includes: + +- Adding/removing parts or changing their positions +- Changing default visibility or sizing +- Adding new actions, menus, or contributions +- Modifying the grid structure +- Changing titlebar configuration +- Adding new CSS classes or file structure changes + +Update the **Revision History** table at the bottom of `LAYOUT.md` with a dated entry describing what changed. + +## 3. Implementation Principles + +When proposing or implementing changes, follow these rules from the spec: + +1. **Maintain fixed positions** — Do not add settings-based position customization +2. **Panel must span the right section width** — The grid structure places the panel below Chat Bar and Auxiliary Bar only +3. **Sidebar spans full height** — Sidebar is in the main content branch, spanning from top to bottom +4. **New parts go in the right section** — Any new parts should be added to the horizontal branch alongside Chat Bar and Auxiliary Bar +5. **Preserve no-op methods** — Unsupported features (zen mode, centered layout, etc.) should remain as no-ops, not throw errors +6. **Handle pane composite lifecycle** — When hiding/showing parts, manage the associated pane composites +7. **Use agent session parts** — New part functionality goes in the agent session part classes (`SidebarPart`, `AuxiliaryBarPart`, `PanelPart`, `ChatBarPart`), not the standard workbench parts +8. **Use separate storage keys** — Agent session parts use their own storage keys (prefixed with `workbench.agentsession.` or `workbench.chatbar.`) to avoid conflicts with regular workbench state +9. **Use agent session menu IDs** — Actions should use `Menus.*` menu IDs (from `sessions/browser/menus.ts`), not shared `MenuId.*` constants + +## 4. Key Files + +| File | Purpose | +|------|---------| +| `sessions/LAYOUT.md` | Authoritative specification | +| `sessions/browser/workbench.ts` | Main layout implementation (`Workbench` class) | +| `sessions/browser/menus.ts` | Agent sessions menu IDs (`Menus` export) | +| `sessions/browser/layoutActions.ts` | Layout actions (toggle sidebar, panel, secondary sidebar) | +| `sessions/browser/paneCompositePartService.ts` | `AgenticPaneCompositePartService` | +| `sessions/browser/style.css` | Layout-specific styles | +| `sessions/browser/parts/` | Agent session part implementations | +| `sessions/browser/parts/titlebarPart.ts` | Titlebar part, MainTitlebarPart, AuxiliaryTitlebarPart, TitleService | +| `sessions/browser/parts/editorModal.ts` | Editor modal overlay | +| `sessions/browser/parts/sidebarPart.ts` | Sidebar part (with footer) | +| `sessions/browser/parts/chatBarPart.ts` | Chat Bar part | +| `sessions/browser/widget/` | Agent sessions chat widget | +| `sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts` | Title bar widget and session picker | +| `sessions/contrib/chat/browser/runScriptAction.ts` | Run script contribution | +| `sessions/contrib/accountMenu/browser/account.contribution.ts` | Account widget for sidebar footer | + +## 5. Testing Changes + +After modifying layout code: + +1. Verify the build compiles without errors via the `VS Code - Build` task +2. Ensure the grid structure matches the spec's visual representation +3. Confirm part visibility toggling works correctly (show/hide/maximize) +4. Test the editor modal opens/closes properly on editor events +5. Verify sidebar footer renders with account widget diff --git a/.github/skills/author-contributions/SKILL.md b/.github/skills/author-contributions/SKILL.md new file mode 100644 index 0000000000000..540becc35ef1b --- /dev/null +++ b/.github/skills/author-contributions/SKILL.md @@ -0,0 +1,186 @@ +--- +name: author-contributions +description: Identify all files a specific author contributed to on a branch vs its upstream, tracing code through renames. Use when asked who edited what, what code an author contributed, or to audit authorship before a merge. This skill should be run as a subagent — it performs many git operations and returns a concise table. +--- + +When asked to find all files a specific author contributed to on a branch (compared to main or another upstream), follow this procedure. The goal is to produce a simple table that both humans and LLMs can consume. + +## Run as a Subagent + +This skill involves many sequential git commands. Delegate it to a subagent with a prompt like: + +> Find every file that author "Full Name" contributed to on branch `` compared to ``. Trace contributions through file renames. Return a markdown table with columns: Status (DIRECT or VIA_RENAME), File Path, and Lines (+/-). Include a summary line at the end. + +## Procedure + +### 1. Identify the author's exact git identity + +```bash +git log --format="%an <%ae>" .. | sort -u +``` + +Match the requested person to their exact `--author=` string. Do not guess — short usernames won't match full display names (resolve via `git log` or the GitHub MCP `get_me` tool). + +### 2. Collect all files the author directly committed to + +```bash +git log --author="" --format="%H" .. +``` + +For each commit hash, extract touched files: + +```bash +git diff-tree --no-commit-id --name-only -r +``` + +Union all results into a set (`author_files`). + +### 3. Build rename map across the entire branch + +For **every** commit on the branch (not just the author's), extract renames: + +```bash +git diff-tree --no-commit-id -r -M +``` + +Parse lines with `R` status to build a map: `new_path → {old_paths}`. + +### 4. Get the merge diff file list + +```bash +git diff --name-only .. +``` + +These are the files that will actually land when the branch merges. + +### 5. Classify each file in the merge diff + +For each file in step 4: +- If it's in `author_files` → **DIRECT** +- Else, walk the rename map transitively (follow chains: current → old → older) and check if any ancestor is in `author_files` → **VIA_RENAME** +- Otherwise → not this author's contribution + +### 6. Get diff stats + +```bash +git diff --stat .. -- ... +``` + +### 7. Return the table + +Format the result as a markdown table: + +``` +| Status | File | +/- | +|--------|------|-----| +| DIRECT | src/vs/foo/bar.ts | +120/-5 | +| VIA_RENAME | src/vs/baz/qux.ts | +300 | +| ... | ... | ... | + +**Total: N files, +X/-Y lines** +``` + +## Important Notes + +- **Use Python for the heavy lifting.** Shell loops with inline comments break in zsh. Write a temp `.py` script, run it, then delete it. +- **Author matching is exact.** Always run step 1 first. `--author` does substring matching but you must verify the right person is matched (e.g., don't match "Joshua Smith" when looking for "Josh S."). Use the GitHub MCP `get_me` tool or `git log` output to resolve the correct full name. +- **Renames can be multi-hop.** A file may have moved `contrib/chat/` → `agentSessions/` → `sessions/`. The rename map must be walked transitively. +- **Only report files in the merge diff** (step 4). Files the author touched that were later deleted entirely should not appear — they won't land in the upstream. +- **The rename map must include all authors' commits**, not just the target author's. Other people often do the rename commits (e.g., bulk refactors/moves). + +## Example Python Script + +```python +import subprocess, os + +os.chdir('') +UPSTREAM = 'main' +AUTHOR = '' # Resolve via `git log` or GitHub MCP `get_me` + +# Step 2: author's files +commits = subprocess.check_output( + ['git', 'log', f'--author={AUTHOR}', '--format=%H', f'{UPSTREAM}..HEAD'], + text=True).strip().split('\n') +author_files = set() +for h in (c for c in commits if c): + files = subprocess.check_output( + ['git', 'diff-tree', '--no-commit-id', '--name-only', '-r', h], + text=True).strip().split('\n') + author_files.update(f for f in files if f) + +# Step 3: rename map from ALL commits +all_commits = subprocess.check_output( + ['git', 'log', '--format=%H', f'{UPSTREAM}..HEAD'], + text=True).strip().split('\n') +rename_map = {} # new_name -> set(old_names) +for h in (c for c in all_commits if c): + out = subprocess.check_output( + ['git', 'diff-tree', '--no-commit-id', '-r', '-M', h], + text=True, timeout=5).strip() + for line in out.split('\n'): + if not line: + continue + parts = line.split('\t') + if len(parts) >= 3 and 'R' in parts[0]: + rename_map.setdefault(parts[2], set()).add(parts[1]) + +# Step 4: merge diff +diff_files = subprocess.check_output( + ['git', 'diff', '--name-only', f'{UPSTREAM}..HEAD'], + text=True).strip().split('\n') + +# Step 5: classify +results = [] +for f in (x for x in diff_files if x): + if f in author_files: + results.append(('DIRECT', f)) + else: + # walk rename chain + chain, to_check = set(), [f] + while to_check: + cur = to_check.pop() + if cur in chain: + continue + chain.add(cur) + to_check.extend(rename_map.get(cur, [])) + chain.discard(f) + if chain & author_files: + results.append(('VIA_RENAME', f)) + +# Step 6: stats +if results: + stat = subprocess.check_output( + ['git', 'diff', '--stat', f'{UPSTREAM}..HEAD', '--'] + + [f for _, f in results], text=True) + print(stat) + +# Step 7: table +for kind, f in sorted(results, key=lambda x: x[1]): + print(f'| {kind:12s} | {f} |') +print(f'\nTotal: {len(results)} files') +``` + +### Alternative Script + +After following the process above, run this script to cross-check files touched by an author against the branch diff. You can do this both with an without src/vs/sessions. + +``` +AUTHOR="" + +# 1. Find commits by author on this branch (not on main) +git log main...HEAD --author="$AUTHOR" --format="%H" + +# 2. Get unique files touched across all those commits, excluding src/vs/sessions/ +git log main...HEAD --author="$AUTHOR" --format="%H" \ + | xargs -I{} git diff-tree --no-commit-id -r --name-only {} \ + | sort -u \ + | grep -v '^src/vs/sessions/' + +# 3. Cross-reference with branch diff to keep only files still changed vs main +git log main...HEAD --author="$AUTHOR" --format="%H" \ + | xargs -I{} git diff-tree --no-commit-id -r --name-only {} \ + | sort -u \ + | grep -v '^src/vs/sessions/' \ + | while read f; do git diff main...HEAD --name-only -- "$f" 2>/dev/null; done \ + | sort -u +``` diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md new file mode 100644 index 0000000000000..010c3779a7253 --- /dev/null +++ b/.github/skills/sessions/SKILL.md @@ -0,0 +1,277 @@ +--- +name: sessions +description: Agent Sessions window architecture — covers the sessions-first app, layering, folder structure, chat widget, menus, contributions, entry points, and development guidelines. Use when implementing features or fixing issues in the Agent Sessions window. +--- + +When working on the Agent Sessions window (`src/vs/sessions/`), always follow these guidelines: + +## 1. Read the Specification Documents First + +The `src/vs/sessions/` directory contains authoritative specification documents. **Always read the relevant spec before making changes.** + +| Document | Path | Covers | +|----------|------|--------| +| Layer spec | `src/vs/sessions/README.md` | Layering rules, dependency constraints, folder conventions | +| Layout spec | `src/vs/sessions/LAYOUT.md` | Grid structure, part positions, sizing, CSS classes, API reference | +| AI Customizations | `src/vs/sessions/AI_CUSTOMIZATIONS.md` | AI customization editor and tree view design | +| Chat Widget | `src/vs/sessions/browser/widget/AGENTS_CHAT_WIDGET.md` | Chat widget wrapper architecture, deferred session creation, option delivery | +| AI Customization Mgmt | `src/vs/sessions/contrib/aiCustomizationManagement/browser/SPEC.md` | Management editor specification | +| AI Customization Tree | `src/vs/sessions/contrib/aiCustomizationTreeView/browser/SPEC.md` | Tree view specification | + +If you modify the implementation, you **must** update the corresponding spec to keep it in sync. Update the Revision History table at the bottom of `LAYOUT.md` with a dated entry. + +## 2. Architecture Overview + +### 2.1 Layering + +``` +vs/base ← Foundation utilities +vs/platform ← Platform services +vs/editor ← Text editor core +vs/workbench ← Standard VS Code workbench +vs/sessions ← Agent Sessions window (this layer) +``` + +**Key constraint:** `vs/sessions` may import from `vs/workbench` and all layers below it. `vs/workbench` must **never** import from `vs/sessions`. + +### 2.2 Dependency Rules + +- ✅ Import from `vs/base`, `vs/platform`, `vs/editor`, `vs/workbench` +- ✅ Import within `vs/sessions` (internal) +- ❌ Never import `vs/sessions` from `vs/workbench` +- Run `npm run valid-layers-check` to verify layering + +### 2.3 How It Differs from VS Code + +| Aspect | VS Code Workbench | Agent Sessions Window | +|--------|-------------------|----------------------| +| Layout | Configurable part positions | Fixed layout, no settings customization | +| Chrome | Activity bar, status bar, banner | Simplified — none of these | +| Primary UX | Editor-centric | Chat-first (Chat Bar is a primary part) | +| Editors | In the grid layout | Modal overlay above the workbench | +| Titlebar | Menubar, editor actions, layout controls | Session picker, run script, toggle sidebar/panel | +| Navigation | Activity bar with viewlets | Sidebar (views) + sidebar footer (account) | +| Entry point | `vs/workbench` workbench class | `vs/sessions/browser/workbench.ts` `Workbench` class | + +## 3. Folder Structure + +``` +src/vs/sessions/ +├── README.md # Layer specification (read first) +├── LAYOUT.md # Authoritative layout specification +├── AI_CUSTOMIZATIONS.md # AI customization design document +├── sessions.common.main.ts # Common (browser + desktop) entry point +├── sessions.desktop.main.ts # Desktop entry point (imports all contributions) +├── common/ # Shared types and context keys +│ └── contextkeys.ts # ChatBar context keys +├── browser/ # Core workbench implementation +│ ├── workbench.ts # Main Workbench class (implements IWorkbenchLayoutService) +│ ├── menus.ts # Agent sessions menu IDs (Menus export) +│ ├── layoutActions.ts # Layout toggle actions (sidebar, panel, auxiliary bar) +│ ├── paneCompositePartService.ts # AgenticPaneCompositePartService +│ ├── style.css # Layout-specific styles +│ ├── widget/ # Agent sessions chat widget +│ │ ├── AGENTS_CHAT_WIDGET.md # Chat widget architecture doc +│ │ ├── agentSessionsChatWidget.ts # Main wrapper around ChatWidget +│ │ ├── agentSessionsChatTargetConfig.ts # Observable target state +│ │ ├── agentSessionsTargetPickerActionItem.ts # Target picker for input toolbar +│ │ └── media/ +│ └── parts/ # Workbench part implementations +│ ├── parts.ts # AgenticParts enum +│ ├── titlebarPart.ts # Titlebar (3-section toolbar layout) +│ ├── sidebarPart.ts # Sidebar (with footer for account widget) +│ ├── chatBarPart.ts # Chat Bar (primary chat surface) +│ ├── auxiliaryBarPart.ts # Auxiliary Bar (with run script dropdown) +│ ├── panelPart.ts # Panel (terminal, output, etc.) +│ ├── projectBarPart.ts # Project bar (folder entries) +│ ├── editorModal.ts # Editor modal overlay +│ ├── agentSessionsChatInputPart.ts # Chat input part adapter +│ ├── agentSessionsChatWelcomePart.ts # Welcome view (mascot + target buttons + pickers) +│ └── media/ # Part CSS files +├── electron-browser/ # Desktop-specific entry points +│ ├── sessions.main.ts # Desktop main bootstrap +│ ├── sessions.ts # Electron process entry +│ ├── sessions.html # Production HTML shell +│ └── sessions-dev.html # Development HTML shell +└── contrib/ # Feature contributions + ├── accountMenu/browser/ # Account widget for sidebar footer + ├── aiCustomizationManagement/browser/ # AI customization management editor + ├── aiCustomizationTreeView/browser/ # AI customization tree view sidebar + ├── changesView/browser/ # File changes view + ├── chat/browser/ # Chat actions (run script, branch, prompts) + ├── configuration/browser/ # Configuration overrides + └── sessions/browser/ # Sessions view, title bar widget, active session service +``` + +## 4. Layout + +Use the `agent-sessions-layout` skill for detailed guidance on the layout. Key points: + +### 4.1 Visual Layout + +``` +┌─────────┬───────────────────────────────────────────────────────┐ +│ │ Titlebar │ +│ ├────────────────────────────────────┬──────────────────┤ +│ Sidebar │ Chat Bar │ Auxiliary Bar │ +│ ├────────────────────────────────────┴──────────────────┤ +│ │ Panel │ +└─────────┴───────────────────────────────────────────────────────┘ +``` + +- **Sidebar** spans full window height (root grid level) +- **Titlebar** is inside the right section +- **Chat Bar** is the primary interaction surface +- **Panel** is hidden by default (terminal, output, etc.) +- **Editor** appears as a **modal overlay**, not in the grid + +### 4.2 Parts + +| Part | Default Visibility | Notes | +|------|-------------------|-------| +| Titlebar | Always visible | 3-section toolbar (left/center/right) | +| Sidebar | Visible | Sessions view, AI customization tree | +| Chat Bar | Visible | Primary chat widget | +| Auxiliary Bar | Visible | Changes view, etc. | +| Panel | Hidden | Terminal, output | +| Editor | Hidden | Modal overlay, auto-shows on editor open | + +**Not included:** Activity Bar, Status Bar, Banner. + +### 4.3 Editor Modal + +Editors appear as modal overlays (80% of workbench, min 400×300, max 1200×900). The modal auto-shows when an editor opens and auto-hides when all editors close. Click backdrop, press Escape, or click X to dismiss. + +## 5. Chat Widget + +The Agent Sessions chat experience is built around `AgentSessionsChatWidget` — a wrapper around the core `ChatWidget` that adds: + +- **Deferred session creation** — the UI is interactive before any session resource exists; sessions are created on first message send +- **Target configuration** — observable state tracking which agent provider (Local, Cloud) is selected +- **Welcome view** — branded empty state with mascot, target buttons, option pickers, and input slot +- **Initial session options** — option selections travel atomically with the first request +- **Configurable picker placement** — pickers can appear in welcome view, input toolbar, or both + +Read `browser/widget/AGENTS_CHAT_WIDGET.md` for the full architecture. + +### Key classes: +- `AgentSessionsChatWidget` (`browser/widget/agentSessionsChatWidget.ts`) — main wrapper +- `AgentSessionsChatTargetConfig` (`browser/widget/agentSessionsChatTargetConfig.ts`) — reactive target state +- `AgentSessionsChatWelcomePart` (`browser/parts/agentSessionsChatWelcomePart.ts`) — welcome view +- `AgentSessionsChatInputPart` (`browser/parts/agentSessionsChatInputPart.ts`) — standalone input adapter + +## 6. Menus + +The agent sessions window uses **its own menu IDs** defined in `browser/menus.ts` via the `Menus` export. **Never use shared `MenuId.*` constants** from `vs/platform/actions` for agent sessions UI — use the `Menus.*` equivalents instead. + +| Menu ID | Purpose | +|---------|---------| +| `Menus.TitleBarLeft` | Left toolbar (toggle sidebar) | +| `Menus.TitleBarCenter` | Not used directly (see CommandCenter) | +| `Menus.TitleBarRight` | Right toolbar (run script, open, toggle auxiliary bar) | +| `Menus.CommandCenter` | Center toolbar with session picker widget | +| `Menus.TitleBarControlMenu` | Submenu intercepted to render `SessionsTitleBarWidget` | +| `Menus.PanelTitle` | Panel title bar actions | +| `Menus.SidebarTitle` | Sidebar title bar actions | +| `Menus.SidebarFooter` | Sidebar footer (account widget) | +| `Menus.AuxiliaryBarTitle` | Auxiliary bar title actions | +| `Menus.AuxiliaryBarTitleLeft` | Auxiliary bar left title actions | +| `Menus.OpenSubMenu` | "Open..." split button (Open Terminal, Open in VS Code) | +| `Menus.ChatBarTitle` | Chat bar title actions | + +## 7. Context Keys + +Defined in `common/contextkeys.ts`: + +| Context Key | Type | Purpose | +|-------------|------|---------| +| `activeChatBar` | `string` | ID of the active chat bar panel | +| `chatBarFocus` | `boolean` | Whether chat bar has keyboard focus | +| `chatBarVisible` | `boolean` | Whether chat bar is visible | + +## 8. Contributions + +Feature contributions live under `contrib//browser/` and are registered via imports in `sessions.desktop.main.ts` (desktop) or `sessions.common.main.ts` (browser-compatible). + +### 8.1 Key Contributions + +| Contribution | Location | Purpose | +|-------------|----------|---------| +| **Sessions View** | `contrib/sessions/browser/` | Sessions list in sidebar, session picker, active session service | +| **Title Bar Widget** | `contrib/sessions/browser/sessionsTitleBarWidget.ts` | Session picker in titlebar center | +| **Account Widget** | `contrib/accountMenu/browser/` | Account button in sidebar footer | +| **Run Script** | `contrib/chat/browser/runScriptAction.ts` | Run configured script in terminal | +| **Branch Chat Session** | `contrib/chat/browser/branchChatSessionAction.ts` | Branch a chat session | +| **Open in VS Code / Terminal** | `contrib/chat/browser/chat.contribution.ts` | Open worktree in VS Code or terminal | +| **Prompts Service** | `contrib/chat/browser/promptsService.ts` | Agentic prompts service override | +| **Changes View** | `contrib/changesView/browser/` | File changes in auxiliary bar | +| **AI Customization Editor** | `contrib/aiCustomizationManagement/browser/` | Management editor for prompts, hooks, MCP, etc. | +| **AI Customization Tree** | `contrib/aiCustomizationTreeView/browser/` | Sidebar tree for AI customizations | +| **Configuration** | `contrib/configuration/browser/` | Configuration overrides | + +### 8.2 Service Overrides + +The agent sessions window registers its own implementations for: + +- `IPaneCompositePartService` → `AgenticPaneCompositePartService` (creates agent-specific parts) +- `IPromptsService` → `AgenticPromptsService` (scopes prompt discovery to active session worktree) +- `IActiveSessionService` → `ActiveSessionService` (tracks active session) + +### 8.3 `WindowVisibility.Sessions` + +Views and contributions that should only appear in the agent sessions window (not in regular VS Code) use `WindowVisibility.Sessions` in their registration. + +## 9. Entry Points + +| File | Purpose | +|------|---------| +| `sessions.common.main.ts` | Common entry — imports browser-compatible services, workbench contributions | +| `sessions.desktop.main.ts` | Desktop entry — imports desktop services, electron contributions, all `contrib/` modules | +| `electron-browser/sessions.main.ts` | Desktop bootstrap | +| `electron-browser/sessions.ts` | Electron process entry | +| `electron-browser/sessions.html` | Production HTML shell | +| `electron-browser/sessions-dev.html` | Development HTML shell | + +## 10. Development Guidelines + +### 10.1 Adding New Features + +1. **Core workbench code** (layout, parts, services) → `browser/` +2. **Feature contributions** (views, actions, editors) → `contrib//browser/` +3. Register by importing in `sessions.desktop.main.ts` (or `sessions.common.main.ts` for browser-compatible) +4. Use `Menus.*` from `browser/menus.ts` for menu registrations — never shared `MenuId.*` +5. Use separate storage keys prefixed with `workbench.agentsession.*` or `workbench.chatbar.*` +6. Use agent session part classes, not standard workbench parts +7. Mark views with `WindowVisibility.Sessions` so they only appear in this window + +### 10.2 Layout Changes + +1. **Read `LAYOUT.md` first** — it's the authoritative spec +2. Use the `agent-sessions-layout` skill for detailed implementation guidance +3. Maintain fixed positions — no settings-based customization +4. Update `LAYOUT.md` and its Revision History after any changes +5. Preserve no-op methods for unsupported features (zen mode, centered layout, etc.) +6. Handle pane composite lifecycle when hiding/showing parts + +### 10.3 Chat Widget Changes + +1. **Read `browser/widget/AGENTS_CHAT_WIDGET.md` first** +2. Prefer composition over modifying core `ChatWidget` — add behavior in the wrapper +3. Use `IAgentChatTargetConfig` observable for target state, not direct session creation +4. Ensure `initialSessionOptions` travel atomically with the first request +5. Test both first-load (extension not yet activated) and new-session flows + +### 10.4 AI Customization Changes + +1. **Read `AI_CUSTOMIZATIONS.md` first** — it covers the management editor and tree view design +2. Lean on existing VS Code services (`IPromptsService`, `IMcpService`, `IChatService`) +3. Browser compatibility required — no Node.js APIs +4. Active worktree comes from `IActiveSessionService` + +### 10.5 Validation + +1. Check `VS Code - Build` task output for compilation errors before declaring work complete +2. Run `npm run valid-layers-check` for layering violations +3. Verify part visibility toggling (show/hide/maximize) +4. Test editor modal open/close behavior +5. Test sidebar footer renders with account widget diff --git a/build/buildfile.ts b/build/buildfile.ts index 168539f4cae5f..47b0476892cb7 100644 --- a/build/buildfile.ts +++ b/build/buildfile.ts @@ -25,7 +25,8 @@ export const workbenchDesktop = [ createModuleDescription('vs/platform/files/node/watcher/watcherMain'), createModuleDescription('vs/platform/terminal/node/ptyHostMain'), createModuleDescription('vs/workbench/api/node/extensionHostProcess'), - createModuleDescription('vs/workbench/workbench.desktop.main') + createModuleDescription('vs/workbench/workbench.desktop.main'), + createModuleDescription('vs/sessions/sessions.desktop.main') ]; export const workbenchWeb = createModuleDescription('vs/workbench/workbench.web.main.internal'); @@ -42,6 +43,7 @@ export const code = [ createModuleDescription('vs/code/node/cliProcessMain'), createModuleDescription('vs/code/electron-utility/sharedProcess/sharedProcessMain'), createModuleDescription('vs/code/electron-browser/workbench/workbench'), + createModuleDescription('vs/sessions/electron-browser/sessions'), ]; export const codeWeb = createModuleDescription('vs/code/browser/workbench/workbench'); diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index a103f116c1e6d..cbf6cc9452708 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -67,6 +67,7 @@ const vscodeResourceIncludes = [ // Workbench 'out-build/vs/code/electron-browser/workbench/workbench.html', + 'out-build/vs/sessions/electron-browser/sessions.html', // Electron Preload 'out-build/vs/base/parts/sandbox/electron-browser/preload.js', @@ -96,6 +97,9 @@ const vscodeResourceIncludes = [ // Welcome 'out-build/vs/workbench/contrib/welcomeGettingStarted/common/media/**/*.{svg,png}', + // Sessions + 'out-build/vs/sessions/contrib/chat/browser/media/*.svg', + // Extensions 'out-build/vs/workbench/contrib/extensions/browser/media/{theme-icon.png,language-icon.svg}', 'out-build/vs/workbench/services/extensionManagement/common/media/*.{svg,png}', @@ -148,7 +152,7 @@ const bundleVSCodeTask = task.define('bundle-vscode', task.series( ...bootstrapEntryPoints ], resources: vscodeResources, - skipTSBoilerplateRemoval: entryPoint => entryPoint === 'vs/code/electron-browser/workbench/workbench' + skipTSBoilerplateRemoval: entryPoint => entryPoint === 'vs/code/electron-browser/workbench/workbench' || entryPoint === 'vs/sessions/electron-browser/sessions' } } ) @@ -327,7 +331,11 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d 'vs/workbench/workbench.desktop.main.css', 'vs/workbench/api/node/extensionHostProcess.js', 'vs/code/electron-browser/workbench/workbench.html', - 'vs/code/electron-browser/workbench/workbench.js' + 'vs/code/electron-browser/workbench/workbench.js', + 'vs/sessions/sessions.desktop.main.js', + 'vs/sessions/sessions.desktop.main.css', + 'vs/sessions/electron-browser/sessions.html', + 'vs/sessions/electron-browser/sessions.js' ]); const src = gulp.src(out + '/**', { base: '.' }) @@ -378,6 +386,28 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d this.emit('data', file); })); + + const packageSubJsonStream = gulp.src(['package.json'], { base: '.' }) + .pipe(jsonEditor((json: Record) => { + json.name = `sessions-${quality || 'oss-dev'}`; + return json; + })) + .pipe(rename('package.sub.json')); + + const embedded = (product as typeof product & { embedded?: { nameShort: string; nameLong: string; applicationName: string; dataFolderName: string; darwinBundleIdentifier: string } }).embedded; + const productSubJsonStream = embedded + ? gulp.src(['product.json'], { base: '.' }) + .pipe(jsonEditor((json: Record) => { + json.nameShort = embedded.nameShort; + json.nameLong = embedded.nameLong; + json.applicationName = embedded.applicationName; + json.dataFolderName = embedded.dataFolderName; + json.darwinBundleIdentifier = embedded.darwinBundleIdentifier; + return json; + })) + .pipe(rename('product.sub.json')) + : gulp.src(['product.sub.json'], { base: '.', allowEmpty: true }); + const license = gulp.src([product.licenseFileName, 'ThirdPartyNotices.txt', 'licenses/**'], { base: '.', allowEmpty: true }); // TODO the API should be copied to `out` during compile, not here @@ -416,6 +446,8 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d let all = es.merge( packageJsonStream, productJsonStream, + packageSubJsonStream, + productSubJsonStream, license, api, telemetry, @@ -470,12 +502,24 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d all = es.merge(all, shortcut, policyDest); } + const electronConfig = { + ...config, + platform, + arch: arch === 'armhf' ? 'arm' : arch, + ffmpegChromium: false, + ...(embedded ? { + darwinMiniAppName: embedded.nameShort, + darwinMiniAppBundleIdentifier: embedded.darwinBundleIdentifier, + darwinMiniAppIcon: 'resources/darwin/sessions.icns', + } : {}) + }; + let result: NodeJS.ReadWriteStream = all .pipe(util.skipDirectories()) .pipe(util.fixWin32DirectoryPermissions()) .pipe(filter(['**', '!**/.github/**'], { dot: true })) // https://github.com/microsoft/vscode/issues/116523 - .pipe(electron({ ...config, platform, arch: arch === 'armhf' ? 'arm' : arch, ffmpegChromium: false })) - .pipe(filter(['**', '!LICENSE', '!version', ...(platform === 'darwin' ? ['!**/Contents/Applications/**'] : [])], { dot: true })); + .pipe(electron(electronConfig)) + .pipe(filter(['**', '!LICENSE', '!version'], { dot: true })); if (platform === 'linux') { result = es.merge(result, gulp.src('resources/completions/bash/code', { base: '.' }) diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index a03d24470c7e7..acf81bb35cb99 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -383,6 +383,9 @@ "--vscode-gauge-foreground", "--vscode-gauge-warningBackground", "--vscode-gauge-warningForeground", + "--vscode-gitDecoration-addedResourceForeground", + "--vscode-gitDecoration-deletedResourceForeground", + "--vscode-gitDecoration-modifiedResourceForeground", "--vscode-icon-foreground", "--vscode-inlineChat-background", "--vscode-inlineChat-border", @@ -941,6 +944,9 @@ "--testMessageDecorationFontSize", "--title-border-bottom-color", "--title-wco-width", + "--reveal-button-size", + "--part-background", + "--part-border-color", "--vscode-chat-list-background", "--vscode-editorCodeLens-fontFamily", "--vscode-editorCodeLens-fontFamilyDefault", @@ -1007,7 +1013,10 @@ "--comment-thread-state-background-color", "--inline-edit-border-radius", "--chat-subagent-last-item-height", - "--vscode-inline-chat-affordance-height" + "--vscode-inline-chat-affordance-height", + "--collapse-from-width", + "--slide-from-x", + "--slide-from-y" ], "sizes": [ "--vscode-bodyFontSize", diff --git a/build/next/index.ts b/build/next/index.ts index 57a33f69d316c..f1c0784ef28e1 100644 --- a/build/next/index.ts +++ b/build/next/index.ts @@ -93,6 +93,7 @@ const desktopWorkerEntryPoints = [ // Desktop workbench and code entry points const desktopEntryPoints = [ 'vs/workbench/workbench.desktop.main', + 'vs/sessions/sessions.desktop.main', 'vs/workbench/contrib/debug/node/telemetryApp', 'vs/platform/files/node/watcher/watcherMain', 'vs/platform/terminal/node/ptyHostMain', @@ -103,6 +104,7 @@ const codeEntryPoints = [ 'vs/code/node/cliProcessMain', 'vs/code/electron-utility/sharedProcess/sharedProcessMain', 'vs/code/electron-browser/workbench/workbench', + 'vs/sessions/electron-browser/sessions', ]; // Web entry points (used in server-web and vscode-web) @@ -197,6 +199,8 @@ function getCssBundleEntryPointsForTarget(target: BuildTarget): Set { return new Set([ 'vs/workbench/workbench.desktop.main', 'vs/code/electron-browser/workbench/workbench', + 'vs/sessions/sessions.desktop.main', + 'vs/sessions/electron-browser/sessions', ]); case 'server': return new Set(); // Server has no UI @@ -227,6 +231,7 @@ const commonResourcePatterns = [ // SVGs referenced from CSS (needed for transpile/dev builds where CSS is copied as-is) 'vs/workbench/browser/media/code-icon.svg', 'vs/workbench/browser/parts/editor/media/letterpress*.svg', + 'vs/sessions/contrib/chat/browser/media/*.svg' ]; // Resources for desktop target @@ -236,6 +241,8 @@ const desktopResourcePatterns = [ // HTML 'vs/code/electron-browser/workbench/workbench.html', 'vs/code/electron-browser/workbench/workbench-dev.html', + 'vs/sessions/electron-browser/sessions.html', + 'vs/sessions/electron-browser/sessions-dev.html', 'vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html', 'vs/workbench/contrib/webview/browser/pre/*.html', diff --git a/eslint.config.js b/eslint.config.js index c2b3e29d446ac..bc3c698a129f2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1686,6 +1686,7 @@ export default tseslint.config( 'vs/workbench/~', 'vs/workbench/services/*/~', 'vs/workbench/contrib/*/~', + 'vs/sessions/~', 'vs/workbench/contrib/terminal/terminalContribChatExports*', 'vs/workbench/contrib/terminal/terminalContribExports*', 'vscode-notebook-renderer', // Type only import @@ -1762,6 +1763,17 @@ export default tseslint.config( } ] }, + { + 'target': 'src/vs/sessions/electron-browser/sessions.ts', + 'layer': 'electron-browser', + 'restrictions': [ + 'vs/base/~', + 'vs/base/parts/*/~', + 'vs/platform/*/~', + 'vs/sessions/~', + 'vs/sessions/sessions.desktop.main.js' + ] + }, { 'target': 'src/vs/server/~', 'restrictions': [ @@ -1894,7 +1906,74 @@ export default tseslint.config( 'src/*.js', '*' // node.js ] - } + }, + { + 'target': 'src/vs/sessions/sessions.common.main.ts', + 'layer': 'browser', + 'restrictions': [ + 'vs/base/~', + 'vs/base/parts/*/~', + 'vs/platform/*/~', + 'vs/editor/~', + 'vs/editor/contrib/*/~', + 'vs/editor/editor.all.js', + 'vs/workbench/~', + 'vs/workbench/api/~', + 'vs/workbench/services/*/~', + 'vs/workbench/contrib/*/~', + 'vs/workbench/contrib/terminal/terminal.all.js' + ] + }, + { + 'target': 'src/vs/sessions/sessions.desktop.main.ts', + 'layer': 'electron-browser', + 'restrictions': [ + 'vs/base/*/~', + 'vs/base/parts/*/~', + 'vs/platform/*/~', + 'vs/editor/~', + 'vs/editor/contrib/*/~', + 'vs/editor/editor.all.js', + 'vs/sessions/~', + 'vs/sessions/contrib/*/~', + 'vs/workbench/~', + 'vs/workbench/api/~', + 'vs/workbench/services/*/~', + 'vs/workbench/contrib/*/~', + 'vs/sessions/sessions.common.main.js' + ] + }, + { + 'target': 'src/vs/sessions/~', + 'restrictions': [ + 'vs/base/~', + 'vs/base/parts/*/~', + 'vs/platform/*/~', + 'vs/editor/~', + 'vs/editor/contrib/*/~', + 'vs/workbench/~', + 'vs/workbench/browser/**', + 'vs/workbench/contrib/**', + 'vs/workbench/services/*/~', + 'vs/sessions/~' + ] + }, + { + 'target': 'src/vs/sessions/contrib/*/~', + 'restrictions': [ + 'vs/base/~', + 'vs/base/parts/*/~', + 'vs/platform/*/~', + 'vs/editor/~', + 'vs/editor/contrib/*/~', + 'vs/workbench/~', + 'vs/workbench/browser/**', + 'vs/workbench/services/*/~', + 'vs/workbench/contrib/*/~', + 'vs/sessions/~', + 'vs/sessions/contrib/*/~' + ] + }, ] } }, diff --git a/extensions/github/package.json b/extensions/github/package.json index cd70cfea26b65..a9ba2e87d3087 100644 --- a/extensions/github/package.json +++ b/extensions/github/package.json @@ -29,6 +29,7 @@ }, "enabledApiProposals": [ "canonicalUriProvider", + "chatSessionsProvider", "contribEditSessions", "contribShareMenu", "contribSourceControlHistoryItemMenu", @@ -68,6 +69,11 @@ "command": "github.timeline.openOnGitHub", "title": "%command.openOnGitHub%", "icon": "$(github)" + }, + { + "command": "github.createPullRequest", + "title": "%command.createPullRequest%", + "icon": "$(git-pull-request)" } ], "continueEditSession": [ @@ -85,6 +91,10 @@ "command": "github.publish", "when": "git-base.gitEnabled && workspaceFolderCount != 0 && remoteName != 'codespaces'" }, + { + "command": "github.createPullRequest", + "when": "false" + }, { "command": "github.graph.openOnGitHub", "when": "false" @@ -163,6 +173,14 @@ "group": "1_actions@3", "when": "github.hasGitHubRepo && timelineItem =~ /git:file:commit\\b/" } + ], + "chat/input/editing/sessionToolbar": [ + { + "command": "github.createPullRequest", + "group": "navigation", + "order": 1, + "when": "isSessionsWindow && agentSessionHasChanges && chatSessionType == copilotcli" + } ] }, "configuration": [ diff --git a/extensions/github/package.nls.json b/extensions/github/package.nls.json index 40271bea980e8..ced536e4bd7c6 100644 --- a/extensions/github/package.nls.json +++ b/extensions/github/package.nls.json @@ -5,6 +5,7 @@ "command.publish": "Publish to GitHub", "command.openOnGitHub": "Open on GitHub", "command.openOnVscodeDev": "Open in vscode.dev", + "command.createPullRequest": "Create Pull Request", "config.branchProtection": "Controls whether to query repository rules for GitHub repositories", "config.gitAuthentication": "Controls whether to enable automatic GitHub authentication for git commands within VS Code.", "config.gitProtocol": "Controls which protocol is used to clone a GitHub repository", diff --git a/extensions/github/src/commands.ts b/extensions/github/src/commands.ts index 48e9574c708c1..496772ededf82 100644 --- a/extensions/github/src/commands.ts +++ b/extensions/github/src/commands.ts @@ -34,6 +34,68 @@ async function openVscodeDevLink(gitAPI: GitAPI): Promise { + if (!sessionResource || !sessionMetadata?.worktreePath) { + return; + } + + const worktreeUri = vscode.Uri.file(sessionMetadata.worktreePath); + const repository = gitAPI.getRepository(worktreeUri); + + if (!repository) { + vscode.window.showErrorMessage(vscode.l10n.t('Could not find a git repository for the session worktree.')); + return; + } + + // Find the GitHub remote + const remotes = repository.state.remotes + .filter(remote => remote.fetchUrl && getRepositoryFromUrl(remote.fetchUrl)); + + if (remotes.length === 0) { + vscode.window.showErrorMessage(vscode.l10n.t('Could not find a GitHub remote for this repository.')); + return; + } + + // Prefer upstream -> origin -> first + const gitRemote = remotes.find(r => r.name === 'upstream') + ?? remotes.find(r => r.name === 'origin') + ?? remotes[0]; + + const remoteInfo = getRepositoryFromUrl(gitRemote.fetchUrl!); + if (!remoteInfo) { + vscode.window.showErrorMessage(vscode.l10n.t('Could not parse GitHub remote URL.')); + return; + } + + // Get the current branch (the worktree branch) + const head = repository.state.HEAD; + if (!head?.name) { + vscode.window.showErrorMessage(vscode.l10n.t('Could not determine the current branch.')); + return; + } + + // Ensure the branch is published to the remote + if (!head.upstream) { + try { + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Publishing branch to {0}...', gitRemote.name) }, + async () => { + await repository.push(gitRemote.name, head.name, true); + } + ); + } catch (err) { + vscode.window.showErrorMessage(vscode.l10n.t('Failed to publish branch: {0}', err instanceof Error ? err.message : String(err))); + return; + } + } + + // Build the GitHub PR creation URL + // Format: https://github.com/owner/repo/compare/base...head + const prUrl = `https://github.com/${remoteInfo.owner}/${remoteInfo.repo}/compare/${head.name}?expand=1`; + + vscode.env.openExternal(vscode.Uri.parse(prUrl)); +} + async function openOnGitHub(repository: Repository, commit: string): Promise { // Get the unique remotes that contain the commit const branches = await repository.getBranches({ contains: commit, remote: true }); @@ -115,5 +177,9 @@ export function registerCommands(gitAPI: GitAPI): vscode.Disposable { return openVscodeDevLink(gitAPI); })); + disposables.add(vscode.commands.registerCommand('github.createPullRequest', async (sessionResource: vscode.Uri | undefined, sessionMetadata: { worktreePath?: string } | undefined) => { + return createPullRequest(gitAPI, sessionResource, sessionMetadata); + })); + return disposables; } diff --git a/package-lock.json b/package-lock.json index 2fa11b4294c6b..32f7e1bae0a78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,7 +84,7 @@ "@types/yazl": "^2.4.2", "@typescript-eslint/utils": "^8.45.0", "@typescript/native-preview": "^7.0.0-dev.20260130", - "@vscode/gulp-electron": "^1.38.2", + "@vscode/gulp-electron": "https://github.com/microsoft/vscode-gulp-electron.git#405e3df0e4e9c37fcf549cbe6f5cef8d5ba5ddff", "@vscode/l10n-dev": "0.0.35", "@vscode/telemetry-extractor": "^1.10.2", "@vscode/test-cli": "^0.0.6", @@ -2979,8 +2979,8 @@ }, "node_modules/@vscode/gulp-electron": { "version": "1.38.2", - "resolved": "https://registry.npmjs.org/@vscode/gulp-electron/-/gulp-electron-1.38.2.tgz", - "integrity": "sha512-uFMp6Utz2kf62NMXVIht09FfIcuAFLuw7b9xhJNm2iGaaAI3b2BBHP05cKG3LYIPGvkWoC7UNk4EjyQDO7T/ZA==", + "resolved": "git+ssh://git@github.com/microsoft/vscode-gulp-electron.git#405e3df0e4e9c37fcf549cbe6f5cef8d5ba5ddff", + "integrity": "sha512-SHFKIq0Gr8WOeVn9QOACkbxX5lsaj96Ux2npBHSb/a7S6ykyDD0Im1i+xCT96WimWLRQV0X20sK9IFli8I2Mkg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index c64345a7823b3..20b139291a3a7 100644 --- a/package.json +++ b/package.json @@ -149,7 +149,7 @@ "@types/yazl": "^2.4.2", "@typescript-eslint/utils": "^8.45.0", "@typescript/native-preview": "^7.0.0-dev.20260130", - "@vscode/gulp-electron": "^1.38.2", + "@vscode/gulp-electron": "https://github.com/microsoft/vscode-gulp-electron.git#405e3df0e4e9c37fcf549cbe6f5cef8d5ba5ddff", "@vscode/l10n-dev": "0.0.35", "@vscode/telemetry-extractor": "^1.10.2", "@vscode/test-cli": "^0.0.6", diff --git a/src/bootstrap-meta.ts b/src/bootstrap-meta.ts index 1e5affb0a9754..51c7066d8c6f8 100644 --- a/src/bootstrap-meta.ts +++ b/src/bootstrap-meta.ts @@ -5,6 +5,7 @@ import { createRequire } from 'node:module'; import type { IProductConfiguration } from './vs/base/common/product.js'; +import type { INodeProcess } from './vs/base/common/platform.js'; const require = createRequire(import.meta.url); @@ -18,6 +19,18 @@ if (pkgObj['BUILD_INSERT_PACKAGE_CONFIGURATION']) { pkgObj = require('../package.json'); // Running out of sources } +// Load sub files +if ((process as INodeProcess).isEmbeddedApp) { + try { + const productSubObj = require('../product.sub.json'); + productObj = Object.assign(productObj, productSubObj); + } catch (error) { /* ignore */ } + try { + const pkgSubObj = require('../package.sub.json'); + pkgObj = Object.assign(pkgObj, pkgSubObj); + } catch (error) { /* ignore */ } +} + let productOverridesObj = {}; if (process.env['VSCODE_DEV']) { try { diff --git a/src/tsec.exemptions.json b/src/tsec.exemptions.json index 83691e2de5ab8..e290af37d5c42 100644 --- a/src/tsec.exemptions.json +++ b/src/tsec.exemptions.json @@ -13,6 +13,7 @@ ], "ban-trustedtypes-createpolicy": [ "vs/code/electron-browser/workbench/workbench.ts", + "vs/sessions/electron-browser/sessions.ts", "vs/amdX.ts", "vs/base/browser/trustedTypes.ts", "vs/workbench/contrib/notebook/browser/view/renderers/webviewPreloads.ts" @@ -37,6 +38,7 @@ "**/*.ts" ], "ban-script-content-assignments": [ - "vs/code/electron-browser/workbench/workbench.ts" + "vs/code/electron-browser/workbench/workbench.ts", + "vs/sessions/electron-browser/sessions.ts" ] } diff --git a/src/vs/base/browser/ui/button/button.ts b/src/vs/base/browser/ui/button/button.ts index 8b7fc2d9b33cc..78c7a71963ae5 100644 --- a/src/vs/base/browser/ui/button/button.ts +++ b/src/vs/base/browser/ui/button/button.ts @@ -91,6 +91,15 @@ const buttonSanitizerConfig = Object.freeze({ }, }); +// Markdown render options that allow class attributes to pass through +const buttonMarkdownRenderOptions = Object.freeze({ + sanitizerConfig: { + allowedAttributes: { + override: ['class'], + } + } +}); + export class Button extends Disposable implements IButton { protected options: IButtonOptions; @@ -262,7 +271,7 @@ export class Button extends Disposable implements IButton { const labelElement = this.options.supportShortLabel ? this._labelElement! : this._element; if (isMarkdownString(value)) { - const rendered = renderMarkdown(value, undefined, document.createElement('span')); + const rendered = renderMarkdown(value, buttonMarkdownRenderOptions, document.createElement('span')); rendered.dispose(); // Don't include outer `

` @@ -673,7 +682,7 @@ export class ButtonWithIcon extends Button { this._element.classList.add('monaco-text-button'); if (isMarkdownString(value)) { - const rendered = renderMarkdown(value, undefined, document.createElement('span')); + const rendered = renderMarkdown(value, buttonMarkdownRenderOptions, document.createElement('span')); rendered.dispose(); // eslint-disable-next-line no-restricted-syntax diff --git a/src/vs/base/browser/ui/tree/abstractTree.ts b/src/vs/base/browser/ui/tree/abstractTree.ts index d8684d50200e4..d3fc8596e457c 100644 --- a/src/vs/base/browser/ui/tree/abstractTree.ts +++ b/src/vs/base/browser/ui/tree/abstractTree.ts @@ -1431,7 +1431,7 @@ class StickyScrollController extends Disposable { const firstVisibleNode = this.getNodeAtHeight(this.paddingTop); // Don't render anything if there are no elements - if (!firstVisibleNode || this.tree.scrollTop <= this.paddingTop) { + if (!firstVisibleNode || this.tree.scrollTop <= this.paddingTop || this.view.renderHeight === 0) { this._widget.setState(undefined); return; } diff --git a/src/vs/base/common/platform.ts b/src/vs/base/common/platform.ts index 3013e09489b22..91831fa4b78b1 100644 --- a/src/vs/base/common/platform.ts +++ b/src/vs/base/common/platform.ts @@ -44,6 +44,7 @@ export interface INodeProcess { chrome?: string; }; type?: string; + isEmbeddedApp?: boolean; cwd: () => string; } diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index fb4dc89183063..fa278c0827683 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -15,7 +15,7 @@ import { getPathLabel } from '../../base/common/labels.js'; import { Disposable, DisposableStore } from '../../base/common/lifecycle.js'; import { Schemas, VSCODE_AUTHORITY } from '../../base/common/network.js'; import { join, posix } from '../../base/common/path.js'; -import { IProcessEnvironment, isLinux, isLinuxSnap, isMacintosh, isWindows, OS } from '../../base/common/platform.js'; +import { INodeProcess, IProcessEnvironment, isLinux, isLinuxSnap, isMacintosh, isWindows, OS } from '../../base/common/platform.js'; import { assertType } from '../../base/common/types.js'; import { URI } from '../../base/common/uri.js'; import { generateUuid } from '../../base/common/uuid.js'; @@ -1288,7 +1288,12 @@ export class CodeApplication extends Disposable { const context = isLaunchedFromCli(process.env) ? OpenContext.CLI : OpenContext.DESKTOP; const args = this.environmentMainService.args; - // First check for windows from protocol links to open + // Open sessions window if requested + if ((process as INodeProcess).isEmbeddedApp || args['sessions']) { + return windowsMainService.openSessionsWindow({ context, contextWindowId: undefined }); + } + + // Then check for windows from protocol links to open if (initialProtocolUrls) { // Openables can open as windows directly diff --git a/src/vs/code/node/cli.ts b/src/vs/code/node/cli.ts index 8e29f4924766b..e2aa3084c3f14 100644 --- a/src/vs/code/node/cli.ts +++ b/src/vs/code/node/cli.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { ChildProcess, spawn, SpawnOptions, StdioOptions } from 'child_process'; -import { chmodSync, existsSync, readFileSync, statSync, truncateSync, unlinkSync } from 'fs'; +import { chmodSync, existsSync, readFileSync, readdirSync, statSync, truncateSync, unlinkSync } from 'fs'; import { homedir, tmpdir } from 'os'; import type { ProfilingSession, Target } from 'v8-inspect-profiler'; import { Event } from '../../base/common/event.js'; @@ -500,7 +500,23 @@ export async function main(argv: string[]): Promise { // focusing issues when the new instance only sends data to a previous instance and then closes. const spawnArgs = ['-n', '-g']; // -a opens the given application. - spawnArgs.push('-a', process.execPath); // -a: opens a specific application + let appToLaunch = process.execPath; + if (args['sessions']) { + // process.execPath is e.g. /Applications/Code.app/Contents/MacOS/Electron + // Embedded app is at /Applications/Code.app/Contents/Applications/.app + const contentsPath = dirname(dirname(process.execPath)); + const applicationsPath = join(contentsPath, 'Applications'); + const embeddedApp = existsSync(applicationsPath) && readdirSync(applicationsPath).find(f => f.endsWith('.app')); + if (embeddedApp) { + appToLaunch = join(applicationsPath, embeddedApp); + argv = argv.filter(arg => arg !== '--sessions'); + } else { + console.error(`No embedded app found in: ${applicationsPath}`); + console.error('The --sessions flag requires an embedded app to be installed.'); + return; + } + } + spawnArgs.push('-a', appToLaunch); if (args.verbose || args.status) { spawnArgs.push('--wait-apps'); // `open --wait-apps`: blocks until the launched app is closed (even if they were already running) diff --git a/src/vs/platform/actions/browser/buttonbar.ts b/src/vs/platform/actions/browser/buttonbar.ts index 52fbbe18092ea..b94a519c4f52f 100644 --- a/src/vs/platform/actions/browser/buttonbar.ts +++ b/src/vs/platform/actions/browser/buttonbar.ts @@ -8,6 +8,7 @@ import { createInstantHoverDelegate } from '../../../base/browser/ui/hover/hover import { ActionRunner, IAction, IActionRunner, SubmenuAction, WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../base/common/actions.js'; import { Codicon } from '../../../base/common/codicons.js'; import { Emitter, Event } from '../../../base/common/event.js'; +import { IMarkdownString, isMarkdownString, MarkdownString } from '../../../base/common/htmlContent.js'; import { DisposableStore } from '../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../base/common/themables.js'; import { localize } from '../../../nls.js'; @@ -24,6 +25,8 @@ export type IButtonConfigProvider = (action: IAction, index: number) => { showIcon?: boolean; showLabel?: boolean; isSecondary?: boolean; + customLabel?: string | IMarkdownString; + customClass?: string; } | undefined; export interface IWorkbenchButtonBarOptions { @@ -117,8 +120,15 @@ export class WorkbenchButtonBar extends ButtonBar { btn.checked = action.checked ?? false; btn.element.classList.add('default-colors'); const showLabel = conifgProvider(action, i)?.showLabel ?? true; + const customClass = conifgProvider(action, i)?.customClass; + const customLabel = conifgProvider(action, i)?.customLabel; + + if (customClass) { + btn.element.classList.add(customClass); + } + if (showLabel) { - btn.label = action.label; + btn.label = customLabel ?? action.label; } else { btn.element.classList.add('monaco-text-button'); } @@ -129,7 +139,12 @@ export class WorkbenchButtonBar extends ButtonBar { } else { // this is REALLY hacky but combining a codicon and normal text is ugly because // the former define a font which doesn't work for text - btn.label = `$(${action.item.icon.id}) ${action.label}`; + const labelValue = customLabel ?? action.label; + btn.label = isMarkdownString(labelValue) + ? new MarkdownString(`$(${action.item.icon.id}) ${labelValue.value}`, { + isTrusted: labelValue.isTrusted, supportThemeIcons: true, supportHtml: labelValue.supportHtml + }) + : `$(${action.item.icon.id}) ${labelValue}`; } } else if (action.class) { btn.element.classList.add(...action.class.split(' ')); diff --git a/src/vs/platform/actions/browser/menuEntryActionViewItem.ts b/src/vs/platform/actions/browser/menuEntryActionViewItem.ts index 96e950596204e..feb53c1efcb5e 100644 --- a/src/vs/platform/actions/browser/menuEntryActionViewItem.ts +++ b/src/vs/platform/actions/browser/menuEntryActionViewItem.ts @@ -520,7 +520,12 @@ export class DropdownWithDefaultActionViewItem extends BaseActionViewItem { super.actionRunner = actionRunner; this._defaultAction.actionRunner = actionRunner; - this._dropdown.actionRunner = actionRunner; + // When togglePrimaryAction is enabled, keep the dropdown's private + // action runner so that the onDidRun listener only fires for actions + // originating from the dropdown, not from unrelated toolbar buttons. + if (!this._options?.togglePrimaryAction) { + this._dropdown.actionRunner = actionRunner; + } if (this._primaryActionListener.value) { this.registerTogglePrimaryActionListener(); } diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index e9356c85f9df0..c3d2db6d98236 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -245,6 +245,7 @@ export class MenuId { static readonly MergeInputResultToolbar = new MenuId('MergeToolbarResultToolbar'); static readonly InlineSuggestionToolbar = new MenuId('InlineSuggestionToolbar'); static readonly InlineEditToolbar = new MenuId('InlineEditToolbar'); + static readonly AgentFeedbackEditorContent = new MenuId('AgentFeedbackEditorContent'); static readonly ChatContext = new MenuId('ChatContext'); static readonly ChatCodeBlock = new MenuId('ChatCodeblock'); static readonly ChatCompareBlock = new MenuId('ChatCompareBlock'); diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts index a10f4c9b3bbc5..45cb23ba6f3f7 100644 --- a/src/vs/platform/environment/common/argv.ts +++ b/src/vs/platform/environment/common/argv.ts @@ -53,6 +53,7 @@ export interface NativeParsedArgs { goto?: boolean; 'new-window'?: boolean; 'reuse-window'?: boolean; + 'sessions'?: boolean; locale?: string; 'user-data-dir'?: string; 'prof-startup'?: boolean; diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts index 35a833d5f903d..6d00ad0ae0908 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -99,6 +99,7 @@ export const OPTIONS: OptionDescriptions> = { 'goto': { type: 'boolean', cat: 'o', alias: 'g', args: 'file:line[:character]', description: localize('goto', "Open a file at the path on the specified line and character position.") }, 'new-window': { type: 'boolean', cat: 'o', alias: 'n', description: localize('newWindow', "Force to open a new window.") }, 'reuse-window': { type: 'boolean', cat: 'o', alias: 'r', description: localize('reuseWindow', "Force to open a file or folder in an already opened window.") }, + 'sessions': { type: 'boolean', cat: 'o', description: localize('sessions', "Opens the sessions window.") }, 'wait': { type: 'boolean', cat: 'o', alias: 'w', description: localize('wait', "Wait for the files to be closed before returning.") }, 'waitMarkerFilePath': { type: 'string' }, 'locale': { type: 'string', cat: 'o', args: 'locale', description: localize('locale', "The locale to use (e.g. en-US or zh-TW).") }, diff --git a/src/vs/platform/environment/node/userDataPath.ts b/src/vs/platform/environment/node/userDataPath.ts index 3d0037b28c218..9c6361e341acf 100644 --- a/src/vs/platform/environment/node/userDataPath.ts +++ b/src/vs/platform/environment/node/userDataPath.ts @@ -5,7 +5,7 @@ import { homedir } from 'os'; import { NativeParsedArgs } from '../common/argv.js'; - +import { INodeProcess } from '../../../base/common/platform.js'; // This file used to be a pure JS file and was always // importing `path` from node.js even though we ship // our own version of the library and prefer to use @@ -46,7 +46,11 @@ function doGetUserDataPath(cliArgs: NativeParsedArgs, productName: string): stri // 0. Running out of sources has a fixed productName if (process.env['VSCODE_DEV']) { - productName = 'code-oss-dev'; + if ((process as INodeProcess).isEmbeddedApp) { + productName = 'sessions-oss-dev'; + } else { + productName = 'code-oss-dev'; + } } // 1. Support portable mode diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index 7369736706ca5..aa73a7c63c437 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -129,6 +129,8 @@ export interface ICommonNativeHostService { openWindow(options?: IOpenEmptyWindowOptions): Promise; openWindow(toOpen: IWindowOpenable[], options?: IOpenWindowOptions): Promise; + openSessionsWindow(): Promise; + isFullScreen(options?: INativeHostOptions): Promise; toggleFullScreen(options?: INativeHostOptions): Promise; diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index 93d000872d635..3edc2ef195d9a 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -304,6 +304,13 @@ export class NativeHostMainService extends Disposable implements INativeHostMain }, options); } + async openSessionsWindow(windowId: number | undefined): Promise { + await this.windowsMainService.openSessionsWindow({ + context: OpenContext.API, + contextWindowId: windowId, + }); + } + async isFullScreen(windowId: number | undefined, options?: INativeHostOptions): Promise { const window = this.windowById(options?.targetWindowId, windowId); return window?.isFullScreen ?? false; diff --git a/src/vs/platform/update/common/update.ts b/src/vs/platform/update/common/update.ts index 8e3db7c247bb2..7f30494da4a37 100644 --- a/src/vs/platform/update/common/update.ts +++ b/src/vs/platform/update/common/update.ts @@ -62,6 +62,7 @@ export const enum DisablementReason { MissingConfiguration, InvalidConfiguration, RunningAsAdmin, + EmbeddedApp, } export type Uninitialized = { type: StateType.Uninitialized }; diff --git a/src/vs/platform/update/electron-main/updateService.darwin.ts b/src/vs/platform/update/electron-main/updateService.darwin.ts index e65a9823839c7..a0c89233f3d4b 100644 --- a/src/vs/platform/update/electron-main/updateService.darwin.ts +++ b/src/vs/platform/update/electron-main/updateService.darwin.ts @@ -16,9 +16,10 @@ import { ILogService } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; import { asJson, IRequestService } from '../../request/common/request.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; -import { AvailableForDownload, IUpdate, State, StateType, UpdateType } from '../common/update.js'; +import { AvailableForDownload, DisablementReason, IUpdate, State, StateType, UpdateType } from '../common/update.js'; import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; import { AbstractUpdateService, createUpdateURL, getUpdateRequestHeaders, IUpdateURLOptions, UpdateErrorClassification } from './abstractUpdateService.js'; +import { INodeProcess } from '../../../base/common/platform.js'; export class DarwinUpdateService extends AbstractUpdateService implements IRelaunchHandler { @@ -67,6 +68,12 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau } protected override async initialize(): Promise { + if ((process as INodeProcess).isEmbeddedApp) { + this.setState(State.Disabled(DisablementReason.EmbeddedApp)); + this.logService.info('update#ctor - updates are disabled for embedded app'); + return; + } + await super.initialize(); this.onRawError(this.onError, this, this.disposables); this.onRawCheckingForUpdate(this.onCheckingForUpdate, this, this.disposables); diff --git a/src/vs/platform/window/common/window.ts b/src/vs/platform/window/common/window.ts index 256c55eba5089..291648bca96e3 100644 --- a/src/vs/platform/window/common/window.ts +++ b/src/vs/platform/window/common/window.ts @@ -465,6 +465,8 @@ export interface INativeWindowConfiguration extends IWindowConfiguration, Native os: IOSConfiguration; policiesData?: IStringDictionary<{ definition: PolicyDefinition; value: PolicyValue }>; + + isSessionsWindow?: boolean; } /** diff --git a/src/vs/platform/windows/electron-main/windowImpl.ts b/src/vs/platform/windows/electron-main/windowImpl.ts index 4faede85f4cb8..7455376e3f45c 100644 --- a/src/vs/platform/windows/electron-main/windowImpl.ts +++ b/src/vs/platform/windows/electron-main/windowImpl.ts @@ -1204,6 +1204,8 @@ export class CodeWindow extends BaseWindow implements ICodeWindow { let windowUrl: string; if (process.env.VSCODE_DEV && process.env.VSCODE_DEV_SERVER_URL) { windowUrl = process.env.VSCODE_DEV_SERVER_URL; // support URL override for development + } else if (configuration.isSessionsWindow) { + windowUrl = FileAccess.asBrowserUri(`vs/sessions/electron-browser/sessions${this.environmentMainService.isBuilt ? '' : '-dev'}.html`).toString(true); } else { windowUrl = FileAccess.asBrowserUri(`vs/code/electron-browser/workbench/workbench${this.environmentMainService.isBuilt ? '' : '-dev'}.html`).toString(true); } diff --git a/src/vs/platform/windows/electron-main/windows.ts b/src/vs/platform/windows/electron-main/windows.ts index 3cba9d126ff78..50b1321e83e30 100644 --- a/src/vs/platform/windows/electron-main/windows.ts +++ b/src/vs/platform/windows/electron-main/windows.ts @@ -39,9 +39,10 @@ export interface IWindowsMainService { open(openConfig: IOpenConfiguration): Promise; openEmptyWindow(openConfig: IOpenEmptyConfiguration, options?: IOpenEmptyWindowOptions): Promise; openExtensionDevelopmentHostWindow(extensionDevelopmentPath: string[], openConfig: IOpenConfiguration): Promise; - openExistingWindow(window: ICodeWindow, openConfig: IOpenConfiguration): void; + openSessionsWindow(openConfig: IBaseOpenConfiguration): Promise; + sendToFocused(channel: string, ...args: unknown[]): void; sendToOpeningWindow(channel: string, ...args: unknown[]): void; sendToAll(channel: string, payload?: unknown, windowIdsToIgnore?: number[]): void; diff --git a/src/vs/platform/windows/electron-main/windowsMainService.ts b/src/vs/platform/windows/electron-main/windowsMainService.ts index e4f9be6fdb984..9771a607efe92 100644 --- a/src/vs/platform/windows/electron-main/windowsMainService.ts +++ b/src/vs/platform/windows/electron-main/windowsMainService.ts @@ -19,7 +19,7 @@ import { basename, join, normalize, posix } from '../../../base/common/path.js'; import { getMarks, mark } from '../../../base/common/performance.js'; import { IProcessEnvironment, isMacintosh, isWindows, OS } from '../../../base/common/platform.js'; import { cwd } from '../../../base/common/process.js'; -import { extUriBiasedIgnorePathCase, isEqualAuthority, normalizePath, originalFSPath, removeTrailingPathSeparator } from '../../../base/common/resources.js'; +import { extUriBiasedIgnorePathCase, isEqual, isEqualAuthority, normalizePath, originalFSPath, removeTrailingPathSeparator } from '../../../base/common/resources.js'; import { assertReturnsDefined } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { getNLSLanguage, getNLSMessages, localize } from '../../../nls.js'; @@ -39,7 +39,7 @@ import { getRemoteAuthority } from '../../remote/common/remoteHosts.js'; import { IStateService } from '../../state/node/state.js'; import { IAddRemoveFoldersRequest, INativeOpenFileRequest, INativeWindowConfiguration, IOpenEmptyWindowOptions, IPath, IPathsToWaitFor, isFileToOpen, isFolderToOpen, isWorkspaceToOpen, IWindowOpenable, IWindowSettings } from '../../window/common/window.js'; import { CodeWindow } from './windowImpl.js'; -import { IOpenConfiguration, IOpenEmptyConfiguration, IWindowsCountChangedEvent, IWindowsMainService, OpenContext, getLastFocused } from './windows.js'; +import { IBaseOpenConfiguration, IOpenConfiguration, IOpenEmptyConfiguration, IWindowsCountChangedEvent, IWindowsMainService, OpenContext, getLastFocused } from './windows.js'; import { findWindowOnExtensionDevelopmentPath, findWindowOnFile, findWindowOnWorkspaceOrFolder } from './windowsFinder.js'; import { IWindowState, WindowsStateHandler } from './windowsStateHandler.js'; import { IRecent } from '../../workspaces/common/workspaces.js'; @@ -58,6 +58,7 @@ import { IAuxiliaryWindowsMainService } from '../../auxiliaryWindow/electron-mai import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js'; import { ICSSDevelopmentService } from '../../cssDev/node/cssDevService.js'; import { ResourceSet } from '../../../base/common/map.js'; +import { VSBuffer } from '../../../base/common/buffer.js'; //#region Helper Interfaces @@ -291,6 +292,31 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic this.handleChatRequest(openConfig, [window]); } + async openSessionsWindow(openConfig: IBaseOpenConfiguration): Promise { + this.logService.trace('windowsManager#openSessionsWindow'); + + const agentSessionsWorkspaceUri = this.environmentMainService.agentSessionsWorkspace; + if (!agentSessionsWorkspaceUri) { + throw new Error('Sessions workspace is not configured'); + } + + // Ensure the workspace file exists + const workspaceExists = await this.fileService.exists(agentSessionsWorkspaceUri); + if (!workspaceExists) { + const emptyWorkspaceContent = JSON.stringify({ folders: [] }, null, '\t'); + await this.fileService.writeFile(agentSessionsWorkspaceUri, VSBuffer.fromString(emptyWorkspaceContent)); + } + + // Open in a new browser window with the agent sessions workspace + return this.open({ + ...openConfig, + urisToOpen: [{ workspaceUri: agentSessionsWorkspaceUri }], + cli: this.environmentMainService.args, + forceNewWindow: true, + noRecentEntry: true, + }); + } + async open(openConfig: IOpenConfiguration): Promise { this.logService.trace('windowsManager#open'); @@ -1541,7 +1567,9 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic policiesData: this.policyService.serialize(), continueOn: this.environmentMainService.continueOn, - cssModules: this.cssDevelopmentService.isEnabled ? await this.cssDevelopmentService.getCssModules() : undefined + cssModules: this.cssDevelopmentService.isEnabled ? await this.cssDevelopmentService.getCssModules() : undefined, + + isSessionsWindow: isWorkspaceIdentifier(options.workspace) && isEqual(options.workspace.configPath, this.environmentMainService.agentSessionsWorkspace), }; // New window diff --git a/src/vs/platform/workspace/common/workspace.ts b/src/vs/platform/workspace/common/workspace.ts index c45fcaa00c0fa..a0459d077e6ea 100644 --- a/src/vs/platform/workspace/common/workspace.ts +++ b/src/vs/platform/workspace/common/workspace.ts @@ -282,10 +282,6 @@ export interface IWorkspace { */ readonly configuration?: URI | null; - /** - * Whether this workspace is an agent sessions workspace. - */ - readonly isAgentSessionsWorkspace?: boolean; } export function isWorkspace(thing: unknown): thing is IWorkspace { @@ -349,7 +345,6 @@ export class Workspace implements IWorkspace { private _transient: boolean, private _configuration: URI | null, private ignorePathCasing: (key: URI) => boolean, - private _isAgentSessionsWorkspace?: boolean, ) { this.foldersMap = TernarySearchTree.forUris(this.ignorePathCasing, () => true); this.folders = folders; @@ -360,7 +355,6 @@ export class Workspace implements IWorkspace { this._configuration = workspace.configuration; this._transient = workspace.transient; this.ignorePathCasing = workspace.ignorePathCasing; - this._isAgentSessionsWorkspace = workspace.isAgentSessionsWorkspace; this.folders = workspace.folders; } @@ -380,10 +374,6 @@ export class Workspace implements IWorkspace { this._configuration = configuration; } - get isAgentSessionsWorkspace(): boolean | undefined { - return this._isAgentSessionsWorkspace; - } - getFolder(resource: URI): IWorkspaceFolder | null { if (!resource) { return null; @@ -400,7 +390,7 @@ export class Workspace implements IWorkspace { } toJSON(): IWorkspace { - return { id: this.id, folders: this.folders, transient: this.transient, configuration: this.configuration, isAgentSessionsWorkspace: this.isAgentSessionsWorkspace }; + return { id: this.id, folders: this.folders, transient: this.transient, configuration: this.configuration }; } } diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md new file mode 100644 index 0000000000000..9dceeac3a2b24 --- /dev/null +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -0,0 +1,94 @@ +# AI Customizations – Design Document + +This document describes the current AI customization experience in this branch: a management editor and tree view that surface items across worktree, user, and extension storage. + +## Current Architecture + +### File Structure (Agentic) + +``` +src/vs/sessions/contrib/aiCustomizationManagement/browser/ +├── aiCustomizationManagement.contribution.ts # Commands + context menus +├── aiCustomizationManagement.ts # IDs + context keys +├── aiCustomizationManagementEditor.ts # SplitView list/editor +├── aiCustomizationManagementEditorInput.ts # Singleton input +├── aiCustomizationListWidget.ts # Search + grouped list +├── aiCustomizationOverviewView.ts # Overview view (counts + deep links) +├── customizationCreatorService.ts # AI-guided creation flow +├── mcpListWidget.ts # MCP servers section +├── SPEC.md # Feature specification +└── media/ + └── aiCustomizationManagement.css + +src/vs/sessions/contrib/aiCustomizationTreeView/browser/ +├── aiCustomizationTreeView.contribution.ts # View + actions +├── aiCustomizationTreeView.ts # IDs + menu IDs +├── aiCustomizationTreeViewViews.ts # Tree data source + view +├── aiCustomizationTreeViewIcons.ts # Icons +├── SPEC.md # Feature specification +└── media/ + └── aiCustomizationTreeView.css +``` + +--- + +## Service Alignment (Required) + +AI customizations must lean on existing VS Code services with well-defined interfaces. This avoids duplicated parsing logic, keeps discovery consistent across the workbench, and ensures prompt/hook behavior stays authoritative. + +Browser compatibility is required. Do not use Node.js APIs; rely on VS Code services that work in browser contexts. + +Key services to rely on: +- Prompt discovery, parsing, and lifecycle: [src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts](../workbench/contrib/chat/common/promptSyntax/service/promptsService.ts) +- Active session scoping for worktree filtering: [src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsService.ts](../workbench/contrib/chat/browser/agentSessions/agentSessionsService.ts) +- MCP servers and tool access: [src/vs/workbench/contrib/mcp/common/mcpService.ts](../workbench/contrib/mcp/common/mcpService.ts) +- MCP management and gallery: [src/vs/platform/mcp/common/mcpManagement.ts](../platform/mcp/common/mcpManagement.ts) +- Chat models and session state: [src/vs/workbench/contrib/chat/common/chatService/chatService.ts](../workbench/contrib/chat/common/chatService/chatService.ts) +- File and model plumbing: [src/vs/platform/files/common/files.ts](../platform/files/common/files.ts), [src/vs/editor/common/services/resolverService.ts](../editor/common/services/resolverService.ts) + +The active worktree comes from `IActiveSessionService` and is the source of truth for any workspace/worktree scoping. + +In the agentic workbench, prompt discovery is scoped by an agentic prompt service override that uses the active session root for workspace folders. See [src/vs/sessions/contrib/chat/browser/promptsService.ts](contrib/chat/browser/promptsService.ts). + +## Implemented Experience + +### Management Editor (Current) + +- A singleton editor surfaces Agents, Skills, Instructions, Prompts, Hooks, MCP Servers, and Models. +- Prompts-based sections use a grouped list (Worktree/User/Extensions) with search, context menus, and an embedded editor. +- Embedded editor uses a full `CodeEditorWidget` and auto-commits worktree files on exit (agent session workflow). +- Creation supports manual or AI-guided flows; AI-guided creation opens a new chat with hidden system instructions. + +### Tree View (Current) + +- Unified sidebar tree with Type -> Storage -> File hierarchy. +- Auto-expands categories to reveal storage groups. +- Context menus provide Open and Run Prompt. +- Creation actions are centralized in the management editor. + +### Additional Surfaces (Current) + +- Overview view provides counts and deep-links into the management editor. +- Management list groups by storage with empty states, git status, and path copy actions. + +--- + +## AI Feature Gating + +All commands and UI must respect `ChatContextKeys.enabled`: + +```typescript +All entry points (view contributions, commands) respect `ChatContextKeys.enabled`. +``` + +--- + +## References + +- [Settings Editor](../src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts) +- [Keybindings Editor](../src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts) +- [Webview Editor](../src/vs/workbench/contrib/webviewPanel/browser/webviewEditorInput.ts) +- [AI Customization Management (agentic)](../src/vs/sessions/contrib/aiCustomizationManagement/browser/) +- [AI Customization Overview View](../src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationOverviewView.ts) +- [AI Customization Tree View (agentic)](../src/vs/sessions/contrib/aiCustomizationTreeView/browser/) +- [IPromptsService](../src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts) diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md new file mode 100644 index 0000000000000..17ff3fee470a1 --- /dev/null +++ b/src/vs/sessions/LAYOUT.md @@ -0,0 +1,740 @@ +# Agent Sessions Workbench Layout Specification + +This document is the **authoritative specification** for the Agent Sessions workbench layout. All implementation changes must be reflected here, and all development work should reference this document. + +--- + +## 1. Overview + +The Agent Sessions Workbench (`Workbench` in `sessions/browser/workbench.ts`) provides a simplified, fixed layout optimized for agent session workflows. Unlike the default VS Code workbench, this layout: + +- Does **not** support settings-based customization +- Has **fixed** part positions +- Excludes several standard workbench parts + +--- + +## 2. Layout Structure + +### 2.1 Visual Representation + +``` +┌─────────┬───────────────────────────────────────────────────────┐ +│ │ Titlebar │ +│ ├────────────────────────────────────┬──────────────────┤ +│ Sidebar │ Chat Bar │ Auxiliary Bar │ +│ ├────────────────────────────────────┴──────────────────┤ +│ │ Panel │ +└─────────┴───────────────────────────────────────────────────────┘ + + ┌───────────────────────────────────────┐ + │ ╔═══════════════════════════╗ │ + │ ║ Editor Modal Overlay ║ │ + │ ║ ┌─────────────────────┐ ║ │ + │ ║ │ [header] [X] │ ║ │ + │ ║ ├─────────────────────┤ ║ │ + │ ║ │ │ ║ │ + │ ║ │ Editor Part │ ║ │ + │ ║ │ │ ║ │ + │ ║ │ │ ║ │ + │ ║ └─────────────────────┘ ║ │ + │ ╚═══════════════════════════╝ │ + └───────────────────────────────────────┘ + (shown when editors are open) +``` + +### 2.2 Parts + +#### Included Parts + +| Part | ID Constant | Position | Default Visibility | ViewContainerLocation | +|------|-------------|----------|------------|----------------------| +| Titlebar | `Parts.TITLEBAR_PART` | Top of right section | Always visible | — | +| Sidebar | `Parts.SIDEBAR_PART` | Left, spans full height from top to bottom | Visible | `ViewContainerLocation.Sidebar` | +| Chat Bar | `Parts.CHATBAR_PART` | Top-right section, takes remaining width | Visible | `ViewContainerLocation.ChatBar` | +| Editor | `Parts.EDITOR_PART` | **Modal overlay** (not in grid) | Hidden | — | +| Auxiliary Bar | `Parts.AUXILIARYBAR_PART` | Top-right section, right side | Visible | `ViewContainerLocation.AuxiliaryBar` | +| Panel | `Parts.PANEL_PART` | Below Chat Bar and Auxiliary Bar (right section only) | Hidden | `ViewContainerLocation.Panel` | + +#### Excluded Parts + +The following parts from the default workbench are **not included**: + +| Part | ID Constant | Reason | +|------|-------------|--------| +| Activity Bar | `Parts.ACTIVITYBAR_PART` | Simplified navigation; global activities (Accounts, Manage) are in titlebar instead | +| Status Bar | `Parts.STATUSBAR_PART` | Reduced chrome | +| Banner | `Parts.BANNER_PART` | Not needed | + +--- + +## 3. Titlebar Configuration + +The Agent Sessions workbench uses a fully independent titlebar part (`TitlebarPart`) with its own title service (`TitleService`), implemented in `sessions/browser/parts/titlebarPart.ts`. This is a standalone implementation (not extending `BrowserTitlebarPart`) with a simple three-section layout driven entirely by menus. + +### 3.1 Titlebar Part Architecture + +The titlebar is divided into three sections, each rendered by a `MenuWorkbenchToolBar`: + +| Section | Menu ID | Purpose | +|---------|---------|--------| +| Left | `Menus.TitleBarLeft` | Toggle sidebar and other left-aligned actions | +| Center | `Menus.CommandCenter` | Session picker widget (rendered via `IActionViewItemService`) | +| Right | `Menus.TitleBarRight` | Run script split button, open submenu, toggle secondary sidebar | + +No menubar, no editor actions, no layout controls, no `WindowTitle` dependency. + +### 3.2 Command Center + +The Agent Sessions titlebar includes a command center with a custom title bar widget (`SessionsTitleBarWidget`). It uses custom menu IDs separate from the default workbench command center to avoid conflicts: + +- **`Menus.CommandCenter`** — The center toolbar menu (replaces `MenuId.CommandCenter`) +- **`Menus.TitleBarControlMenu`** — A submenu registered in the command center whose rendering is intercepted by `IActionViewItemService` to display the custom widget + +The widget: +- Extends `BaseActionViewItem` and renders a clickable label showing the active session title +- Shows kind icon (provider type icon), session title, repository folder name, and changes summary (+insertions -deletions) +- On click, opens the `AgentSessionsPicker` quick pick to switch between sessions +- Gets the active session label from `IActiveSessionService.getActiveSession()` and the live model title from `IChatService`, falling back to "New Session" if no active session is found +- Re-renders automatically when the active session changes via `autorun` on `IActiveSessionService.activeSession`, and when session data changes via `IAgentSessionsService.model.onDidChangeSessions` +- Is registered via `SessionsTitleBarContribution` (an `IWorkbenchContribution` in `contrib/sessions/browser/sessionsTitleBarWidget.ts`) that calls `IActionViewItemService.register()` to intercept the submenu rendering + +### 3.3 Left Toolbar + +The Agent Sessions titlebar includes a custom left toolbar that appears after the app icon. This toolbar: + +- Uses `Menus.TitleBarLeft` for its actions +- Uses `HiddenItemStrategy.NoHide` so actions cannot be hidden by users +- Displays actions registered to `Menus.TitleBarLeft` + +### 3.4 Titlebar Actions + +| Action | ID | Location | Behavior | +|--------|-----|----------|----------| +| Toggle Sidebar | `workbench.action.agentToggleSidebarVisibility` | Left toolbar (`TitleBarLeft`) | Toggles primary sidebar visibility | +| Run Script | `workbench.action.agentSessions.runScript` | Right toolbar (`TitleBarRight`) | Split button: runs configured script or shows configure dialog | +| Open... | (submenu) | Right toolbar (`TitleBarRight`) | Split button submenu: Open Terminal, Open in VS Code | +| Toggle Secondary Sidebar | `workbench.action.agentToggleSecondarySidebarVisibility` | Right toolbar (`TitleBarRight`) | Toggles auxiliary bar visibility | + +The toggle sidebar action: +- Shows `layoutSidebarLeft` icon when sidebar is visible +- Shows `layoutSidebarLeftOff` icon when sidebar is hidden +- Bound to `Ctrl+B` / `Cmd+B` keybinding +- Announces visibility changes to screen readers + +The Run Script action: +- Displayed as a split button via `RunScriptDropdownMenuId` submenu on `Menus.TitleBarRight` +- Primary action runs the configured script command in a terminal +- Dropdown includes "Configure Run Action..." to set/change the script +- Registered in `contrib/chat/browser/runScriptAction.ts` + +The Open... action: +- Displayed as a split button via `Menus.OpenSubMenu` on `Menus.TitleBarRight` +- Contains "Open Terminal" (opens terminal at session worktree) and "Open in VS Code" (opens worktree in new VS Code window) +- Registered in `contrib/chat/browser/chat.contribution.ts` + +### 3.5 Panel Title Actions + +The panel title bar includes actions for controlling the panel: + +| Action | ID | Icon | Order | Behavior | +|--------|-----|------|-------|----------| +| Hide Panel | `workbench.action.agentTogglePanelVisibility` | `close` | 2 | Hides the panel | + +### 3.6 Account Widget + +The account widget has been moved from the titlebar to the **sidebar footer**. It is rendered as a custom `AccountWidget` action view item: + +- Registered in `contrib/accountMenu/browser/account.contribution.ts` +- Uses the `Menus.SidebarFooter` menu +- Shows account button with sign-in/sign-out and an update button when an update is available +- Account menu shows signed-in user label from `IDefaultAccountService` (or Sign In), Sign Out, Settings, and Check for Updates + +--- + +## 4. Grid Structure + +The layout uses `SerializableGrid` from `vs/base/browser/ui/grid/grid.js`. + +### 4.1 Grid Tree + +The Editor part is **not** in the grid — it is rendered as a modal overlay (see Section 4.3). + +``` +Orientation: HORIZONTAL (root) +├── Sidebar (leaf, size: 300px default) +└── Right Section (branch, VERTICAL, size: remaining width) + ├── Titlebar (leaf, size: titleBarHeight) + ├── Top Right (branch, HORIZONTAL, size: remaining height - panel) + │ ├── Chat Bar (leaf, size: remaining width) + │ └── Auxiliary Bar (leaf, size: 300px default) + └── Panel (leaf, size: 300px default, hidden by default) +``` + +This structure places the sidebar at the root level spanning the full window height. The titlebar, chat bar, auxiliary bar, and panel are all within the right section. + +### 4.2 Default Sizes + +| Part | Default Size | +|------|--------------| +| Sidebar | 300px width | +| Auxiliary Bar | 300px width | +| Chat Bar | Remaining space | +| Editor Modal | 80% of workbench (min 400x300, max 1200x900), calculated in TypeScript | +| Panel | 300px height | +| Titlebar | Determined by `minimumHeight` (~30px) | + +### 4.3 Editor Modal + +The Editor part is rendered as a **modal overlay** rather than being part of the grid. This provides a focused editing experience that hovers above the main workbench layout. + +#### Modal Structure + +``` +EditorModal +├── Overlay (semi-transparent backdrop) +├── Container (centered dialog) +│ ├── Header (32px, contains close button) +│ └── Content (editor part fills remaining space) +``` + +#### Behavior + +| Trigger | Action | +|---------|--------| +| Editor opens (`onWillOpenEditor`) | Modal shows automatically | +| All editors close | Modal hides automatically | +| Click backdrop | Close all editors, hide modal | +| Click close button (X) | Close all editors, hide modal | +| Press Escape key | Close all editors, hide modal | + +#### Modal Sizing + +Modal dimensions are calculated in TypeScript rather than CSS. The `EditorModal.layout()` method receives workbench dimensions and computes the modal size with constraints: + +| Property | Value | Constant | +|----------|-------|----------| +| Size Percentage | 80% of workbench | `MODAL_SIZE_PERCENTAGE = 0.8` | +| Max Width | 1200px | `MODAL_MAX_WIDTH = 1200` | +| Max Height | 900px | `MODAL_MAX_HEIGHT = 900` | +| Min Width | 400px | `MODAL_MIN_WIDTH = 400` | +| Min Height | 300px | `MODAL_MIN_HEIGHT = 300` | +| Header Height | 32px | `MODAL_HEADER_HEIGHT = 32` | + +The calculation: +```typescript +modalWidth = min(MODAL_MAX_WIDTH, max(MODAL_MIN_WIDTH, workbenchWidth * MODAL_SIZE_PERCENTAGE)) +modalHeight = min(MODAL_MAX_HEIGHT, max(MODAL_MIN_HEIGHT, workbenchHeight * MODAL_SIZE_PERCENTAGE)) +contentHeight = modalHeight - MODAL_HEADER_HEIGHT +``` + +#### CSS Classes + +| Class | Applied To | Notes | +|-------|------------|-------| +| `editor-modal-overlay` | Overlay container | Positioned absolute, full size | +| `editor-modal-overlay.visible` | When modal is shown | Enables pointer events | +| `editor-modal-backdrop` | Semi-transparent backdrop | Clicking closes modal | +| `editor-modal-container` | Centered modal dialog | Width/height set in TypeScript | +| `editor-modal-header` | Header with close button | Fixed 32px height | +| `editor-modal-content` | Editor content area | Width/height set in TypeScript | +| `editor-modal-visible` | Added to `mainContainer` when modal is visible | — | + +#### Implementation + +The modal is implemented in `EditorModal` class (`parts/editorModal.ts`): + +```typescript +class EditorModal extends Disposable { + // Events + readonly onDidChangeVisibility: Event; + + // State + get visible(): boolean; + + // Methods + show(): void; // Show modal using stored dimensions + hide(): void; // Hide modal + close(): void; // Close all editors, then hide + layout(workbenchWidth: number, workbenchHeight: number): void; // Store dimensions, re-layout if visible +} +``` + +The `Workbench.layout()` passes the workbench dimensions to `EditorModal.layout()`, which calculates and applies the modal size with min/max constraints. Dimensions are stored so that `show()` can use them when the modal becomes visible. + +--- + +## 5. Feature Support Matrix + +| Feature | Default Workbench | Agent Sessions | Notes | +|---------|-------------------|----------------|-------| +| Activity Bar | ✅ Configurable | ❌ Not included | — | +| Status Bar | ✅ Configurable | ❌ Not included | — | +| Sidebar Position | ✅ Left/Right | 🔒 Fixed: Left | `getSideBarPosition()` returns `Position.LEFT` | +| Panel Position | ✅ Top/Bottom/Left/Right | 🔒 Fixed: Bottom | `getPanelPosition()` returns `Position.BOTTOM` | +| Panel Alignment | ✅ Left/Center/Right/Justify | 🔒 Fixed: Justify | `getPanelAlignment()` returns `'justify'` | +| Maximize Panel | ✅ Supported | ✅ Supported | Excludes titlebar when maximizing | +| Maximize Auxiliary Bar | ✅ Supported | ❌ No-op | `toggleMaximizedAuxiliaryBar()` does nothing | +| Zen Mode | ✅ Supported | ❌ No-op | `toggleZenMode()` does nothing | +| Centered Editor Layout | ✅ Supported | ❌ No-op | `centerMainEditorLayout()` does nothing | +| Menu Bar Toggle | ✅ Supported | ❌ No-op | `toggleMenuBar()` does nothing | +| Resize Parts | ✅ Supported | ✅ Supported | Via grid or programmatic API | +| Hide/Show Parts | ✅ Supported | ✅ Supported | Via `setPartHidden()` | +| Window Maximized State | ✅ Supported | ✅ Supported | Tracked per window ID | +| Fullscreen | ✅ Supported | ✅ Supported | CSS class applied | + +--- + +## 6. API Reference + +### 6.1 Part Visibility + +```typescript +// Check if a part is visible +isVisible(part: Parts): boolean + +// Show or hide a part +setPartHidden(hidden: boolean, part: Parts): void +``` + +**Behavior:** +- Hiding a part also hides its active pane composite +- Showing a part restores the last active pane composite +- **Panel Part:** + - If the panel is maximized when hiding, it exits maximized state first +- **Editor Part Auto-Visibility:** + - Automatically shows when an editor is about to open (`onWillOpenEditor`) + - Automatically hides when the last editor closes (`onDidCloseEditor` + all groups empty) + +### 6.2 Part Sizing + +```typescript +// Get current size of a part +getSize(part: Parts): IViewSize + +// Set absolute size of a part +setSize(part: Parts, size: IViewSize): void + +// Resize by delta values +resizePart(part: Parts, sizeChangeWidth: number, sizeChangeHeight: number): void +``` + +### 6.3 Focus Management + +```typescript +// Focus a specific part +focusPart(part: Parts): void + +// Check if a part has focus +hasFocus(part: Parts): boolean + +// Focus the Chat Bar (default focus target) +focus(): void +``` + +### 6.4 Container Access + +```typescript +// Get the main container or active container +get mainContainer(): HTMLElement +get activeContainer(): HTMLElement + +// Get container for a specific part +getContainer(targetWindow: Window, part?: Parts): HTMLElement | undefined +``` + +### 6.5 Layout Offset + +```typescript +// Get offset info for positioning elements +get mainContainerOffset(): ILayoutOffsetInfo +get activeContainerOffset(): ILayoutOffsetInfo +``` + +Returns `{ top, quickPickTop }` where `top` is the titlebar height. + +--- + +## 7. Events + +| Event | Fired When | +|-------|------------| +| `onDidChangePartVisibility` | Any part visibility changes | +| `onDidLayoutMainContainer` | Main container is laid out | +| `onDidLayoutActiveContainer` | Active container is laid out | +| `onDidLayoutContainer` | Any container is laid out | +| `onDidChangeWindowMaximized` | Window maximized state changes | +| `onDidChangeNotificationsVisibility` | Notification visibility changes | +| `onWillShutdown` | Workbench is about to shut down | +| `onDidShutdown` | Workbench has shut down | + +**Events that never fire** (unsupported features): +- `onDidChangeZenMode` +- `onDidChangeMainEditorCenteredLayout` +- `onDidChangePanelAlignment` +- `onDidChangePanelPosition` +- `onDidChangeAuxiliaryBarMaximized` + +--- + +## 8. CSS Classes + +### 8.1 Visibility Classes + +Applied to `mainContainer` based on part visibility: + +| Class | Applied When | +|-------|--------------| +| `nosidebar` | Sidebar is hidden | +| `nomaineditorarea` | Editor modal is hidden | +| `noauxiliarybar` | Auxiliary bar is hidden | +| `nochatbar` | Chat bar is hidden | +| `nopanel` | Panel is hidden | +| `editor-modal-visible` | Editor modal is visible | + +### 8.2 Window State Classes + +| Class | Applied When | +|-------|--------------| +| `fullscreen` | Window is in fullscreen mode | +| `maximized` | Window is maximized | + +### 8.3 Platform Classes + +Applied during workbench render: +- `monaco-workbench` +- `agent-sessions-workbench` +- `windows` / `linux` / `mac` +- `web` (if running in browser) +- `chromium` / `firefox` / `safari` + +--- + +## 9. Agent Session Parts + +The Agent Sessions workbench uses specialized part implementations that extend the base pane composite infrastructure but are simplified for agent session contexts. + +### 9.1 Part Classes + +| Part | Class | Extends | Location | +|------|-------|---------|----------| +| Sidebar | `SidebarPart` | `AbstractPaneCompositePart` | `sessions/browser/parts/sidebarPart.ts` | +| Auxiliary Bar | `AuxiliaryBarPart` | `AbstractPaneCompositePart` | `sessions/browser/parts/auxiliaryBarPart.ts` | +| Panel | `PanelPart` | `AbstractPaneCompositePart` | `sessions/browser/parts/panelPart.ts` | +| Chat Bar | `ChatBarPart` | `AbstractPaneCompositePart` | `sessions/browser/parts/chatBarPart.ts` | +| Titlebar | `TitlebarPart` / `MainTitlebarPart` | `Part` | `sessions/browser/parts/titlebarPart.ts` | +| Project Bar | `ProjectBarPart` | `Part` | `sessions/browser/parts/projectBarPart.ts` | +| Editor Modal | `EditorModal` | `Disposable` | `sessions/browser/parts/editorModal.ts` | + +### 9.2 Key Differences from Standard Parts + +| Feature | Standard Parts | Agent Session Parts | +|---------|----------------|---------------------| +| Activity Bar integration | Full support | No activity bar; account widget in sidebar footer | +| Composite bar position | Configurable (top/bottom/title/hidden) | Fixed: Title | +| Composite bar visibility | Configurable | Sidebar: hidden (`shouldShowCompositeBar()` returns `false`); ChatBar: hidden; Auxiliary Bar & Panel: visible | +| Auto-hide support | Configurable | Disabled | +| Configuration listening | Many settings | Minimal | +| Context menu actions | Full set | Simplified | +| Title bar | Full support | Sidebar: `hasTitle: true` (with footer); ChatBar: `hasTitle: false`; Auxiliary Bar & Panel: `hasTitle: true` | +| Visual margins | None | Auxiliary Bar: 8px top/bottom/right (card appearance); Panel: 8px bottom/left/right (card appearance); Sidebar: 0 (flush) | + +### 9.3 Part Creation + +The agent sessions pane composite parts are created and registered via the `AgenticPaneCompositePartService` in `sessions/browser/paneCompositePartService.ts`. This service is registered as a singleton for `IPaneCompositePartService` and directly instantiates each part: + +```typescript +// In AgenticPaneCompositePartService constructor +this.registerPart(ViewContainerLocation.Panel, instantiationService.createInstance(PanelPart)); +this.registerPart(ViewContainerLocation.Sidebar, instantiationService.createInstance(SidebarPart)); +this.registerPart(ViewContainerLocation.AuxiliaryBar, instantiationService.createInstance(AuxiliaryBarPart)); +this.registerPart(ViewContainerLocation.ChatBar, instantiationService.createInstance(ChatBarPart)); +``` + +This architecture ensures that: +1. The agent sessions workbench uses its own part implementations rather than the standard workbench parts +2. Each part is instantiated eagerly in the constructor, as the service delegates all operations to the appropriate part by `ViewContainerLocation` + +### 9.4 Storage Keys + +Each agent session part uses separate storage keys to avoid conflicts with regular workbench state: + +| Part | Setting | Storage Key | +|------|---------|-------------| +| Sidebar | Active viewlet | `workbench.agentsession.sidebar.activeviewletid` | +| Sidebar | Pinned viewlets | `workbench.agentsession.pinnedViewlets2` | +| Sidebar | Placeholders | `workbench.agentsession.placeholderViewlets` | +| Sidebar | Workspace state | `workbench.agentsession.viewletsWorkspaceState` | +| Auxiliary Bar | Active panel | `workbench.agentsession.auxiliarybar.activepanelid` | +| Auxiliary Bar | Pinned views | `workbench.agentsession.auxiliarybar.pinnedPanels` | +| Auxiliary Bar | Placeholders | `workbench.agentsession.auxiliarybar.placeholderPanels` | +| Auxiliary Bar | Workspace state | `workbench.agentsession.auxiliarybar.viewContainersWorkspaceState` | +| Panel | Active panel | `workbench.agentsession.panelpart.activepanelid` | +| Panel | Pinned panels | `workbench.agentsession.panel.pinnedPanels` | +| Panel | Placeholders | `workbench.agentsession.panel.placeholderPanels` | +| Panel | Workspace state | `workbench.agentsession.panel.viewContainersWorkspaceState` | +| Chat Bar | Active panel | `workbench.chatbar.activepanelid` | +| Chat Bar | Pinned panels | `workbench.chatbar.pinnedPanels` | +| Chat Bar | Placeholders | `workbench.chatbar.placeholderPanels` | +| Chat Bar | Workspace state | `workbench.chatbar.viewContainersWorkspaceState` | + +### 9.5 Part Borders and Card Appearance + +Parts manage their own border and background styling via the `updateStyles()` method. The auxiliary bar and panel use a **card appearance** with CSS variables for background and border: + +| Part | Styling | Notes | +|------|---------|-------| +| Sidebar | Right border via `SIDE_BAR_BORDER` / `contrastBorder` | Flush appearance, no card styling | +| Chat Bar | Background only, no borders | `borderWidth` returns `0` | +| Auxiliary Bar | Card appearance via CSS variables `--part-background` / `--part-border-color` | Uses `SIDE_BAR_BACKGROUND` / `SIDE_BAR_BORDER`; transparent background on container; margins create card offset | +| Panel | Card appearance via CSS variables `--part-background` / `--part-border-color` | Uses `PANEL_BACKGROUND` / `PANEL_BORDER`; transparent background on container; margins create card offset | + +--- + +### 9.6 Auxiliary Bar Run Script Dropdown + +The `AuxiliaryBarPart` provides a custom `DropdownWithPrimaryActionViewItem` for the run script action (`workbench.action.agentSessions.runScript`). This is rendered as a split button with: + +- **Primary action**: Runs the main script action +- **Dropdown**: Shows additional actions from the `AgentSessionsRunScriptDropdown` menu +- The dropdown menu is created from `MenuId.for('AgentSessionsRunScriptDropdown')` and updates dynamically when menu items change + +### 9.7 Sidebar Footer + +The `SidebarPart` includes a footer section (35px height) positioned below the pane composite content. The sidebar uses a custom `layout()` override that reduces the content height by `FOOTER_HEIGHT` and renders a `MenuWorkbenchToolBar` driven by `Menus.SidebarFooter`. The footer hosts the account widget (see Section 3.6). + +On macOS native, the sidebar title area includes a traffic light spacer (70px) to push content past the system window controls, which is hidden in fullscreen mode. + +--- + +## 10. Workbench Contributions + +The Agent Sessions workbench registers contributions via module imports in `sessions.desktop.main.ts` (and `sessions.common.main.ts`). Key contributions: + +| Contribution | Class | Phase | Location | +|-------------|-------|-------|----------| +| Run Script | `RunScriptContribution` | `AfterRestored` | `contrib/chat/browser/runScriptAction.ts` | +| Title Bar Widget | `SessionsTitleBarContribution` | `AfterRestored` | `contrib/sessions/browser/sessionsTitleBarWidget.ts` | +| Account Widget | `AccountWidgetContribution` | `AfterRestored` | `contrib/accountMenu/browser/account.contribution.ts` | +| Active Session Service | `ActiveSessionService` | Singleton | `contrib/sessions/browser/activeSessionService.ts` | +| Prompts Service | `AgenticPromptsService` | Singleton | `contrib/chat/browser/promptsService.ts` | + +Additionally, `BranchChatSessionAction` is registered in `contrib/chat/browser/chat.contribution.ts`. + +### 10.1 Changes View + +The Changes view is registered in `contrib/changesView/browser/changesView.contribution.ts`: + +- **Container**: `CHANGES_VIEW_CONTAINER_ID` in `ViewContainerLocation.AuxiliaryBar` (default, hidden if empty) +- **View**: `CHANGES_VIEW_ID` with `ChangesViewPane` +- **Window visibility**: `WindowVisibility.Sessions` (only visible in agent sessions workbench) + +### 10.2 Sessions View + +The Sessions view is registered in `contrib/sessions/browser/sessions.contribution.ts`: + +- **Container**: Sessions container in `ViewContainerLocation.Sidebar` (default) +- **View**: `SessionsViewId` with `AgenticSessionsViewPane` +- **Window visibility**: `WindowVisibility.Sessions` + +--- + +## 11. File Structure + +``` +src/vs/sessions/ +├── README.md # Layer specification +├── LAYOUT.md # This specification +├── AI_CUSTOMIZATIONS.md # AI customization design document +├── sessions.common.main.ts # Common entry point (browser + desktop) +├── sessions.desktop.main.ts # Desktop entry point (imports all contributions) +├── common/ +│ └── contextkeys.ts # ChatBar context keys +├── browser/ # Core workbench implementation +│ ├── workbench.ts # Main layout implementation (Workbench class) +│ ├── menus.ts # Agent sessions menu IDs (Menus export) +│ ├── layoutActions.ts # Layout actions (toggle sidebar, secondary sidebar, panel) +│ ├── paneCompositePartService.ts # AgenticPaneCompositePartService +│ ├── style.css # Layout-specific styles (including editor modal) +│ ├── widget/ # Agent sessions chat widget +│ │ ├── AGENTS_CHAT_WIDGET.md # Chat widget architecture documentation +│ │ ├── agentSessionsChatWidget.ts # Main chat widget wrapper +│ │ ├── agentSessionsChatTargetConfig.ts # Target configuration (observable) +│ │ ├── agentSessionsTargetPickerActionItem.ts # Target picker for input toolbar +│ │ └── media/ +│ │ └── agentSessionsChatWidget.css +│ └── parts/ +│ ├── titlebarPart.ts # Simplified titlebar part, MainTitlebarPart, AuxiliaryTitlebarPart, and TitleService +│ ├── sidebarPart.ts # Agent session sidebar (with footer and macOS traffic light spacer) +│ ├── auxiliaryBarPart.ts # Agent session auxiliary bar (with run script dropdown) +│ ├── panelPart.ts # Agent session panel +│ ├── chatBarPart.ts # Chat Bar part implementation +│ ├── projectBarPart.ts # Project bar part (folder entries, icon customization) +│ ├── editorModal.ts # Editor modal overlay implementation +│ ├── parts.ts # AgenticParts enum +│ ├── agentSessionsChatInputPart.ts # Chat input part adapter +│ ├── agentSessionsChatWelcomePart.ts # Chat welcome part +│ └── media/ +│ ├── titlebarpart.css +│ ├── sidebarPart.css +│ ├── chatBarPart.css +│ ├── projectBarPart.css +│ └── agentSessionsChatWelcomePart.css +├── electron-browser/ # Desktop-specific entry points +│ ├── sessions.main.ts +│ ├── sessions.ts +│ ├── sessions.html +│ └── sessions-dev.html +├── contrib/ # Feature contributions +│ ├── accountMenu/browser/ # Account menu widget for sidebar footer +│ │ ├── account.contribution.ts +│ │ └── media/ +│ ├── aiCustomizationManagement/browser/ # AI customization management editor +│ ├── aiCustomizationTreeView/browser/ # AI customization tree view sidebar +│ ├── changesView/browser/ # File changes view +│ │ ├── changesView.contribution.ts +│ │ ├── changesView.ts +│ │ └── media/ +│ ├── chat/browser/ # Chat actions and services +│ │ ├── chat.contribution.ts # Open in VS Code, Open Terminal, branch chat, run script, prompts service +│ │ ├── branchChatSessionAction.ts # Branch chat session action +│ │ ├── runScriptAction.ts # Run script contribution and split button +│ │ └── promptsService.ts # Agentic prompts service override +│ ├── configuration/browser/ # Configuration contribution +│ │ └── configuration.contribution.ts +│ └── sessions/browser/ # Sessions view and title bar widget +│ ├── sessions.contribution.ts # Sessions view container, view, and title bar widget registration +│ ├── sessionsViewPane.ts # Sessions list view pane +│ ├── sessionsTitleBarWidget.ts # Title bar widget (SessionsTitleBarWidget, SessionsTitleBarContribution) +│ ├── activeSessionService.ts # IActiveSessionService implementation +│ └── media/ +│ └── sessionsTitleBarWidget.css +``` + +--- + +## 12. Implementation Requirements + +When modifying the Agent Sessions layout: + +1. **Maintain fixed positions** — Do not add settings-based position customization +2. **Panel must span the right section width** — The grid structure places the panel below Chat Bar and Auxiliary Bar only +3. **Sidebar spans full window height** — Sidebar is at the root grid level, spanning from top to bottom independently of the titlebar +4. **New parts go in right section** — Any new parts should be added to the right section alongside Titlebar, Chat Bar, and Auxiliary Bar +5. **Update this spec** — All changes must be documented here +5. **Preserve no-op methods** — Unsupported features should remain as no-ops, not throw errors +6. **Handle pane composite lifecycle** — When hiding/showing parts, manage the associated pane composites +7. **Use agent session parts** — New functionality for parts should be added to the agent session part classes, not the standard parts + +--- + +## 13. Lifecycle + +### 13.1 Startup Sequence + +1. `constructor()` — Register error handlers +2. `startup()` — Initialize services and layout +3. `initServices()` — Set up service collection (including `TitleService`), register singleton services, set lifecycle to `Ready` +4. `initLayout()` — Get services, register layout listeners, register editor open/close listeners +5. `renderWorkbench()` — Create DOM, create parts, create editor modal, set up notifications +6. `createWorkbenchLayout()` — Build the grid structure +7. `createWorkbenchManagement()` — (No-op in agent sessions layout) +8. `layout()` — Perform initial layout +9. `restore()` — Restore parts (open default view containers), set lifecycle to `Restored`, then `Eventually` + +Note: Contributions are registered via module imports in `sessions.desktop.main.ts` (through `registerWorkbenchContribution2`, `registerAction2`, `registerSingleton` calls), not via a central registration function. + +### 13.2 Part Restoration + +During the `restore()` phase, `restoreParts()` is called to open the default view container for each visible part: + +```typescript +private restoreParts(): void { + const partsToRestore = [ + { location: ViewContainerLocation.Sidebar, visible: this.partVisibility.sidebar }, + { location: ViewContainerLocation.Panel, visible: this.partVisibility.panel }, + { location: ViewContainerLocation.AuxiliaryBar, visible: this.partVisibility.auxiliaryBar }, + { location: ViewContainerLocation.ChatBar, visible: this.partVisibility.chatBar }, + ]; + + for (const { location, visible } of partsToRestore) { + if (visible) { + const defaultViewContainer = this.viewDescriptorService.getDefaultViewContainer(location); + if (defaultViewContainer) { + this.paneCompositeService.openPaneComposite(defaultViewContainer.id, location); + } + } + } +} +``` + +This ensures that when a part is visible, its default view container is automatically opened and displayed. + +### 13.3 State Tracking + +```typescript +interface IPartVisibilityState { + sidebar: boolean; + auxiliaryBar: boolean; + editor: boolean; + panel: boolean; + chatBar: boolean; +} +``` + +**Initial state:** + +| Part | Initial Visibility | +|------|--------------------| +| Sidebar | `true` (visible) | +| Auxiliary Bar | `true` (visible) | +| Chat Bar | `true` (visible) | +| Editor | `false` (hidden) | +| Panel | `false` (hidden) | + +--- + +## 14. Sidebar Reveal Buttons + +> **Note:** Sidebar reveal buttons (`SidebarRevealButton`) have been removed from the implementation. The corresponding file `parts/sidebarRevealButton.ts` no longer exists. Sidebar visibility is controlled via the toggle actions in the titlebar (see Section 3.4). + +--- + +## Revision History + +| Date | Change | +|------|--------| +| 2026-02-13 | Documentation sync: Updated all file names, class names, and references to match current implementation. `AgenticWorkbench` → `Workbench`, `AgenticSidebarPart` → `SidebarPart`, `AgenticAuxiliaryBarPart` → `AuxiliaryBarPart`, `AgenticPanelPart` → `PanelPart`, `agenticWorkbench.ts` → `workbench.ts`, `agenticWorkbenchMenus.ts` → `menus.ts`, `agenticLayoutActions.ts` → `layoutActions.ts`, `AgenticTitleBarWidget` → `SessionsTitleBarWidget`, `AgenticTitleBarContribution` → `SessionsTitleBarContribution`. Removed references to deleted files (`sidebarRevealButton.ts`, `floatingToolbar.ts`, `agentic.contributions.ts`, `agenticTitleBarWidget.ts`). Updated pane composite architecture from `SyncDescriptor`-based to `AgenticPaneCompositePartService`. Moved account widget docs from titlebar to sidebar footer. Added documentation for sidebar footer, project bar, traffic light spacer, card appearance styling, widget directory, and new contrib structure (`accountMenu/`, `chat/`, `configuration/`, `sessions/`). Updated titlebar actions to reflect Run Script split button and Open submenu. Removed Toggle Maximize panel action (no longer registered). Updated contributions section with all current contributions and their locations. | +| 2026-02-13 | Changed grid structure: sidebar now spans full window height at root level (HORIZONTAL root orientation); Titlebar moved inside right section; Grid is now `Sidebar \| [Titlebar / TopRight / Panel]` instead of `Titlebar / [Sidebar \| RightSection]`; Panel maximize now excludes both titlebar and sidebar; Floating toolbar positioning no longer depends on titlebar height | +| 2026-02-11 | Simplified titlebar: replaced `BrowserTitlebarPart`-derived implementation with standalone `TitlebarPart` using three `MenuWorkbenchToolBar` sections (left/center/right); Removed `CommandCenterControl`, `WindowTitle`, layout toolbar, and manual toolbar management; Center section uses `Menus.CommandCenter` which renders session picker via `IActionViewItemService`; Right section uses `Menus.TitleBarRight` which includes account submenu; Removed `commandCenterControl.ts` file | +| 2026-02-11 | Removed activity actions (Accounts, Manage) from titlebar; Added `AgenticAccount` submenu to `TitleBarRight` with account icon; Menu shows signed-in user label from `IDefaultAccountService` (or Sign In action if no account), Settings, and Check for Updates; Added `AgenticAccountContribution` workbench contribution for dynamic account state; Added `AgenticAccount` menu ID to `Menus` | +| 2026-02-10 | Titlebar customization now uses class inheritance with protected getter overrides on `BrowserTitlebarPart`; Base class retains original API — no `ITitlebarPartOptions`/`ITitlebarPartConfiguration` removed; `AgenticTitlebarPart` and `AgenticTitleService` in `parts/agenticTitlebarPart.ts` override `isCommandCenterVisible`, `editorActionsEnabled`, `installMenubar()`, and menu ID getters | +| 2026-02-07 | Comprehensive spec update: fixed widget class names (`AgenticTitleBarWidget`/`AgenticTitleBarContribution`), corrected click behavior (uses `AgentSessionsPicker` not `FocusAgentSessionsAction`), corrected session label source (`IActiveSessionService`), fixed toggle terminal details (uses standard `toggleTerminal` command via `MenuRegistry.appendMenuItem` on right toolbar), added sidebar/chatbar storage keys, added chatbar to part classes table, documented contributions section with `RunScriptContribution`/`AgenticTitleBarContribution`/Changes view, added `agent-sessions-workbench` platform class, documented auxiliary bar run script dropdown, updated file structure with `actions/`, `views/`, `media/` directories, fixed lifecycle section numbering, corrected `focus()` target to ChatBar | +| 2026-02-07 | Moved `ToggleTerminalAction` to `contrib/terminal/browser/terminalAgentSessionActions.ts`; Menu item registered via `MenuRegistry.appendMenuItem` from `agenticLayoutActions.ts` to avoid layering violation |\n| 2026-02-07 | Added `TitleBarLeft`, `TitleBarCenter`, `TitleBarRight` menu IDs to `AgenticWorkbenchMenus`; Added `titleBarMenuId` option to `ITitlebarPartOptions` for overriding the global toolbar menu; Actions now use agent-session-specific menu IDs instead of shared `MenuId.TitleBarLeft` / `MenuId.TitleBar` | +| 2026-02-07 | Moved agent sessions workbench menu IDs to `agenticWorkbenchMenus.ts`; Renamed `AgentSessionMenus` to `AgenticWorkbenchMenus` | +| 2026-02-07 | Added `MenuId.AgentSessionsTitleBarContext` as a separate titlebar context menu ID; `contextMenuId` option now set in both main and auxiliary titlebar configurations | +| 2026-02-07 | Added `ToggleTerminalAction` to left toolbar; toggles panel with terminal view; bound to `` Ctrl+` `` | +| 2026-02-06 | `AgentSessionsTitleBarStatusWidget` now shows active chat session title instead of workspace label; Clicking opens sessions view via `FocusAgentSessionsAction`; Removed folder picker and recent folders | +| 2026-02-06 | Replaced command center folder picker with `AgentSessionsTitleBarStatusWidget` (custom `BaseActionViewItem`); Uses `IActionViewItemService` to intercept `AgentSessionsTitleBarControlMenu` submenu; Shows workspace label pill with quick pick for recent folders | +| 2026-02-06 | Added Command Center with custom `AgenticCommandCenter` menu IDs; Dropdown shows recent folders and Open Folder action; Added `AgenticCommandCenterContribution` | +| 2026-02-06 | Added sidebar reveal buttons (`SidebarRevealButton`) — round edge-hover buttons that appear when sidebars are hidden; implemented in `parts/sidebarRevealButton.ts` | +| 2026-02-06 | Auxiliary Bar now visible by default; Removed `AuxiliaryBarVisibilityContribution` (no longer auto-shows/hides based on chat state) | +| 2026-02-06 | Removed Command Center and Project Bar completely; Layout is now: Sidebar \| Chat Bar \| Auxiliary Bar; Global activities (Accounts, Settings) in titlebar via `supportsActivityActions` | +| 2026-02-06 | ~~Removed Project Bar; Added Command Center to titlebar~~ (superseded) | +| 2026-02-06 | ~~Project Bar now stores folder entries in workspace storage~~ (superseded) | +| 2026-02-05 | Auxiliary Bar now hidden by default; Added `AuxiliaryBarVisibilityContribution` to auto-show when chat session has requests, auto-hide when empty | +| 2026-02-05 | Hiding panel now exits maximized state first if panel was maximized | +| 2026-02-05 | Added panel maximize/minimize support via `toggleMaximizedPanel()`; Uses `Grid.maximizeView()` with exclusions for titlebar; Added `TogglePanelMaximizedAction` and `TogglePanelVisibilityAction` to panel title bar | +| 2026-02-05 | Changed layout structure: Panel is now below Chat Bar and Auxiliary Bar only (not full width); Sidebar spans full height | +| 2026-02-05 | Added configurable titlebar via `ITitlebarPartOptions` and `ITitlebarPartConfiguration`; Titlebar now disables command center, menubar, and editor actions; Added left toolbar with `MenuId.TitleBarLeft`; Added `ToggleSidebarVisibilityAction` in `agenticLayoutActions.ts` | +| 2026-02-04 | Modal sizing (80%, min/max constraints) moved from CSS to TypeScript; `EditorModal.layout()` now accepts workbench dimensions | +| 2026-02-04 | Editor now renders as modal overlay instead of in grid; Added `EditorModal` class in `parts/editorModal.ts`; Closing modal closes all editors; Grid layout is now Sidebar \| Chat Bar \| Auxiliary Bar | +| 2026-02-04 | Changed part creation to use `SyncDescriptor0` for lazy instantiation—parts are created when first accessed, not at service construction time | +| 2026-02-04 | Refactored part creation: each layout class now creates and passes parts to `PaneCompositePartService` via `IPaneCompositePartsConfiguration`, removing `isAgentSessionsWorkspace` dependency from the service | +| 2026-02-04 | Added `restoreParts()` to automatically open default view containers for visible parts during startup | +| 2026-02-04 | Restored Editor part; Layout order is now Sidebar \| Chat Bar \| Editor \| Auxiliary Bar | +| 2026-02-04 | Removed Editor part; Chat Bar now takes max width; Layout order changed to Sidebar \| Auxiliary Bar \| Chat Bar | +| 2026-02-04 | Added agent session specific parts (AgenticSidebarPart, AgenticAuxiliaryBarPart, AgenticPanelPart) in `sessions/browser/parts/`; PaneCompositePartService now selects parts based on isAgentSessionsWorkspace | +| 2026-02-04 | Editor and Panel hidden by default; Editor auto-shows on editor open, auto-hides when last editor closes | +| 2026-02-04 | Added Chat Bar part with `ViewContainerLocation.ChatBar` | +| Initial | Document created with base layout specification | diff --git a/src/vs/sessions/README.md b/src/vs/sessions/README.md new file mode 100644 index 0000000000000..002781a49ce4d --- /dev/null +++ b/src/vs/sessions/README.md @@ -0,0 +1,125 @@ +# vs/sessions — Agentic Sessions Window Layer + +## Overview + +The `vs/sessions` layer hosts the implementation of the **Agentic Window**, a dedicated workbench experience optimized for agent session workflows. This is a distinct top-level layer within the VS Code architecture, sitting alongside `vs/workbench`. + +## Architecture + +### Layering Rules + +``` +vs/base ← Foundation utilities +vs/platform ← Platform services +vs/editor ← Text editor core +vs/workbench ← Standard workbench +vs/sessions ← Agentic window (this layer) +``` + +**Key constraint:** `vs/sessions` may import from `vs/workbench` (and all layers below it), but `vs/workbench` must **never** import from `vs/sessions`. This ensures the standard workbench remains independent of the agentic window implementation. + +### Allowed Dependencies + +| From `vs/sessions` | Can Import | +|--------------------|------------| +| `vs/base/**` | ✅ | +| `vs/platform/**` | ✅ | +| `vs/editor/**` | ✅ | +| `vs/workbench/**` | ✅ | +| `vs/sessions/**` | ✅ (internal) | + +| From `vs/workbench` | Can Import | +|----------------------|------------| +| `vs/sessions/**` | ❌ **Forbidden** | + +### Folder Structure + +The `vs/sessions` layer follows the same layering conventions as `vs/workbench`: + +``` +src/vs/sessions/ +├── README.md ← This specification +├── LAYOUT.md ← Layout specification for the agentic workbench +├── AI_CUSTOMIZATIONS.md ← AI customization design document +├── sessions.common.main.ts ← Common (browser + desktop) entry point +├── sessions.desktop.main.ts ← Desktop entry point +├── common/ ← Shared types and context keys +│ └── contextkeys.ts ← ChatBar context keys +├── browser/ ← Core workbench implementation +│ ├── workbench.ts ← Main workbench layout (Workbench class) +│ ├── layoutActions.ts ← Layout toggle actions +│ ├── menus.ts ← Menu IDs for agent sessions menus (Menus export) +│ ├── paneCompositePartService.ts ← AgenticPaneCompositePartService +│ ├── style.css ← Layout styles +│ ├── widget/ ← Agent sessions chat widget +│ │ ├── AGENTS_CHAT_WIDGET.md ← Chat widget architecture documentation +│ │ ├── agentSessionsChatWidget.ts ← Main chat widget wrapper +│ │ ├── agentSessionsChatTargetConfig.ts ← Target configuration (observable) +│ │ ├── agentSessionsTargetPickerActionItem.ts ← Target picker for input toolbar +│ │ └── media/ +│ │ └── agentSessionsChatWidget.css +│ └── parts/ ← Workbench part implementations +│ ├── titlebarPart.ts ← Simplified titlebar part & title service +│ ├── sidebarPart.ts ← Sidebar part (with footer) +│ ├── auxiliaryBarPart.ts ← Auxiliary bar part (with run script dropdown) +│ ├── panelPart.ts ← Panel part +│ ├── chatBarPart.ts ← Chat bar part +│ ├── projectBarPart.ts ← Project bar part (folder entries) +│ ├── editorModal.ts ← Editor modal overlay +│ ├── parts.ts ← AgenticParts enum +│ ├── agentSessionsChatInputPart.ts ← Chat input part adapter +│ ├── agentSessionsChatWelcomePart.ts ← Chat welcome part +│ └── media/ ← Part CSS +├── electron-browser/ ← Desktop-specific entry points +│ ├── sessions.main.ts +│ ├── sessions.ts +│ ├── sessions.html +│ └── sessions-dev.html +├── contrib/ ← Feature contributions +│ ├── accountMenu/browser/ ← Account menu widget and sidebar footer +│ │ └── account.contribution.ts +│ ├── aiCustomizationManagement/ ← AI customization management editor +│ │ └── browser/ +│ ├── aiCustomizationTreeView/ ← AI customization tree view sidebar +│ │ └── browser/ +│ ├── changesView/browser/ ← File changes view +│ │ ├── changesView.contribution.ts +│ │ └── changesView.ts +│ ├── chat/browser/ ← Chat-related actions and services +│ │ ├── chat.contribution.ts +│ │ ├── branchChatSessionAction.ts +│ │ ├── runScriptAction.ts +│ │ └── promptsService.ts +│ ├── configuration/browser/ ← Configuration contribution +│ │ └── configuration.contribution.ts +│ └── sessions/browser/ ← Sessions view and title bar widget +│ ├── sessions.contribution.ts +│ ├── sessionsViewPane.ts +│ ├── sessionsTitleBarWidget.ts +│ ├── activeSessionService.ts +│ └── media/ +``` + +## What is the Agentic Window? + +The Agentic Window (`Workbench`) provides a simplified, fixed-layout workbench tailored for agent session workflows. Unlike the standard VS Code workbench: + +- **Fixed layout** — Part positions are not configurable via settings +- **Simplified chrome** — No activity bar, no status bar, no banner +- **Chat-first UX** — Chat bar is a primary part alongside sidebar and auxiliary bar +- **Modal editor** — Editors appear as modal overlays rather than in the main grid +- **Session-aware titlebar** — Titlebar shows active session with a session picker +- **Sidebar footer** — Account widget and sign-in live in the sidebar footer + +See [LAYOUT.md](LAYOUT.md) for the detailed layout specification. + +## Adding New Functionality + +When adding features to the agentic window: + +1. **Core workbench code** (layout, parts, services) goes under `browser/` +2. **Feature contributions** (views, actions, editors) go under `contrib//browser/` +3. Register contributions by importing them in `sessions.desktop.main.ts` (or `sessions.common.main.ts` for browser-compatible code) +4. Do **not** add imports from `vs/workbench` back to `vs/sessions` +5. Contributions can import from `vs/sessions/browser/` (core) and other `vs/sessions/contrib/*/` modules +6. Update the layout spec (`LAYOUT.md`) for any layout changes diff --git a/src/vs/sessions/browser/layoutActions.ts b/src/vs/sessions/browser/layoutActions.ts new file mode 100644 index 0000000000000..d16de091c627c --- /dev/null +++ b/src/vs/sessions/browser/layoutActions.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { alert } from '../../base/browser/ui/aria/aria.js'; +import { Codicon } from '../../base/common/codicons.js'; +import { KeyCode, KeyMod } from '../../base/common/keyCodes.js'; +import { localize, localize2 } from '../../nls.js'; +import { Categories } from '../../platform/action/common/actionCommonCategories.js'; +import { Action2, registerAction2 } from '../../platform/actions/common/actions.js'; +import { Menus } from './menus.js'; +import { ServicesAccessor } from '../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../platform/keybinding/common/keybindingsRegistry.js'; +import { registerIcon } from '../../platform/theme/common/iconRegistry.js'; +import { AuxiliaryBarVisibleContext, SideBarVisibleContext } from '../../workbench/common/contextkeys.js'; +import { IWorkbenchLayoutService, Parts } from '../../workbench/services/layout/browser/layoutService.js'; + +// Register Icons +const panelLeftIcon = registerIcon('agent-panel-left', Codicon.layoutSidebarLeft, localize('panelLeft', "Represents a side bar in the left position")); +const panelLeftOffIcon = registerIcon('agent-panel-left-off', Codicon.layoutSidebarLeftOff, localize('panelLeftOff', "Represents a side bar in the left position that is hidden")); +const panelRightIcon = registerIcon('agent-panel-right', Codicon.layoutSidebarRight, localize('panelRight', "Represents a secondary side bar in the right position")); +const panelRightOffIcon = registerIcon('agent-panel-right-off', Codicon.layoutSidebarRightOff, localize('panelRightOff', "Represents a secondary side bar in the right position that is hidden")); +const panelCloseIcon = registerIcon('agent-panel-close', Codicon.close, localize('agentPanelCloseIcon', "Icon to close the panel.")); + +class ToggleSidebarVisibilityAction extends Action2 { + + static readonly ID = 'workbench.action.agentToggleSidebarVisibility'; + static readonly LABEL = localize('compositePart.hideSideBarLabel', "Hide Primary Side Bar"); + + constructor() { + super({ + id: ToggleSidebarVisibilityAction.ID, + title: localize2('toggleSidebar', 'Toggle Primary Side Bar Visibility'), + icon: panelLeftOffIcon, + toggled: { + condition: SideBarVisibleContext, + icon: panelLeftIcon, + title: localize('primary sidebar', "Primary Side Bar"), + mnemonicTitle: localize({ key: 'primary sidebar mnemonic', comment: ['&& denotes a mnemonic'] }, "&&Primary Side Bar"), + }, + metadata: { + description: localize('openAndCloseSidebar', 'Open/Show and Close/Hide Sidebar'), + }, + category: Categories.View, + f1: true, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyCode.KeyB + }, + menu: [ + { + id: Menus.TitleBarLeft, + group: 'navigation', + order: 0 + } + ] + }); + } + + run(accessor: ServicesAccessor): void { + const layoutService = accessor.get(IWorkbenchLayoutService); + const isCurrentlyVisible = layoutService.isVisible(Parts.SIDEBAR_PART); + + layoutService.setPartHidden(isCurrentlyVisible, Parts.SIDEBAR_PART); + + // Announce visibility change to screen readers + const alertMessage = isCurrentlyVisible + ? localize('sidebarHidden', "Primary Side Bar hidden") + : localize('sidebarVisible', "Primary Side Bar shown"); + alert(alertMessage); + } +} + +class ToggleSecondarySidebarVisibilityAction extends Action2 { + + static readonly ID = 'workbench.action.agentToggleSecondarySidebarVisibility'; + static readonly LABEL = localize('compositePart.hideSecondarySideBarLabel', "Hide Secondary Side Bar"); + + constructor() { + super({ + id: ToggleSecondarySidebarVisibilityAction.ID, + title: localize2('toggleSecondarySidebar', 'Toggle Secondary Side Bar Visibility'), + icon: panelRightOffIcon, + toggled: { + condition: AuxiliaryBarVisibleContext, + icon: panelRightIcon, + title: localize('secondary sidebar', "Secondary Side Bar"), + mnemonicTitle: localize({ key: 'secondary sidebar mnemonic', comment: ['&& denotes a mnemonic'] }, "&&Secondary Side Bar"), + }, + metadata: { + description: localize('openAndCloseSecondarySidebar', 'Open/Show and Close/Hide Secondary Side Bar'), + }, + category: Categories.View, + f1: true, + menu: [ + { + id: Menus.TitleBarRight, + group: 'navigation', + order: 10 + } + ] + }); + } + + run(accessor: ServicesAccessor): void { + const layoutService = accessor.get(IWorkbenchLayoutService); + const isCurrentlyVisible = layoutService.isVisible(Parts.AUXILIARYBAR_PART); + + layoutService.setPartHidden(isCurrentlyVisible, Parts.AUXILIARYBAR_PART); + + // Announce visibility change to screen readers + const alertMessage = isCurrentlyVisible + ? localize('secondarySidebarHidden', "Secondary Side Bar hidden") + : localize('secondarySidebarVisible', "Secondary Side Bar shown"); + alert(alertMessage); + } +} + +class TogglePanelVisibilityAction extends Action2 { + + static readonly ID = 'workbench.action.agentTogglePanelVisibility'; + + constructor() { + super({ + id: TogglePanelVisibilityAction.ID, + title: localize2('togglePanel', 'Toggle Panel Visibility'), + category: Categories.View, + f1: true, + icon: panelCloseIcon, + menu: [ + { + id: Menus.PanelTitle, + group: 'navigation', + order: 2 + } + ] + }); + } + + run(accessor: ServicesAccessor): void { + const layoutService = accessor.get(IWorkbenchLayoutService); + layoutService.setPartHidden(layoutService.isVisible(Parts.PANEL_PART), Parts.PANEL_PART); + } +} + +registerAction2(ToggleSidebarVisibilityAction); +registerAction2(ToggleSecondarySidebarVisibilityAction); +registerAction2(TogglePanelVisibilityAction); diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts new file mode 100644 index 0000000000000..8d0fdbe0d1d9f --- /dev/null +++ b/src/vs/sessions/browser/menus.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { MenuId } from '../../platform/actions/common/actions.js'; + +/** + * Menu IDs for the Agent Sessions workbench layout. + */ +export const Menus = { + ChatBarTitle: new MenuId('ChatBarTitle'), + CommandCenter: new MenuId('SessionsCommandCenter'), + CommandCenterCenter: new MenuId('SessionsCommandCenterCenter'), + TitleBarContext: new MenuId('SessionsTitleBarContext'), + TitleBarControlMenu: new MenuId('SessionsTitleBarControlMenu'), + TitleBarLeft: new MenuId('SessionsTitleBarLeft'), + TitleBarCenter: new MenuId('SessionsTitleBarCenter'), + TitleBarRight: new MenuId('SessionsTitleBarRight'), + OpenSubMenu: new MenuId('SessionsOpenSubMenu'), + PanelTitle: new MenuId('SessionsPanelTitle'), + SidebarTitle: new MenuId('SessionsSidebarTitle'), + AuxiliaryBarTitle: new MenuId('SessionsAuxiliaryBarTitle'), + AuxiliaryBarTitleLeft: new MenuId('SessionsAuxiliaryBarTitleLeft'), + SidebarFooter: new MenuId('SessionsSidebarFooter'), +} as const; diff --git a/src/vs/sessions/browser/paneCompositePartService.ts b/src/vs/sessions/browser/paneCompositePartService.ts new file mode 100644 index 0000000000000..060cdfdedade6 --- /dev/null +++ b/src/vs/sessions/browser/paneCompositePartService.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from '../../base/common/event.js'; +import { assertReturnsDefined } from '../../base/common/types.js'; +import { IInstantiationService } from '../../platform/instantiation/common/instantiation.js'; +import { IProgressIndicator } from '../../platform/progress/common/progress.js'; +import { IPaneComposite } from '../../workbench/common/panecomposite.js'; +import { ViewContainerLocation } from '../../workbench/common/views.js'; +import { IPaneCompositePartService } from '../../workbench/services/panecomposite/browser/panecomposite.js'; +import { Disposable } from '../../base/common/lifecycle.js'; +import { PaneCompositeDescriptor } from '../../workbench/browser/panecomposite.js'; +import { IPaneCompositePart } from '../../workbench/browser/parts/paneCompositePart.js'; +import { SINGLE_WINDOW_PARTS } from '../../workbench/services/layout/browser/layoutService.js'; +import { PanelPart } from './parts/panelPart.js'; +import { SidebarPart } from './parts/sidebarPart.js'; +import { AuxiliaryBarPart } from './parts/auxiliaryBarPart.js'; +import { ChatBarPart } from './parts/chatBarPart.js'; +import { InstantiationType, registerSingleton } from '../../platform/instantiation/common/extensions.js'; + +export class AgenticPaneCompositePartService extends Disposable implements IPaneCompositePartService { + + declare readonly _serviceBrand: undefined; + + private readonly _onDidPaneCompositeOpen = this._register(new Emitter<{ composite: IPaneComposite; viewContainerLocation: ViewContainerLocation }>()); + readonly onDidPaneCompositeOpen = this._onDidPaneCompositeOpen.event; + + private readonly _onDidPaneCompositeClose = this._register(new Emitter<{ composite: IPaneComposite; viewContainerLocation: ViewContainerLocation }>()); + readonly onDidPaneCompositeClose = this._onDidPaneCompositeClose.event; + + private readonly paneCompositeParts = new Map(); + + constructor( + @IInstantiationService instantiationService: IInstantiationService + ) { + super(); + + this.registerPart(ViewContainerLocation.Panel, instantiationService.createInstance(PanelPart)); + this.registerPart(ViewContainerLocation.Sidebar, instantiationService.createInstance(SidebarPart)); + this.registerPart(ViewContainerLocation.AuxiliaryBar, instantiationService.createInstance(AuxiliaryBarPart)); + this.registerPart(ViewContainerLocation.ChatBar, instantiationService.createInstance(ChatBarPart)); + } + + private registerPart(location: ViewContainerLocation, part: IPaneCompositePart): void { + this.paneCompositeParts.set(location, part); + this._register(part.onDidPaneCompositeOpen(composite => this._onDidPaneCompositeOpen.fire({ composite, viewContainerLocation: location }))); + this._register(part.onDidPaneCompositeClose(composite => this._onDidPaneCompositeClose.fire({ composite, viewContainerLocation: location }))); + } + + getRegistryId(viewContainerLocation: ViewContainerLocation): string { + return this.getPartByLocation(viewContainerLocation).registryId; + } + + getPartId(viewContainerLocation: ViewContainerLocation): SINGLE_WINDOW_PARTS { + return this.getPartByLocation(viewContainerLocation).partId; + } + + openPaneComposite(id: string | undefined, viewContainerLocation: ViewContainerLocation, focus?: boolean): Promise { + return this.getPartByLocation(viewContainerLocation).openPaneComposite(id, focus); + } + + getActivePaneComposite(viewContainerLocation: ViewContainerLocation): IPaneComposite | undefined { + return this.getPartByLocation(viewContainerLocation).getActivePaneComposite(); + } + + getPaneComposite(id: string, viewContainerLocation: ViewContainerLocation): PaneCompositeDescriptor | undefined { + return this.getPartByLocation(viewContainerLocation).getPaneComposite(id); + } + + getPaneComposites(viewContainerLocation: ViewContainerLocation): PaneCompositeDescriptor[] { + return this.getPartByLocation(viewContainerLocation).getPaneComposites(); + } + + getPinnedPaneCompositeIds(viewContainerLocation: ViewContainerLocation): string[] { + return this.getPartByLocation(viewContainerLocation).getPinnedPaneCompositeIds(); + } + + getVisiblePaneCompositeIds(viewContainerLocation: ViewContainerLocation): string[] { + return this.getPartByLocation(viewContainerLocation).getVisiblePaneCompositeIds(); + } + + getPaneCompositeIds(viewContainerLocation: ViewContainerLocation): string[] { + return this.getPartByLocation(viewContainerLocation).getPaneCompositeIds(); + } + + getProgressIndicator(id: string, viewContainerLocation: ViewContainerLocation): IProgressIndicator | undefined { + return this.getPartByLocation(viewContainerLocation).getProgressIndicator(id); + } + + hideActivePaneComposite(viewContainerLocation: ViewContainerLocation): void { + this.getPartByLocation(viewContainerLocation).hideActivePaneComposite(); + } + + getLastActivePaneCompositeId(viewContainerLocation: ViewContainerLocation): string { + return this.getPartByLocation(viewContainerLocation).getLastActivePaneCompositeId(); + } + + private getPartByLocation(viewContainerLocation: ViewContainerLocation): IPaneCompositePart { + return assertReturnsDefined(this.paneCompositeParts.get(viewContainerLocation)); + } + +} + +registerSingleton(IPaneCompositePartService, AgenticPaneCompositePartService, InstantiationType.Delayed); diff --git a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts new file mode 100644 index 0000000000000..abb205600d572 --- /dev/null +++ b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts @@ -0,0 +1,280 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import '../../../workbench/browser/parts/auxiliarybar/media/auxiliaryBarPart.css'; +import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; +import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../platform/keybinding/common/keybinding.js'; +import { INotificationService } from '../../../platform/notification/common/notification.js'; +import { IStorageService } from '../../../platform/storage/common/storage.js'; +import { IThemeService } from '../../../platform/theme/common/themeService.js'; +import { ActiveAuxiliaryContext, AuxiliaryBarFocusContext } from '../../../workbench/common/contextkeys.js'; +import { ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND, ACTIVITY_BAR_TOP_ACTIVE_BORDER, ACTIVITY_BAR_TOP_DRAG_AND_DROP_BORDER, ACTIVITY_BAR_TOP_FOREGROUND, ACTIVITY_BAR_TOP_INACTIVE_FOREGROUND, PANEL_ACTIVE_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_DRAG_AND_DROP_BORDER, PANEL_INACTIVE_TITLE_FOREGROUND, SIDE_BAR_BACKGROUND, SIDE_BAR_BORDER, SIDE_BAR_TITLE_BORDER, SIDE_BAR_FOREGROUND } from '../../../workbench/common/theme.js'; +import { contrastBorder } from '../../../platform/theme/common/colorRegistry.js'; +import { IViewDescriptorService, ViewContainerLocation } from '../../../workbench/common/views.js'; +import { IExtensionService } from '../../../workbench/services/extensions/common/extensions.js'; +import { IWorkbenchLayoutService, Parts } from '../../../workbench/services/layout/browser/layoutService.js'; +import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js'; +import { IAction } from '../../../base/common/actions.js'; +import { assertReturnsDefined } from '../../../base/common/types.js'; +import { LayoutPriority } from '../../../base/browser/ui/splitview/splitview.js'; +import { AbstractPaneCompositePart, CompositeBarPosition } from '../../../workbench/browser/parts/paneCompositePart.js'; +import { Part } from '../../../workbench/browser/part.js'; +import { ActionsOrientation, IActionViewItem } from '../../../base/browser/ui/actionbar/actionbar.js'; +import { IPaneCompositeBarOptions } from '../../../workbench/browser/parts/paneCompositeBar.js'; +import { IMenuService, IMenu, MenuId, MenuItemAction } from '../../../platform/actions/common/actions.js'; +import { Menus } from '../menus.js'; +import { IHoverService } from '../../../platform/hover/browser/hover.js'; +import { DropdownWithPrimaryActionViewItem } from '../../../platform/actions/browser/dropdownWithPrimaryActionViewItem.js'; +import { IBaseActionViewItemOptions } from '../../../base/browser/ui/actionbar/actionViewItems.js'; +import { getFlatContextMenuActions } from '../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; +import { Extensions } from '../../../workbench/browser/panecomposite.js'; + +/** + * Auxiliary bar part specifically for agent sessions workbench. + * This is a simplified version of the AuxiliaryBarPart for agent session contexts. + */ +export class AuxiliaryBarPart extends AbstractPaneCompositePart { + + static readonly activeViewSettingsKey = 'workbench.agentsession.auxiliarybar.activepanelid'; + static readonly pinnedViewsKey = 'workbench.agentsession.auxiliarybar.pinnedPanels'; + static readonly placeholdeViewContainersKey = 'workbench.agentsession.auxiliarybar.placeholderPanels'; + static readonly viewContainersWorkspaceStateKey = 'workbench.agentsession.auxiliarybar.viewContainersWorkspaceState'; + + /** Visual margin values for the card-like appearance */ + static readonly MARGIN_TOP = 8; + static readonly MARGIN_BOTTOM = 8; + static readonly MARGIN_RIGHT = 8; + + // Action ID for run script - defined here to avoid layering issues + private static readonly RUN_SCRIPT_ACTION_ID = 'workbench.action.agentSessions.runScript'; + private static readonly RUN_SCRIPT_DROPDOWN_MENU_ID = MenuId.for('AgentSessionsRunScriptDropdown'); + + // Run script dropdown management + private readonly _runScriptDropdown = this._register(new MutableDisposable()); + private readonly _runScriptMenu = this._register(new MutableDisposable()); + private readonly _runScriptMenuListener = this._register(new MutableDisposable()); + + // Use the side bar dimensions + override readonly minimumWidth: number = 170; + override readonly maximumWidth: number = Number.POSITIVE_INFINITY; + override readonly minimumHeight: number = 0; + override readonly maximumHeight: number = Number.POSITIVE_INFINITY; + + get preferredHeight(): number | undefined { + return this.layoutService.mainContainerDimension.height * 0.4; + } + + get preferredWidth(): number | undefined { + const activeComposite = this.getActivePaneComposite(); + + if (!activeComposite) { + return undefined; + } + + const width = activeComposite.getOptimalWidth(); + if (typeof width !== 'number') { + return undefined; + } + + return Math.max(width, 300); + } + + readonly priority = LayoutPriority.Low; + + constructor( + @INotificationService notificationService: INotificationService, + @IStorageService storageService: IStorageService, + @IContextMenuService contextMenuService: IContextMenuService, + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @IKeybindingService keybindingService: IKeybindingService, + @IHoverService hoverService: IHoverService, + @IInstantiationService instantiationService: IInstantiationService, + @IThemeService themeService: IThemeService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IContextKeyService contextKeyService: IContextKeyService, + @IExtensionService extensionService: IExtensionService, + @IMenuService menuService: IMenuService, + ) { + super( + Parts.AUXILIARYBAR_PART, + { + hasTitle: true, + trailingSeparator: false, + borderWidth: () => 0, + }, + AuxiliaryBarPart.activeViewSettingsKey, + ActiveAuxiliaryContext.bindTo(contextKeyService), + AuxiliaryBarFocusContext.bindTo(contextKeyService), + 'auxiliarybar', + 'auxiliarybar', + undefined, + SIDE_BAR_TITLE_BORDER, + ViewContainerLocation.AuxiliaryBar, + Extensions.Auxiliary, + Menus.AuxiliaryBarTitle, + Menus.AuxiliaryBarTitleLeft, + notificationService, + storageService, + contextMenuService, + layoutService, + keybindingService, + hoverService, + instantiationService, + themeService, + viewDescriptorService, + contextKeyService, + extensionService, + menuService, + ); + + } + + override updateStyles(): void { + super.updateStyles(); + + const container = assertReturnsDefined(this.getContainer()); + + // Store background and border as CSS variables for the card styling on .part + container.style.setProperty('--part-background', this.getColor(SIDE_BAR_BACKGROUND) || ''); + container.style.setProperty('--part-border-color', this.getColor(SIDE_BAR_BORDER) || this.getColor(contrastBorder) || 'transparent'); + container.style.backgroundColor = 'transparent'; + container.style.color = this.getColor(SIDE_BAR_FOREGROUND) || ''; + + // Clear borders - the card appearance uses border-radius instead + container.style.borderLeftColor = ''; + container.style.borderRightColor = ''; + container.style.borderLeftStyle = ''; + container.style.borderRightStyle = ''; + container.style.borderLeftWidth = ''; + container.style.borderRightWidth = ''; + } + + protected getCompositeBarOptions(): IPaneCompositeBarOptions { + const $this = this; + return { + partContainerClass: 'auxiliarybar', + pinnedViewContainersKey: AuxiliaryBarPart.pinnedViewsKey, + placeholderViewContainersKey: AuxiliaryBarPart.placeholdeViewContainersKey, + viewContainersWorkspaceStateKey: AuxiliaryBarPart.viewContainersWorkspaceStateKey, + icon: false, + orientation: ActionsOrientation.HORIZONTAL, + recomputeSizes: true, + activityHoverOptions: { + position: () => this.getCompositeBarPosition() === CompositeBarPosition.BOTTOM ? HoverPosition.ABOVE : HoverPosition.BELOW, + }, + fillExtraContextMenuActions: actions => this.fillExtraContextMenuActions(actions), + compositeSize: 0, + iconSize: 16, + get overflowActionSize() { return $this.getCompositeBarPosition() === CompositeBarPosition.TITLE ? 40 : 30; }, + colors: theme => ({ + activeBackgroundColor: theme.getColor(SIDE_BAR_BACKGROUND), + inactiveBackgroundColor: theme.getColor(SIDE_BAR_BACKGROUND), + get activeBorderBottomColor() { return $this.getCompositeBarPosition() === CompositeBarPosition.TITLE ? theme.getColor(PANEL_ACTIVE_TITLE_BORDER) : theme.getColor(ACTIVITY_BAR_TOP_ACTIVE_BORDER); }, + get activeForegroundColor() { return $this.getCompositeBarPosition() === CompositeBarPosition.TITLE ? theme.getColor(PANEL_ACTIVE_TITLE_FOREGROUND) : theme.getColor(ACTIVITY_BAR_TOP_FOREGROUND); }, + get inactiveForegroundColor() { return $this.getCompositeBarPosition() === CompositeBarPosition.TITLE ? theme.getColor(PANEL_INACTIVE_TITLE_FOREGROUND) : theme.getColor(ACTIVITY_BAR_TOP_INACTIVE_FOREGROUND); }, + badgeBackground: theme.getColor(ACTIVITY_BAR_BADGE_BACKGROUND), + badgeForeground: theme.getColor(ACTIVITY_BAR_BADGE_FOREGROUND), + get dragAndDropBorder() { return $this.getCompositeBarPosition() === CompositeBarPosition.TITLE ? theme.getColor(PANEL_DRAG_AND_DROP_BORDER) : theme.getColor(ACTIVITY_BAR_TOP_DRAG_AND_DROP_BORDER); } + }), + compact: true + }; + } + + protected override actionViewItemProvider(action: IAction, options: IBaseActionViewItemOptions): IActionViewItem | undefined { + // Create a DropdownWithPrimaryActionViewItem for the run script action + if (action.id === AuxiliaryBarPart.RUN_SCRIPT_ACTION_ID && action instanceof MenuItemAction) { + // Create and store the menu so we can listen for changes + if (!this._runScriptMenu.value) { + this._runScriptMenu.value = this.menuService.createMenu(AuxiliaryBarPart.RUN_SCRIPT_DROPDOWN_MENU_ID, this.contextKeyService); + this._runScriptMenuListener.value = this._runScriptMenu.value.onDidChange(() => this._updateRunScriptDropdown()); + } + + const dropdownActions = this._getRunScriptDropdownActions(); + + const dropdownAction: IAction = { + id: 'runScriptDropdown', + label: '', + tooltip: '', + class: undefined, + enabled: true, + run: () => { } + }; + + this._runScriptDropdown.value = this.instantiationService.createInstance( + DropdownWithPrimaryActionViewItem, + action, + dropdownAction, + dropdownActions, + '', + { + hoverDelegate: options.hoverDelegate, + getKeyBinding: (action: IAction) => this.keybindingService.lookupKeybinding(action.id, this.contextKeyService) + } + ); + + return this._runScriptDropdown.value; + } + + return super.actionViewItemProvider(action, options); + } + + private _getRunScriptDropdownActions(): IAction[] { + if (!this._runScriptMenu.value) { + return []; + } + return getFlatContextMenuActions(this._runScriptMenu.value.getActions({ shouldForwardArgs: true })); + } + + private _updateRunScriptDropdown(): void { + if (this._runScriptDropdown.value) { + const dropdownActions = this._getRunScriptDropdownActions(); + const dropdownAction: IAction = { + id: 'runScriptDropdown', + label: '', + tooltip: '', + class: undefined, + enabled: true, + run: () => { } + }; + this._runScriptDropdown.value.update(dropdownAction, dropdownActions); + } + } + + private fillExtraContextMenuActions(_actions: IAction[]): void { } + + protected shouldShowCompositeBar(): boolean { + return true; + } + + protected getCompositeBarPosition(): CompositeBarPosition { + return CompositeBarPosition.TITLE; + } + + override layout(width: number, height: number, top: number, left: number): void { + if (!this.layoutService.isVisible(Parts.AUXILIARYBAR_PART)) { + return; + } + + // Layout content with reduced dimensions to account for visual margins + super.layout( + width - AuxiliaryBarPart.MARGIN_RIGHT, + height - AuxiliaryBarPart.MARGIN_TOP - AuxiliaryBarPart.MARGIN_BOTTOM, + top, left + ); + + // Restore the full grid-allocated dimensions so that Part.relayout() works correctly. + // Part.layout() only stores _dimension and _contentPosition - no other side effects. + Part.prototype.layout.call(this, width, height, top, left); + } + + override toJSON(): object { + return { + type: Parts.AUXILIARYBAR_PART + }; + } +} diff --git a/src/vs/sessions/browser/parts/chatBarPart.ts b/src/vs/sessions/browser/parts/chatBarPart.ts new file mode 100644 index 0000000000000..3a1b3be4ce6a5 --- /dev/null +++ b/src/vs/sessions/browser/parts/chatBarPart.ts @@ -0,0 +1,162 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/chatBarPart.css'; +import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; +import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../platform/keybinding/common/keybinding.js'; +import { INotificationService } from '../../../platform/notification/common/notification.js'; +import { IStorageService } from '../../../platform/storage/common/storage.js'; +import { IThemeService } from '../../../platform/theme/common/themeService.js'; +import { ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND, PANEL_ACTIVE_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_DRAG_AND_DROP_BORDER, PANEL_INACTIVE_TITLE_FOREGROUND, SIDE_BAR_BACKGROUND, SIDE_BAR_TITLE_BORDER, SIDE_BAR_FOREGROUND } from '../../../workbench/common/theme.js'; +import { IViewDescriptorService, ViewContainerLocation } from '../../../workbench/common/views.js'; +import { IExtensionService } from '../../../workbench/services/extensions/common/extensions.js'; +import { IWorkbenchLayoutService, Parts } from '../../../workbench/services/layout/browser/layoutService.js'; +import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js'; +import { assertReturnsDefined } from '../../../base/common/types.js'; +import { LayoutPriority } from '../../../base/browser/ui/splitview/splitview.js'; +import { AbstractPaneCompositePart, CompositeBarPosition } from '../../../workbench/browser/parts/paneCompositePart.js'; +import { ActionsOrientation } from '../../../base/browser/ui/actionbar/actionbar.js'; +import { IPaneCompositeBarOptions } from '../../../workbench/browser/parts/paneCompositeBar.js'; +import { IMenuService } from '../../../platform/actions/common/actions.js'; +import { IHoverService } from '../../../platform/hover/browser/hover.js'; +import { Extensions } from '../../../workbench/browser/panecomposite.js'; +import { Menus } from '../menus.js'; +import { ActiveChatBarContext, ChatBarFocusContext } from '../../common/contextkeys.js'; + +export class ChatBarPart extends AbstractPaneCompositePart { + + static readonly activeViewSettingsKey = 'workbench.chatbar.activepanelid'; + static readonly pinnedViewsKey = 'workbench.chatbar.pinnedPanels'; + static readonly placeholdeViewContainersKey = 'workbench.chatbar.placeholderPanels'; + static readonly viewContainersWorkspaceStateKey = 'workbench.chatbar.viewContainersWorkspaceState'; + + // Use the side bar dimensions + override readonly minimumWidth: number = 170; + override readonly maximumWidth: number = Number.POSITIVE_INFINITY; + override readonly minimumHeight: number = 0; + override readonly maximumHeight: number = Number.POSITIVE_INFINITY; + + get preferredHeight(): number | undefined { + return this.layoutService.mainContainerDimension.height * 0.4; + } + + get preferredWidth(): number | undefined { + const activeComposite = this.getActivePaneComposite(); + + if (!activeComposite) { + return undefined; + } + + const width = activeComposite.getOptimalWidth(); + if (typeof width !== 'number') { + return undefined; + } + + return Math.max(width, 300); + } + + readonly priority = LayoutPriority.High; + + constructor( + @INotificationService notificationService: INotificationService, + @IStorageService storageService: IStorageService, + @IContextMenuService contextMenuService: IContextMenuService, + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @IKeybindingService keybindingService: IKeybindingService, + @IHoverService hoverService: IHoverService, + @IInstantiationService instantiationService: IInstantiationService, + @IThemeService themeService: IThemeService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IContextKeyService contextKeyService: IContextKeyService, + @IExtensionService extensionService: IExtensionService, + @IMenuService menuService: IMenuService + ) { + super( + Parts.CHATBAR_PART, + { + hasTitle: false, + trailingSeparator: true, + borderWidth: () => 0, + }, + ChatBarPart.activeViewSettingsKey, + ActiveChatBarContext.bindTo(contextKeyService), + ChatBarFocusContext.bindTo(contextKeyService), + 'chatbar', + 'chatbar', + undefined, + SIDE_BAR_TITLE_BORDER, + ViewContainerLocation.ChatBar, + Extensions.ChatBar, + Menus.ChatBarTitle, + undefined, + notificationService, + storageService, + contextMenuService, + layoutService, + keybindingService, + hoverService, + instantiationService, + themeService, + viewDescriptorService, + contextKeyService, + extensionService, + menuService, + ); + } + + override updateStyles(): void { + super.updateStyles(); + + const container = assertReturnsDefined(this.getContainer()); + container.style.backgroundColor = this.getColor(SIDE_BAR_BACKGROUND) || ''; + container.style.color = this.getColor(SIDE_BAR_FOREGROUND) || ''; + } + + protected getCompositeBarOptions(): IPaneCompositeBarOptions { + return { + partContainerClass: 'chatbar', + pinnedViewContainersKey: ChatBarPart.pinnedViewsKey, + placeholderViewContainersKey: ChatBarPart.placeholdeViewContainersKey, + viewContainersWorkspaceStateKey: ChatBarPart.viewContainersWorkspaceStateKey, + icon: false, + orientation: ActionsOrientation.HORIZONTAL, + recomputeSizes: true, + activityHoverOptions: { + position: () => HoverPosition.BELOW, + }, + fillExtraContextMenuActions: () => { }, + compositeSize: 0, + iconSize: 16, + overflowActionSize: 30, + colors: theme => ({ + activeBackgroundColor: theme.getColor(SIDE_BAR_BACKGROUND), + inactiveBackgroundColor: theme.getColor(SIDE_BAR_BACKGROUND), + activeBorderBottomColor: theme.getColor(PANEL_ACTIVE_TITLE_BORDER), + activeForegroundColor: theme.getColor(PANEL_ACTIVE_TITLE_FOREGROUND), + inactiveForegroundColor: theme.getColor(PANEL_INACTIVE_TITLE_FOREGROUND), + badgeBackground: theme.getColor(ACTIVITY_BAR_BADGE_BACKGROUND), + badgeForeground: theme.getColor(ACTIVITY_BAR_BADGE_FOREGROUND), + dragAndDropBorder: theme.getColor(PANEL_DRAG_AND_DROP_BORDER) + }), + compact: true + }; + } + + protected shouldShowCompositeBar(): boolean { + return false; + } + + protected getCompositeBarPosition(): CompositeBarPosition { + return CompositeBarPosition.TITLE; + } + + override toJSON(): object { + return { + type: Parts.CHATBAR_PART + }; + } +} diff --git a/src/vs/sessions/browser/parts/editorModal.ts b/src/vs/sessions/browser/parts/editorModal.ts new file mode 100644 index 0000000000000..6b30966f5623c --- /dev/null +++ b/src/vs/sessions/browser/parts/editorModal.ts @@ -0,0 +1,194 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $ } from '../../../base/browser/dom.js'; +import { mainWindow } from '../../../base/browser/window.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { Codicon } from '../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../base/common/themables.js'; +import { Part } from '../../../workbench/browser/part.js'; +import { Parts } from '../../../workbench/services/layout/browser/layoutService.js'; +import { IEditorGroupsService } from '../../../workbench/services/editor/common/editorGroupsService.js'; +import { mark } from '../../../base/common/performance.js'; + +const MODAL_HEADER_HEIGHT = 32; +const MODAL_SIZE_PERCENTAGE = 0.8; +const MODAL_MIN_WIDTH = 400; +const MODAL_MAX_WIDTH = 1200; +const MODAL_MIN_HEIGHT = 300; +const MODAL_MAX_HEIGHT = 900; + +export class EditorModal extends Disposable { + + private readonly _onDidChangeVisibility = this._register(new Emitter()); + readonly onDidChangeVisibility: Event = this._onDidChangeVisibility.event; + + private readonly overlay: HTMLElement; + private readonly container: HTMLElement; + private readonly content: HTMLElement; + + private _visible = false; + get visible(): boolean { return this._visible; } + + private _workbenchWidth = 0; + private _workbenchHeight = 0; + + constructor( + private readonly parentContainer: HTMLElement, + private readonly editorPart: Part, + private readonly editorGroupService: IEditorGroupsService + ) { + super(); + + // Create modal structure + this.overlay = this.createOverlay(); + this.container = this.createContainer(); + this.content = this.createContent(); + + // Assemble the modal + this.container.appendChild(this.content); + this.overlay.appendChild(this.container); + + // Create and add editor part to modal content + this.createEditorPart(); + + // Register keyboard handler + this.registerKeyboardHandler(); + + // Add to parent + this.parentContainer.appendChild(this.overlay); + } + + private createOverlay(): HTMLElement { + const overlay = $('div.editor-modal-overlay'); + + // Create backdrop (clicking closes the modal) + const backdrop = $('div.editor-modal-backdrop'); + backdrop.addEventListener('click', () => this.close()); + overlay.appendChild(backdrop); + + return overlay; + } + + private createContainer(): HTMLElement { + const container = $('div.editor-modal-container'); + container.setAttribute('role', 'dialog'); + container.setAttribute('aria-modal', 'true'); + + // Create header with close button + const header = $('div.editor-modal-header'); + const closeButton = $('button.editor-modal-close-button'); + closeButton.setAttribute('aria-label', 'Close'); + closeButton.title = 'Close (Escape)'; + const closeIcon = $('span'); + closeIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.close)); + closeButton.appendChild(closeIcon); + closeButton.addEventListener('click', () => this.close()); + header.appendChild(closeButton); + container.appendChild(header); + + return container; + } + + private createContent(): HTMLElement { + return $('div.editor-modal-content'); + } + + private createEditorPart(): void { + const editorPartContainer = document.createElement('div'); + editorPartContainer.classList.add('part', 'editor'); + editorPartContainer.id = Parts.EDITOR_PART; + editorPartContainer.setAttribute('role', 'main'); + + mark('code/willCreatePart/workbench.parts.editor'); + this.editorPart.create(editorPartContainer, { restorePreviousState: false }); + mark('code/didCreatePart/workbench.parts.editor'); + + this.content.appendChild(editorPartContainer); + } + + private registerKeyboardHandler(): void { + mainWindow.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this._visible) { + this.close(); + } + }); + } + + show(): void { + if (this._visible) { + return; + } + + this._visible = true; + this.overlay.classList.add('visible'); + + this.doLayout(); + + this._onDidChangeVisibility.fire(true); + } + + hide(): void { + if (!this._visible) { + return; + } + + this._visible = false; + this.overlay.classList.remove('visible'); + + this._onDidChangeVisibility.fire(false); + } + + close(): void { + if (!this._visible) { + return; + } + + // Close all editors in all groups + for (const group of this.editorGroupService.groups) { + group.closeAllEditors(); + } + + // Hide the modal + this.hide(); + } + + layout(workbenchWidth: number, workbenchHeight: number): void { + this._workbenchWidth = workbenchWidth; + this._workbenchHeight = workbenchHeight; + + if (this._visible) { + this.doLayout(); + } + } + + private doLayout(): void { + // Calculate modal dimensions based on workbench size with constraints + const modalWidth = Math.floor( + Math.min(MODAL_MAX_WIDTH, Math.max(MODAL_MIN_WIDTH, this._workbenchWidth * MODAL_SIZE_PERCENTAGE)) + ); + const modalHeight = Math.floor( + Math.min(MODAL_MAX_HEIGHT, Math.max(MODAL_MIN_HEIGHT, this._workbenchHeight * MODAL_SIZE_PERCENTAGE)) + ); + + // Set the modal container dimensions + this.container.style.width = `${modalWidth}px`; + this.container.style.height = `${modalHeight}px`; + + // Calculate content dimensions (subtract header height) + const contentWidth = modalWidth; + const contentHeight = modalHeight - MODAL_HEADER_HEIGHT; + + if (contentWidth > 0 && contentHeight > 0) { + // Explicitly size the content area + this.content.style.width = `${contentWidth}px`; + this.content.style.height = `${contentHeight}px`; + + // Layout the editor part + this.editorPart.layout(contentWidth, contentHeight, 0, 0); + } + } +} diff --git a/src/vs/sessions/browser/parts/media/chatBarPart.css b/src/vs/sessions/browser/parts/media/chatBarPart.css new file mode 100644 index 0000000000000..4db26e2e5b032 --- /dev/null +++ b/src/vs/sessions/browser/parts/media/chatBarPart.css @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-workbench.nochatbar .part.chatbar { + display: none !important; + visibility: hidden !important; +} + +.monaco-workbench .part.chatbar > .content .monaco-editor, +.monaco-workbench .part.chatbar > .content .monaco-editor .margin, +.monaco-workbench .part.chatbar > .content .monaco-editor .monaco-editor-background { + background-color: var(--vscode-sideBar-background); +} + +.monaco-workbench .part.chatbar .title-actions .actions-container { + justify-content: flex-end; +} + +.monaco-workbench .part.chatbar .title-actions .action-item { + margin-right: 4px; +} + +.monaco-workbench .part.chatbar > .title { + background-color: var(--vscode-sideBarTitle-background); +} + +.monaco-workbench .part.chatbar > .title > .title-label { + flex: 1; +} + +.monaco-workbench .part.chatbar > .title > .title-label h2 { + text-transform: uppercase; +} + +.monaco-workbench .part.chatbar > .title > .composite-bar-container { + flex: 1; +} + +.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus { + outline: 0 !important; /* activity bar indicates focus custom */ +} + +.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { + outline-offset: 2px; +} + +.hc-black .monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, +.hc-light .monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { + border-radius: 0px; +} + +.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label::before { + position: absolute; + left: 5px; /* place icon in center */ +} + +.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked:not(:focus) .active-item-indicator:before, +.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked.clicked:focus .active-item-indicator:before { + border-top-color: var(--vscode-panelTitle-activeBorder) !important; +} + +.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:hover .action-label, +.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .action-label { + color: var(--vscode-sideBarTitle-foreground) !important; +} + +.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .action-label, +.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:hover .action-label { + outline: var(--vscode-contrastActiveBorder, unset) solid 1px !important; +} + +.monaco-workbench .part.chatbar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.checked):hover .action-label { + outline: var(--vscode-contrastActiveBorder, unset) dashed 1px !important; +} + +.monaco-workbench .chatbar.part.pane-composite-part > .composite.title > .title-actions { + flex: inherit; +} + +.monaco-workbench .chatbar.pane-composite-part > .title.has-composite-bar > .title-actions .monaco-action-bar .action-item { + max-width: 150px; +} diff --git a/src/vs/sessions/browser/parts/media/projectBarPart.css b/src/vs/sessions/browser/parts/media/projectBarPart.css new file mode 100644 index 0000000000000..70576594a7a3f --- /dev/null +++ b/src/vs/sessions/browser/parts/media/projectBarPart.css @@ -0,0 +1,263 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-workbench .part.projectbar { + width: 48px; + height: 100%; +} + +.monaco-workbench .projectbar.bordered::before { + content: ''; + float: left; + position: absolute; + box-sizing: border-box; + height: 100%; + width: 0px; + border-color: inherit; +} + +.monaco-workbench .projectbar.left.bordered::before { + right: 0; + border-right-style: solid; + border-right-width: 1px; +} + +.monaco-workbench .projectbar > .content { + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.monaco-workbench .projectbar > .content > .actions-container { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + margin-bottom: auto; +} + +/* Action items (both add button and workspace entries) */ +.monaco-workbench .projectbar .action-item { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + cursor: pointer; +} + +.monaco-workbench .projectbar .action-item:focus { + outline: 0 !important; /* project bar indicates focus custom */ +} + +.monaco-workbench .projectbar .action-item:focus-visible::before { + content: ''; + position: absolute; + inset: 6px; + border: 1px solid var(--vscode-focusBorder); + border-radius: 4px; + pointer-events: none; +} + +.monaco-workbench .projectbar .action-item .action-label { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + font-size: 16px; + border-radius: 4px; + color: var(--vscode-activityBar-inactiveForeground); +} + +.monaco-workbench .projectbar .action-item:hover .action-label { + color: var(--vscode-activityBar-foreground); +} + +/* Add folder button */ +.monaco-workbench .projectbar .action-item.add-folder { + margin-bottom: 4px; +} + +.monaco-workbench .projectbar .action-item.add-folder .action-label { + font-size: 20px; +} + +/* Workspace entry icon - shows first letter */ +.monaco-workbench .projectbar .action-item.workspace-entry .action-label.workspace-icon { + font-weight: 600; + font-size: 18px; + text-transform: uppercase; + background-color: var(--vscode-activityBar-inactiveForeground); + color: var(--vscode-activityBar-background); + border-radius: 6px; +} + +.monaco-workbench .projectbar .action-item.workspace-entry:hover .action-label.workspace-icon { + background-color: var(--vscode-activityBar-foreground); +} + +/* Workspace entry with codicon icon */ +.monaco-workbench .projectbar .action-item.workspace-entry .action-label.workspace-icon.codicon-icon { + font-weight: normal; + font-size: 24px; + text-transform: none; + background-color: transparent; + color: var(--vscode-activityBar-inactiveForeground); + border-radius: 0; +} + +.monaco-workbench .projectbar .action-item.workspace-entry:hover .action-label.workspace-icon.codicon-icon { + background-color: transparent; + color: var(--vscode-activityBar-foreground); +} + +.monaco-workbench .projectbar .action-item.workspace-entry.checked .action-label.workspace-icon.codicon-icon { + background-color: transparent; + color: var(--vscode-activityBar-foreground); +} + +/* Selected/checked state */ +.monaco-workbench .projectbar .action-item.workspace-entry.checked .action-label.workspace-icon { + background-color: var(--vscode-activityBar-foreground); + color: var(--vscode-activityBar-background); +} + +/* Active item indicator (vertical bar on the left) */ +.monaco-workbench .projectbar .action-item .active-item-indicator { + position: absolute; + left: 0; + width: 2px; + height: 24px; + background-color: transparent; + border-radius: 0 2px 2px 0; +} + +.monaco-workbench .projectbar .action-item.workspace-entry.checked .active-item-indicator { + background-color: var(--vscode-activityBar-activeBorder, var(--vscode-activityBar-foreground)); +} + +/* Active background for checked items */ +.monaco-workbench .projectbar .action-item.workspace-entry.checked { + background-color: var(--vscode-activityBar-activeBackground); +} + +/* High contrast styling */ +.monaco-workbench.hc-black .projectbar .action-item .action-label, +.monaco-workbench.hc-light .projectbar .action-item .action-label { + padding: 6px; +} + +.monaco-workbench.hc-black .projectbar .action-item.checked .action-label::before, +.monaco-workbench.hc-light .projectbar .action-item.checked .action-label::before { + outline: 1px solid var(--vscode-contrastActiveBorder); +} + +.monaco-workbench.hc-black .projectbar .action-item:hover .action-label::before, +.monaco-workbench.hc-light .projectbar .action-item:hover .action-label::before { + outline: 1px dashed var(--vscode-contrastActiveBorder); +} + +/* ===== Global Composite Bar (Accounts, Settings) at bottom ===== */ + +.monaco-workbench .projectbar > .content > .monaco-action-bar { + text-align: center; + background-color: inherit; +} + +.monaco-workbench .projectbar > .content > .monaco-action-bar .action-item { + display: block; + position: relative; +} + +.monaco-workbench .projectbar > .content > .monaco-action-bar .action-label { + position: relative; + z-index: 1; + display: flex; + overflow: hidden; + width: 48px; + height: 48px; + margin-right: 0; + box-sizing: border-box; +} + +.monaco-workbench .projectbar > .content > .monaco-action-bar .action-label.codicon { + font-size: 24px; + align-items: center; + justify-content: center; + color: var(--vscode-activityBar-inactiveForeground); +} + +.monaco-workbench .projectbar > .content > .monaco-action-bar .action-item.active .action-label.codicon, +.monaco-workbench .projectbar > .content > .monaco-action-bar .action-item:focus .action-label.codicon, +.monaco-workbench .projectbar > .content > .monaco-action-bar .action-item:hover .action-label.codicon { + color: var(--vscode-activityBar-foreground) !important; +} + +.monaco-workbench .projectbar > .content > .monaco-action-bar .action-item:focus { + outline: 0 !important; +} + +.monaco-workbench .projectbar > .content > .monaco-action-bar .profile-badge, +.monaco-workbench .projectbar > .content > .monaco-action-bar .active-item-indicator, +.monaco-workbench .projectbar > .content > .monaco-action-bar .badge { + position: absolute; + top: 0; + bottom: 0; + margin: auto; + left: 0; + overflow: hidden; + width: 100%; + height: 100%; +} + +.monaco-workbench .projectbar > .content > .monaco-action-bar .active-item-indicator, +.monaco-workbench .projectbar > .content > .monaco-action-bar .badge { + z-index: 2; +} + +.monaco-workbench .projectbar > .content > .monaco-action-bar .profile-badge { + z-index: 1; +} + +.monaco-workbench .projectbar > .content > .monaco-action-bar .active-item-indicator { + pointer-events: none; +} + +.monaco-workbench .projectbar > .content > .monaco-action-bar .badge .badge-content { + position: absolute; + top: 24px; + right: 8px; + font-size: 9px; + font-weight: 600; + min-width: 8px; + height: 16px; + line-height: 16px; + padding: 0 4px; + border-radius: 20px; + text-align: center; +} + +.monaco-workbench .projectbar > .content > .monaco-action-bar .profile-badge .profile-text-overlay { + position: absolute; + font-weight: 600; + font-size: 9px; + line-height: 10px; + top: 24px; + right: 6px; + padding: 2px 3px; + border-radius: 7px; + background-color: var(--vscode-profileBadge-background); + color: var(--vscode-profileBadge-foreground); + border: 2px solid var(--vscode-activityBar-background); +} + +.monaco-workbench .projectbar > .content > .monaco-action-bar .action-item:active .profile-text-overlay, +.monaco-workbench .projectbar > .content > .monaco-action-bar .action-item:focus .profile-text-overlay, +.monaco-workbench .projectbar > .content > .monaco-action-bar .action-item:hover .profile-text-overlay { + color: var(--vscode-activityBar-foreground); +} diff --git a/src/vs/sessions/browser/parts/media/sidebarPart.css b/src/vs/sessions/browser/parts/media/sidebarPart.css new file mode 100644 index 0000000000000..1c845edee3ba1 --- /dev/null +++ b/src/vs/sessions/browser/parts/media/sidebarPart.css @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Hide traffic light spacer in fullscreen (traffic lights are not shown) */ +.monaco-workbench.fullscreen .part.sidebar .window-controls-container { + display: none; +} + +/* Sidebar Footer Container */ +.monaco-workbench .part.sidebar > .sidebar-footer { + display: flex; + align-items: center; + padding: 6px; + border-top: 1px solid var(--vscode-sideBarSectionHeader-border, transparent); + flex-shrink: 0; +} + +/* Make the toolbar and its action-item fill the full footer width */ +.monaco-workbench .part.sidebar > .sidebar-footer .monaco-toolbar, +.monaco-workbench .part.sidebar > .sidebar-footer .monaco-action-bar, +.monaco-workbench .part.sidebar > .sidebar-footer .actions-container, +.monaco-workbench .part.sidebar > .sidebar-footer .action-item { + flex: 1; + width: 100%; + max-width: 100%; + cursor: default; +} diff --git a/src/vs/sessions/browser/parts/media/titlebarpart.css b/src/vs/sessions/browser/parts/media/titlebarpart.css new file mode 100644 index 0000000000000..b005dbe50de41 --- /dev/null +++ b/src/vs/sessions/browser/parts/media/titlebarpart.css @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Left Tool Bar Container */ +.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-left > .left-toolbar-container { + display: none; + padding-left: 8px; + flex-grow: 0; + flex-shrink: 0; + text-align: center; + position: relative; + z-index: 2500; + -webkit-app-region: no-drag; + height: 100%; + order: 2; +} + +.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-left > .left-toolbar-container:not(.has-no-actions) { + display: flex; + justify-content: center; + align-items: center; +} + +.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-left > .left-toolbar-container .codicon { + color: inherit; +} + +.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-left > .left-toolbar-container .monaco-action-bar .action-item { + display: flex; +} + +/* TODO: Hack to avoid flicker when sidebar becomes visible. + * The contribution swaps the menu item synchronously, but the toolbar + * re-render is async, causing a brief flash. Hide the container via + * CSS when sidebar is visible (nosidebar class is removed synchronously). */ +.agent-sessions-workbench:not(.nosidebar) .part.titlebar > .titlebar-container > .titlebar-left > .left-toolbar-container { + display: none !important; +} diff --git a/src/vs/sessions/browser/parts/panelPart.ts b/src/vs/sessions/browser/parts/panelPart.ts new file mode 100644 index 0000000000000..c8f0ce71f8bfd --- /dev/null +++ b/src/vs/sessions/browser/parts/panelPart.ts @@ -0,0 +1,201 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import '../../../workbench/browser/parts/panel/media/panelpart.css'; +import { IAction } from '../../../base/common/actions.js'; +import { ActionsOrientation } from '../../../base/browser/ui/actionbar/actionbar.js'; +import { ActivePanelContext, PanelFocusContext } from '../../../workbench/common/contextkeys.js'; +import { IWorkbenchLayoutService, Parts, Position } from '../../../workbench/services/layout/browser/layoutService.js'; +import { IStorageService } from '../../../platform/storage/common/storage.js'; +import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; +import { IKeybindingService } from '../../../platform/keybinding/common/keybinding.js'; +import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; +import { IThemeService } from '../../../platform/theme/common/themeService.js'; +import { PANEL_BACKGROUND, PANEL_BORDER, PANEL_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_INACTIVE_TITLE_FOREGROUND, PANEL_ACTIVE_TITLE_BORDER, PANEL_DRAG_AND_DROP_BORDER, PANEL_TITLE_BADGE_BACKGROUND, PANEL_TITLE_BADGE_FOREGROUND } from '../../../workbench/common/theme.js'; +import { contrastBorder } from '../../../platform/theme/common/colorRegistry.js'; +import { INotificationService } from '../../../platform/notification/common/notification.js'; +import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; +import { assertReturnsDefined } from '../../../base/common/types.js'; +import { IExtensionService } from '../../../workbench/services/extensions/common/extensions.js'; +import { IViewDescriptorService, ViewContainerLocation } from '../../../workbench/common/views.js'; +import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js'; +import { IMenuService } from '../../../platform/actions/common/actions.js'; +import { Menus } from '../menus.js'; +import { AbstractPaneCompositePart, CompositeBarPosition } from '../../../workbench/browser/parts/paneCompositePart.js'; +import { Part } from '../../../workbench/browser/part.js'; +import { IPaneCompositeBarOptions } from '../../../workbench/browser/parts/paneCompositeBar.js'; +import { IHoverService } from '../../../platform/hover/browser/hover.js'; +import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; +import { Extensions } from '../../../workbench/browser/panecomposite.js'; + +/** + * Panel part specifically for agent sessions workbench. + * This is a simplified version of the PanelPart for agent session contexts. + */ +export class PanelPart extends AbstractPaneCompositePart { + + //#region IView + + readonly minimumWidth: number = 300; + readonly maximumWidth: number = Number.POSITIVE_INFINITY; + readonly minimumHeight: number = 77; + readonly maximumHeight: number = Number.POSITIVE_INFINITY; + + get preferredHeight(): number | undefined { + return this.layoutService.mainContainerDimension.height * 0.4; + } + + get preferredWidth(): number | undefined { + const activeComposite = this.getActivePaneComposite(); + + if (!activeComposite) { + return undefined; + } + + const width = activeComposite.getOptimalWidth(); + if (typeof width !== 'number') { + return undefined; + } + + return Math.max(width, 300); + } + + //#endregion + + static readonly activePanelSettingsKey = 'workbench.agentsession.panelpart.activepanelid'; + + /** Visual margin values for the card-like appearance */ + static readonly MARGIN_BOTTOM = 8; + static readonly MARGIN_LEFT = 8; + static readonly MARGIN_RIGHT = 8; + + constructor( + @INotificationService notificationService: INotificationService, + @IStorageService storageService: IStorageService, + @IContextMenuService contextMenuService: IContextMenuService, + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @IKeybindingService keybindingService: IKeybindingService, + @IHoverService hoverService: IHoverService, + @IInstantiationService instantiationService: IInstantiationService, + @IThemeService themeService: IThemeService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IContextKeyService contextKeyService: IContextKeyService, + @IExtensionService extensionService: IExtensionService, + @IMenuService menuService: IMenuService, + @IConfigurationService private readonly configurationService: IConfigurationService + ) { + super( + Parts.PANEL_PART, + { hasTitle: true, trailingSeparator: true }, + PanelPart.activePanelSettingsKey, + ActivePanelContext.bindTo(contextKeyService), + PanelFocusContext.bindTo(contextKeyService), + 'panel', + 'panel', + undefined, + PANEL_TITLE_BORDER, + ViewContainerLocation.Panel, + Extensions.Panels, + Menus.PanelTitle, + undefined, + notificationService, + storageService, + contextMenuService, + layoutService, + keybindingService, + hoverService, + instantiationService, + themeService, + viewDescriptorService, + contextKeyService, + extensionService, + menuService, + ); + + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('workbench.panel.showLabels')) { + this.updateCompositeBar(true); + } + })); + } + + override updateStyles(): void { + super.updateStyles(); + + const container = assertReturnsDefined(this.getContainer()); + + // Store background and border as CSS variables for the card styling on .part + container.style.setProperty('--part-background', this.getColor(PANEL_BACKGROUND) || ''); + container.style.setProperty('--part-border-color', this.getColor(PANEL_BORDER) || this.getColor(contrastBorder) || 'transparent'); + container.style.backgroundColor = 'transparent'; + + // Clear inline borders - the card appearance uses CSS border-radius instead + container.style.borderTopColor = ''; + container.style.borderTopStyle = ''; + container.style.borderTopWidth = ''; + } + + protected getCompositeBarOptions(): IPaneCompositeBarOptions { + return { + partContainerClass: 'panel', + pinnedViewContainersKey: 'workbench.agentsession.panel.pinnedPanels', + placeholderViewContainersKey: 'workbench.agentsession.panel.placeholderPanels', + viewContainersWorkspaceStateKey: 'workbench.agentsession.panel.viewContainersWorkspaceState', + icon: this.configurationService.getValue('workbench.panel.showLabels') === false, + orientation: ActionsOrientation.HORIZONTAL, + recomputeSizes: true, + activityHoverOptions: { + position: () => this.layoutService.getPanelPosition() === Position.BOTTOM && !this.layoutService.isPanelMaximized() ? HoverPosition.ABOVE : HoverPosition.BELOW, + }, + fillExtraContextMenuActions: actions => this.fillExtraContextMenuActions(actions), + compositeSize: 0, + iconSize: 16, + compact: true, + overflowActionSize: 44, + colors: theme => ({ + activeBackgroundColor: theme.getColor(PANEL_BACKGROUND), + inactiveBackgroundColor: theme.getColor(PANEL_BACKGROUND), + activeBorderBottomColor: theme.getColor(PANEL_ACTIVE_TITLE_BORDER), + activeForegroundColor: theme.getColor(PANEL_ACTIVE_TITLE_FOREGROUND), + inactiveForegroundColor: theme.getColor(PANEL_INACTIVE_TITLE_FOREGROUND), + badgeBackground: theme.getColor(PANEL_TITLE_BADGE_BACKGROUND), + badgeForeground: theme.getColor(PANEL_TITLE_BADGE_FOREGROUND), + dragAndDropBorder: theme.getColor(PANEL_DRAG_AND_DROP_BORDER) + }) + }; + } + + private fillExtraContextMenuActions(_actions: IAction[]): void { } + + override layout(width: number, height: number, top: number, left: number): void { + if (!this.layoutService.isVisible(Parts.PANEL_PART)) { + return; + } + + // Layout content with reduced dimensions to account for visual margins + super.layout( + width - PanelPart.MARGIN_LEFT - PanelPart.MARGIN_RIGHT, + height - PanelPart.MARGIN_BOTTOM, + top, left + ); + + // Restore the full grid-allocated dimensions so that Part.relayout() works correctly. + Part.prototype.layout.call(this, width, height, top, left); + } + + protected override shouldShowCompositeBar(): boolean { + return true; + } + + protected getCompositeBarPosition(): CompositeBarPosition { + return CompositeBarPosition.TITLE; + } + + toJSON(): object { + return { + type: Parts.PANEL_PART + }; + } +} diff --git a/src/vs/sessions/browser/parts/parts.ts b/src/vs/sessions/browser/parts/parts.ts new file mode 100644 index 0000000000000..3a1ed5afc86a9 --- /dev/null +++ b/src/vs/sessions/browser/parts/parts.ts @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const enum AgenticParts { + PROJECTBAR_PART = 'workbench.parts.projectbar', +} diff --git a/src/vs/sessions/browser/parts/projectBarPart.ts b/src/vs/sessions/browser/parts/projectBarPart.ts new file mode 100644 index 0000000000000..e902c52e66edd --- /dev/null +++ b/src/vs/sessions/browser/parts/projectBarPart.ts @@ -0,0 +1,584 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/projectBarPart.css'; +import { Part } from '../../../workbench/browser/part.js'; +import { IWorkbenchLayoutService, Position } from '../../../workbench/services/layout/browser/layoutService.js'; +import { IColorTheme, IThemeService } from '../../../platform/theme/common/themeService.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../platform/storage/common/storage.js'; +import { IWorkspaceContextService } from '../../../platform/workspace/common/workspace.js'; +import { IHoverService } from '../../../platform/hover/browser/hover.js'; +import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; +import { $, addDisposableListener, append, clearNode, Dimension, EventType, getActiveDocument, getWindow } from '../../../base/browser/dom.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { ACTIVITY_BAR_BACKGROUND, ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND, ACTIVITY_BAR_BORDER, ACTIVITY_BAR_FOREGROUND, ACTIVITY_BAR_INACTIVE_FOREGROUND } from '../../../workbench/common/theme.js'; +import { contrastBorder } from '../../../platform/theme/common/colorRegistry.js'; +import { assertReturnsDefined } from '../../../base/common/types.js'; +import { ThemeIcon } from '../../../base/common/themables.js'; +import { Codicon } from '../../../base/common/codicons.js'; +import { codiconsLibrary } from '../../../base/common/codiconsLibrary.js'; +import { Lazy } from '../../../base/common/lazy.js'; +import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js'; +import { GlobalCompositeBar } from '../../../workbench/browser/parts/globalCompositeBar.js'; +import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; +import { IAction, Action, Separator } from '../../../base/common/actions.js'; +import { URI } from '../../../base/common/uri.js'; +import { IFileDialogService } from '../../../platform/dialogs/common/dialogs.js'; +import { IPathService } from '../../../workbench/services/path/common/pathService.js'; +import { IWorkspaceEditingService } from '../../../workbench/services/workspaces/common/workspaceEditing.js'; +import { ILabelService } from '../../../platform/label/common/label.js'; +import { basename } from '../../../base/common/resources.js'; +import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; +import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js'; +import { IQuickInputService, IQuickPickItem } from '../../../platform/quickinput/common/quickInput.js'; +import { getIconRegistry, IconContribution } from '../../../platform/theme/common/iconRegistry.js'; +import { defaultInputBoxStyles } from '../../../platform/theme/browser/defaultStyles.js'; +import { WorkbenchIconSelectBox } from '../../../workbench/services/userDataProfile/browser/iconSelectBox.js'; +import { localize } from '../../../nls.js'; +import { AgenticParts } from './parts.js'; + +const HOVER_GROUP_ID = 'projectbar'; +const PROJECT_BAR_FOLDERS_KEY = 'workbench.agentsession.projectbar.folders'; + +type ProjectBarEntryDisplayType = 'letter' | 'icon'; + +interface IProjectBarEntryData { + readonly uri: string; + readonly displayType?: ProjectBarEntryDisplayType; + readonly iconId?: string; +} + +interface IProjectBarEntry { + readonly uri: URI; + readonly name: string; + displayType: ProjectBarEntryDisplayType; + iconId?: string; +} + +const icons = new Lazy(() => { + const iconDefinitions = getIconRegistry().getIcons(); + const includedChars = new Set(); + const dedupedIcons = iconDefinitions.filter(e => { + if (e.id === codiconsLibrary.blank.id) { + return false; + } + if (ThemeIcon.isThemeIcon(e.defaults)) { + return false; + } + if (includedChars.has(e.defaults.fontCharacter)) { + return false; + } + includedChars.add(e.defaults.fontCharacter); + return true; + }); + return dedupedIcons; +}); + +/** + * ProjectBarPart displays project folder entries stored in workspace storage and allows selection between them. + * When a folder is selected, the workspace editing service is used to replace the current workspace folder + * with the selected one. It is positioned to the left of the sidebar and has the same visual style as the activity bar. + * Also includes global activities (accounts, settings) at the bottom. + */ +export class ProjectBarPart extends Part { + + static readonly ACTION_HEIGHT = 48; + + //#region IView + + readonly minimumWidth: number = 48; + readonly maximumWidth: number = 48; + readonly minimumHeight: number = 0; + readonly maximumHeight: number = Number.POSITIVE_INFINITY; + + //#endregion + + private content: HTMLElement | undefined; + private actionsContainer: HTMLElement | undefined; + private addFolderButton: HTMLElement | undefined; + private entries: IProjectBarEntry[] = []; + private _selectedFolderUri: URI | undefined; + private readonly globalCompositeBar: GlobalCompositeBar; + + private readonly workspaceEntryDisposables = this._register(new MutableDisposable()); + + private readonly _onDidSelectWorkspace = this._register(new Emitter()); + readonly onDidSelectWorkspace: Event = this._onDidSelectWorkspace.event; + + constructor( + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @IThemeService themeService: IThemeService, + @IStorageService private readonly storageService: IStorageService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @IFileDialogService private readonly fileDialogService: IFileDialogService, + @IPathService private readonly pathService: IPathService, + @IWorkspaceEditingService private readonly workspaceEditingService: IWorkspaceEditingService, + @ILabelService private readonly labelService: ILabelService, + @IHoverService private readonly hoverService: IHoverService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IQuickInputService private readonly quickInputService: IQuickInputService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(AgenticParts.PROJECTBAR_PART, { hasTitle: false }, themeService, storageService, layoutService); + + // Create the global composite bar for accounts and settings at the bottom + this.globalCompositeBar = this._register(instantiationService.createInstance( + GlobalCompositeBar, + () => this.getContextMenuActions(), + (theme: IColorTheme) => ({ + activeForegroundColor: theme.getColor(ACTIVITY_BAR_FOREGROUND), + inactiveForegroundColor: theme.getColor(ACTIVITY_BAR_INACTIVE_FOREGROUND), + badgeBackground: theme.getColor(ACTIVITY_BAR_BADGE_BACKGROUND), + badgeForeground: theme.getColor(ACTIVITY_BAR_BADGE_FOREGROUND), + activeBackgroundColor: undefined, + inactiveBackgroundColor: undefined, + activeBorderBottomColor: undefined, + }), + { + position: () => this.layoutService.getSideBarPosition() === Position.LEFT ? HoverPosition.RIGHT : HoverPosition.LEFT, + } + )); + + // Load entries from storage + this.loadEntriesFromStorage(); + } + + private getContextMenuActions(): IAction[] { + return this.globalCompositeBar.getContextMenuActions(); + } + + private loadEntriesFromStorage(): void { + const raw = this.storageService.get(PROJECT_BAR_FOLDERS_KEY, StorageScope.WORKSPACE); + if (raw) { + try { + const data: (string | IProjectBarEntryData)[] = JSON.parse(raw); + this.entries = data.map(item => { + // Support legacy format (just URIs as strings) and new format (objects with display settings) + if (typeof item === 'string') { + const uri = URI.parse(item); + return { uri, name: basename(uri), displayType: 'letter' as ProjectBarEntryDisplayType }; + } else { + const uri = URI.parse(item.uri); + return { + uri, + name: basename(uri), + displayType: item.displayType ?? 'letter', + iconId: item.iconId + }; + } + }); + } catch { + this.entries = []; + } + } else { + this.entries = []; + } + + // The selected folder is always the first workspace folder + const currentFolders = this.workspaceContextService.getWorkspace().folders; + this._selectedFolderUri = currentFolders.length > 0 ? currentFolders[0].uri : undefined; + } + + private saveEntriesToStorage(): void { + const data: IProjectBarEntryData[] = this.entries.map(e => ({ + uri: e.uri.toString(), + displayType: e.displayType, + iconId: e.iconId + })); + this.storageService.store(PROJECT_BAR_FOLDERS_KEY, JSON.stringify(data), StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + + private addFolderEntry(uri: URI): void { + // Don't add duplicates + if (this.entries.some(e => e.uri.toString() === uri.toString())) { + return; + } + + this.entries.push({ uri, name: basename(uri), displayType: 'letter' }); + this.saveEntriesToStorage(); + + // Select the newly added folder + this._selectedFolderUri = uri; + this.saveEntriesToStorage(); + this.applySelectedFolder(); + this._onDidSelectWorkspace.fire(this._selectedFolderUri); + + this.renderContent(); + } + + private async applySelectedFolder(): Promise { + if (!this._selectedFolderUri) { + return; + } + + const currentFolders = this.workspaceContextService.getWorkspace().folders; + const foldersToRemove = currentFolders.map(f => f.uri); + + // Remove existing workspace folders and add the selected one + await this.workspaceEditingService.updateFolders( + 0, + foldersToRemove.length, + [{ uri: this._selectedFolderUri }] + ); + } + + protected override createContentArea(parent: HTMLElement): HTMLElement { + this.element = parent; + this.content = append(this.element, $('.content')); + + // Create actions container for workspace folders and add button + this.actionsContainer = append(this.content, $('.actions-container')); + + // Create the UI for workspace folders + this.renderContent(); + + // Create global composite bar at the bottom (accounts, settings) + this.globalCompositeBar.create(this.content); + + return this.content; + } + + private renderContent(): void { + if (!this.actionsContainer) { + return; + } + + // Clear existing content + clearNode(this.actionsContainer); + this.workspaceEntryDisposables.value = new DisposableStore(); + + // Create add folder button + this.createAddFolderButton(this.actionsContainer); + + // Create workspace folder entries + this.createWorkspaceEntries(this.actionsContainer); + } + + private createAddFolderButton(container: HTMLElement): void { + this.addFolderButton = append(container, $('.action-item.add-folder')); + const actionLabel = append(this.addFolderButton, $('span.action-label')); + + // Add the plus icon using codicon + actionLabel.classList.add(...ThemeIcon.asClassNameArray(Codicon.add)); + + // Add hover tooltip + this.workspaceEntryDisposables.value?.add( + this.hoverService.setupDelayedHover( + this.addFolderButton, + { + appearance: { showPointer: true }, + position: { hoverPosition: HoverPosition.RIGHT }, + content: 'Add Folder to Project' + }, + { groupId: HOVER_GROUP_ID } + ) + ); + + // Click handler to add folder + this.workspaceEntryDisposables.value?.add( + addDisposableListener(this.addFolderButton, EventType.CLICK, () => { + this.pickAndAddFolder(); + }) + ); + + // Keyboard support + this.addFolderButton.setAttribute('tabindex', '0'); + this.addFolderButton.setAttribute('role', 'button'); + this.addFolderButton.setAttribute('aria-label', 'Add Folder to Project'); + this.workspaceEntryDisposables.value?.add( + addDisposableListener(this.addFolderButton, EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.pickAndAddFolder(); + } + }) + ); + } + + private async pickAndAddFolder(): Promise { + const folders = await this.fileDialogService.showOpenDialog({ + openLabel: 'Add', + title: 'Add Folder to Project', + canSelectFolders: true, + canSelectMany: false, + defaultUri: await this.fileDialogService.defaultFolderPath(), + availableFileSystems: [this.pathService.defaultUriScheme] + }); + + if (folders?.length) { + this.addFolderEntry(folders[0]); + } + } + + private createWorkspaceEntries(container: HTMLElement): void { + for (let i = 0; i < this.entries.length; i++) { + this.createWorkspaceEntry(container, this.entries[i], i); + } + + // Auto-select first entry if available and none selected + if (this.entries.length > 0 && this._selectedFolderUri) { + this._onDidSelectWorkspace.fire(this._selectedFolderUri); + } + } + + private createWorkspaceEntry(container: HTMLElement, entry: IProjectBarEntry, index: number): void { + const entryDisposables = this.workspaceEntryDisposables.value!; + + const entryElement = append(container, $('.action-item.workspace-entry')); + const actionLabel = append(entryElement, $('span.action-label.workspace-icon')); + append(entryElement, $('span.active-item-indicator')); + + // Render based on display type + const folderName = entry.name; + if (entry.displayType === 'icon' && entry.iconId) { + // Render codicon + const icon = ThemeIcon.fromId(entry.iconId); + actionLabel.classList.add(...ThemeIcon.asClassNameArray(icon)); + actionLabel.classList.add('codicon-icon'); + actionLabel.textContent = ''; + } else { + // Default: render first letter of folder name + const firstLetter = folderName.charAt(0).toUpperCase(); + actionLabel.textContent = firstLetter; + } + + // Set selected state + const isSelected = this._selectedFolderUri?.toString() === entry.uri.toString(); + if (isSelected) { + entryElement.classList.add('checked'); + } + + // Build hover content with full path + const folderPath = this.labelService.getUriLabel(entry.uri, { relative: false }); + + // Add hover tooltip with folder name + entryDisposables.add( + this.hoverService.setupDelayedHover( + entryElement, + { + appearance: { showPointer: true }, + position: { hoverPosition: HoverPosition.RIGHT }, + content: folderPath + }, + { groupId: HOVER_GROUP_ID } + ) + ); + + // Click handler to select workspace + entryDisposables.add( + addDisposableListener(entryElement, EventType.CLICK, () => { + this.selectWorkspace(index); + }) + ); + + // Keyboard support + entryElement.setAttribute('tabindex', '0'); + entryElement.setAttribute('role', 'button'); + entryElement.setAttribute('aria-label', folderName); + entryElement.setAttribute('aria-pressed', isSelected ? 'true' : 'false'); + entryDisposables.add( + addDisposableListener(entryElement, EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.selectWorkspace(index); + } + }) + ); + + // Context menu with customize and remove actions + entryDisposables.add( + addDisposableListener(entryElement, EventType.CONTEXT_MENU, (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const event = new StandardMouseEvent(getWindow(entryElement), e); + this.contextMenuService.showContextMenu({ + getAnchor: () => event, + getActions: () => [ + new Action('projectbar.customize', localize('projectbar.customize', "Customize"), undefined, true, () => this.showCustomizeQuickPick(index)), + new Separator(), + new Action('projectbar.removeFolder', localize('projectbar.removeFolder', "Remove Folder"), undefined, true, () => this.removeFolderEntry(index)) + ] + }); + }) + ); + } + + private selectWorkspace(index: number): void { + if (index < 0 || index >= this.entries.length) { + return; + } + + const entry = this.entries[index]; + if (this._selectedFolderUri?.toString() === entry.uri.toString()) { + return; // Already selected + } + + this._selectedFolderUri = entry.uri; + this.saveEntriesToStorage(); + + // Re-render to update visual state + this.renderContent(); + + // Apply the selected folder as the workspace folder + this.applySelectedFolder(); + + // Fire selection event + this._onDidSelectWorkspace.fire(this._selectedFolderUri); + } + + private removeFolderEntry(index: number): void { + if (index < 0 || index >= this.entries.length) { + return; + } + + const removedUri = this.entries[index].uri; + this.entries.splice(index, 1); + this.saveEntriesToStorage(); + + // If the removed entry was the selected one, select the first remaining entry + if (this._selectedFolderUri?.toString() === removedUri.toString()) { + if (this.entries.length > 0) { + this._selectedFolderUri = this.entries[0].uri; + this.applySelectedFolder(); + this._onDidSelectWorkspace.fire(this._selectedFolderUri); + } else { + this._selectedFolderUri = undefined; + this._onDidSelectWorkspace.fire(undefined); + } + } + + this.renderContent(); + } + + private async showCustomizeQuickPick(index: number): Promise { + if (index < 0 || index >= this.entries.length) { + return; + } + + const entry = this.entries[index]; + + interface ICustomizeQuickPickItem extends IQuickPickItem { + customType: 'letter' | 'icon'; + } + + const items: ICustomizeQuickPickItem[] = [ + { + customType: 'letter', + label: localize('projectbar.customize.letter', "Letter"), + description: localize('projectbar.customize.letter.description', "Show the first letter of the workspace name") + }, + { + customType: 'icon', + label: localize('projectbar.customize.icon', "Icon"), + description: localize('projectbar.customize.icon.description', "Choose a codicon to represent the workspace") + } + ]; + + const picked = await this.quickInputService.pick(items, { + placeHolder: localize('projectbar.customize.placeholder', "Choose how to display the workspace in the project bar"), + title: localize('projectbar.customize.title', "Customize Workspace Appearance") + }); + + if (!picked) { + return; + } + + if (picked.customType === 'letter') { + entry.displayType = 'letter'; + entry.iconId = undefined; + this.saveEntriesToStorage(); + this.renderContent(); + } else if (picked.customType === 'icon') { + const icon = await this.pickIcon(); + if (icon) { + entry.displayType = 'icon'; + entry.iconId = icon.id; + this.saveEntriesToStorage(); + this.renderContent(); + } + } + } + + private async pickIcon(): Promise { + const iconSelectBox = this.instantiationService.createInstance(WorkbenchIconSelectBox, { + icons: icons.value, + inputBoxStyles: defaultInputBoxStyles + }); + + const dimension = new Dimension(486, 260); + return new Promise(resolve => { + const disposables = new DisposableStore(); + + disposables.add(iconSelectBox.onDidSelect(e => { + resolve(e); + disposables.dispose(); + iconSelectBox.dispose(); + })); + + iconSelectBox.clearInput(); + const body = getActiveDocument().body; + const bodyRect = body.getBoundingClientRect(); + const hoverWidget = this.hoverService.showInstantHover({ + content: iconSelectBox.domNode, + target: { + targetElements: [body], + x: bodyRect.left + (bodyRect.width - dimension.width) / 2, + y: bodyRect.top + this.layoutService.activeContainerOffset.top + }, + position: { + hoverPosition: HoverPosition.BELOW, + }, + persistence: { + sticky: true, + }, + }, true); + + if (hoverWidget) { + disposables.add(hoverWidget); + } + + iconSelectBox.layout(dimension); + iconSelectBox.focus(); + }); + } + + get selectedWorkspaceFolder(): URI | undefined { + return this._selectedFolderUri; + } + + override updateStyles(): void { + super.updateStyles(); + + const container = assertReturnsDefined(this.getContainer()); + const background = this.getColor(ACTIVITY_BAR_BACKGROUND) || ''; + container.style.backgroundColor = background; + + const borderColor = this.getColor(ACTIVITY_BAR_BORDER) || this.getColor(contrastBorder) || ''; + container.classList.toggle('bordered', !!borderColor); + container.style.borderColor = borderColor ? borderColor : ''; + } + + focus(): void { + // Focus the add folder button (first focusable element) + this.addFolderButton?.focus(); + } + + focusGlobalCompositeBar(): void { + this.globalCompositeBar.focus(); + } + + override layout(width: number, height: number): void { + super.layout(width, height, 0, 0); + + // The global composite bar takes some height at the bottom + // The actions container will take the remaining space due to CSS flex layout + } + + toJSON(): object { + return { + type: AgenticParts.PROJECTBAR_PART + }; + } +} diff --git a/src/vs/sessions/browser/parts/sidebarPart.ts b/src/vs/sessions/browser/parts/sidebarPart.ts new file mode 100644 index 0000000000000..fcae124b6e1ec --- /dev/null +++ b/src/vs/sessions/browser/parts/sidebarPart.ts @@ -0,0 +1,274 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import '../../../workbench/browser/parts/sidebar/media/sidebarpart.css'; +import './media/sidebarPart.css'; +import { IWorkbenchLayoutService, Parts, Position as SideBarPosition } from '../../../workbench/services/layout/browser/layoutService.js'; +import { SidebarFocusContext, ActiveViewletContext } from '../../../workbench/common/contextkeys.js'; +import { IStorageService } from '../../../platform/storage/common/storage.js'; +import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; +import { IKeybindingService } from '../../../platform/keybinding/common/keybinding.js'; +import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; +import { IThemeService } from '../../../platform/theme/common/themeService.js'; +import { SIDE_BAR_TITLE_FOREGROUND, SIDE_BAR_TITLE_BORDER, SIDE_BAR_BACKGROUND, SIDE_BAR_FOREGROUND, SIDE_BAR_BORDER, SIDE_BAR_DRAG_AND_DROP_BACKGROUND, ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND, ACTIVITY_BAR_TOP_FOREGROUND, ACTIVITY_BAR_TOP_ACTIVE_BORDER, ACTIVITY_BAR_TOP_INACTIVE_FOREGROUND, ACTIVITY_BAR_TOP_DRAG_AND_DROP_BORDER } from '../../../workbench/common/theme.js'; +import { contrastBorder } from '../../../platform/theme/common/colorRegistry.js'; +import { INotificationService } from '../../../platform/notification/common/notification.js'; +import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; +import { AnchorAlignment } from '../../../base/browser/ui/contextview/contextview.js'; +import { IExtensionService } from '../../../workbench/services/extensions/common/extensions.js'; +import { LayoutPriority } from '../../../base/browser/ui/grid/grid.js'; +import { assertReturnsDefined } from '../../../base/common/types.js'; +import { IViewDescriptorService, ViewContainerLocation } from '../../../workbench/common/views.js'; +import { AbstractPaneCompositePart, CompositeBarPosition } from '../../../workbench/browser/parts/paneCompositePart.js'; +import { ICompositeTitleLabel } from '../../../workbench/browser/parts/compositePart.js'; +import { Part } from '../../../workbench/browser/part.js'; +import { ActionsOrientation } from '../../../base/browser/ui/actionbar/actionbar.js'; +import { HoverPosition } from '../../../base/browser/ui/hover/hoverWidget.js'; +import { IPaneCompositeBarOptions } from '../../../workbench/browser/parts/paneCompositeBar.js'; +import { IMenuService } from '../../../platform/actions/common/actions.js'; +import { Separator } from '../../../base/common/actions.js'; +import { IHoverService } from '../../../platform/hover/browser/hover.js'; +import { Extensions } from '../../../workbench/browser/panecomposite.js'; +import { Menus } from '../menus.js'; +import { $, append, getWindowId, prepend } from '../../../base/browser/dom.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../platform/actions/browser/toolbar.js'; +import { isMacintosh, isNative } from '../../../base/common/platform.js'; +import { isFullscreen, onDidChangeFullscreen } from '../../../base/browser/browser.js'; +import { mainWindow } from '../../../base/browser/window.js'; + +/** + * Sidebar part specifically for agent sessions workbench. + * This is a simplified version of the SidebarPart for agent session contexts. + */ +export class SidebarPart extends AbstractPaneCompositePart { + + static readonly activeViewletSettingsKey = 'workbench.agentsession.sidebar.activeviewletid'; + static readonly pinnedViewContainersKey = 'workbench.agentsession.pinnedViewlets2'; + static readonly placeholderViewContainersKey = 'workbench.agentsession.placeholderViewlets'; + static readonly viewContainersWorkspaceStateKey = 'workbench.agentsession.viewletsWorkspaceState'; + + /** Visual margin values - sidebar is flush (no card appearance) */ + static readonly MARGIN_TOP = 0; + static readonly MARGIN_BOTTOM = 0; + static readonly MARGIN_LEFT = 0; + static readonly FOOTER_HEIGHT = 39; + + + //#region IView + + readonly minimumWidth: number = 170; + readonly maximumWidth: number = Number.POSITIVE_INFINITY; + readonly minimumHeight: number = 0; + readonly maximumHeight: number = Number.POSITIVE_INFINITY; + override get snap(): boolean { return true; } + + readonly priority: LayoutPriority = LayoutPriority.Low; + + get preferredWidth(): number | undefined { + const viewlet = this.getActivePaneComposite(); + + if (!viewlet) { + return undefined; + } + + const width = viewlet.getOptimalWidth(); + if (typeof width !== 'number') { + return undefined; + } + + return Math.max(width, 300); + } + + //#endregion + + constructor( + @INotificationService notificationService: INotificationService, + @IStorageService storageService: IStorageService, + @IContextMenuService contextMenuService: IContextMenuService, + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @IKeybindingService keybindingService: IKeybindingService, + @IHoverService hoverService: IHoverService, + @IInstantiationService instantiationService: IInstantiationService, + @IThemeService themeService: IThemeService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IContextKeyService contextKeyService: IContextKeyService, + @IExtensionService extensionService: IExtensionService, + @IMenuService menuService: IMenuService, + ) { + super( + Parts.SIDEBAR_PART, + { hasTitle: true, trailingSeparator: false, borderWidth: () => 0 }, + SidebarPart.activeViewletSettingsKey, + ActiveViewletContext.bindTo(contextKeyService), + SidebarFocusContext.bindTo(contextKeyService), + 'sideBar', + 'viewlet', + SIDE_BAR_TITLE_FOREGROUND, + SIDE_BAR_TITLE_BORDER, + ViewContainerLocation.Sidebar, + Extensions.Viewlets, + Menus.SidebarTitle, + Menus.TitleBarLeft, + notificationService, + storageService, + contextMenuService, + layoutService, + keybindingService, + hoverService, + instantiationService, + themeService, + viewDescriptorService, + contextKeyService, + extensionService, + menuService, + ); + } + + override create(parent: HTMLElement): void { + super.create(parent); + this.createFooter(parent); + } + + protected override createTitleArea(parent: HTMLElement): HTMLElement | undefined { + const titleArea = super.createTitleArea(parent); + + // macOS native: the sidebar spans full height and the traffic lights + // overlay the top-left corner. Add a fixed-width spacer inside the + // title area to push content horizontally past the traffic lights. + if (titleArea && isMacintosh && isNative) { + const spacer = $('div.window-controls-container'); + spacer.style.width = '70px'; + spacer.style.height = '100%'; + spacer.style.flexShrink = '0'; + spacer.style.order = '-1'; // match global-actions-left order so DOM order is respected + prepend(titleArea, spacer); + + // Hide spacer in fullscreen (traffic lights are not shown) + const updateSpacerVisibility = () => { + spacer.style.display = isFullscreen(mainWindow) ? 'none' : ''; + }; + updateSpacerVisibility(); + this._register(onDidChangeFullscreen(windowId => { + if (windowId === getWindowId(mainWindow)) { + updateSpacerVisibility(); + } + })); + } + + return titleArea; + } + + private createFooter(parent: HTMLElement): void { + const footer = append(parent, $('.sidebar-footer')); + + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, footer, Menus.SidebarFooter, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + toolbarOptions: { primaryGroup: () => true }, + telemetrySource: 'sidebarFooter', + })); + } + + override updateStyles(): void { + super.updateStyles(); + + const container = assertReturnsDefined(this.getContainer()); + + container.style.backgroundColor = this.getColor(SIDE_BAR_BACKGROUND) || ''; + container.style.color = this.getColor(SIDE_BAR_FOREGROUND) || ''; + container.style.outlineColor = this.getColor(SIDE_BAR_DRAG_AND_DROP_BACKGROUND) ?? ''; + + // Right border to separate from the right section + const borderColor = this.getColor(SIDE_BAR_BORDER) || this.getColor(contrastBorder) || ''; + container.style.borderRightWidth = borderColor ? '1px' : ''; + container.style.borderRightStyle = borderColor ? 'solid' : ''; + container.style.borderRightColor = borderColor; + } + + override layout(width: number, height: number, top: number, left: number): void { + if (!this.layoutService.isVisible(Parts.SIDEBAR_PART)) { + return; + } + + // Layout content with reduced height to account for footer + super.layout( + width, + height - SidebarPart.FOOTER_HEIGHT, + top, left + ); + + // Restore the full grid-allocated dimensions so that Part.relayout() works correctly. + Part.prototype.layout.call(this, width, height, top, left); + } + + protected override getTitleAreaDropDownAnchorAlignment(): AnchorAlignment { + return this.layoutService.getSideBarPosition() === SideBarPosition.LEFT ? AnchorAlignment.LEFT : AnchorAlignment.RIGHT; + } + + protected override createTitleLabel(_parent: HTMLElement): ICompositeTitleLabel { + // No title label in agent sessions sidebar + return { + updateTitle: () => { }, + updateStyles: () => { } + }; + } + + protected getCompositeBarOptions(): IPaneCompositeBarOptions { + return { + partContainerClass: 'sidebar', + pinnedViewContainersKey: SidebarPart.pinnedViewContainersKey, + placeholderViewContainersKey: SidebarPart.placeholderViewContainersKey, + viewContainersWorkspaceStateKey: SidebarPart.viewContainersWorkspaceStateKey, + icon: false, + orientation: ActionsOrientation.HORIZONTAL, + recomputeSizes: true, + activityHoverOptions: { + position: () => this.getCompositeBarPosition() === CompositeBarPosition.BOTTOM ? HoverPosition.ABOVE : HoverPosition.BELOW, + }, + fillExtraContextMenuActions: actions => { + if (this.getCompositeBarPosition() === CompositeBarPosition.TITLE) { + const viewsSubmenuAction = this.getViewsSubmenuAction(); + if (viewsSubmenuAction) { + actions.push(new Separator()); + actions.push(viewsSubmenuAction); + } + } + }, + compositeSize: 0, + iconSize: 16, + overflowActionSize: 30, + colors: theme => ({ + activeBackgroundColor: theme.getColor(SIDE_BAR_BACKGROUND), + inactiveBackgroundColor: theme.getColor(SIDE_BAR_BACKGROUND), + activeBorderBottomColor: theme.getColor(ACTIVITY_BAR_TOP_ACTIVE_BORDER), + activeForegroundColor: theme.getColor(ACTIVITY_BAR_TOP_FOREGROUND), + inactiveForegroundColor: theme.getColor(ACTIVITY_BAR_TOP_INACTIVE_FOREGROUND), + badgeBackground: theme.getColor(ACTIVITY_BAR_BADGE_BACKGROUND), + badgeForeground: theme.getColor(ACTIVITY_BAR_BADGE_FOREGROUND), + dragAndDropBorder: theme.getColor(ACTIVITY_BAR_TOP_DRAG_AND_DROP_BORDER) + }), + compact: true + }; + } + + protected shouldShowCompositeBar(): boolean { + return false; + } + + protected getCompositeBarPosition(): CompositeBarPosition { + return CompositeBarPosition.TITLE; + } + + async focusActivityBar(): Promise { + if (this.shouldShowCompositeBar()) { + this.focusCompositeBar(); + } + } + + toJSON(): object { + return { + type: Parts.SIDEBAR_PART + }; + } +} diff --git a/src/vs/sessions/browser/parts/titlebarPart.ts b/src/vs/sessions/browser/parts/titlebarPart.ts new file mode 100644 index 0000000000000..b2231dd47b821 --- /dev/null +++ b/src/vs/sessions/browser/parts/titlebarPart.ts @@ -0,0 +1,419 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import '../../../workbench/browser/parts/titlebar/media/titlebarpart.css'; +import './media/titlebarpart.css'; +import { MultiWindowParts, Part } from '../../../workbench/browser/part.js'; +import { ITitleService } from '../../../workbench/services/title/browser/titleService.js'; +import { getZoomFactor, isWCOEnabled, getWCOTitlebarAreaRect, isFullscreen, onDidChangeFullscreen } from '../../../base/browser/browser.js'; +import { hasCustomTitlebar, hasNativeTitlebar, DEFAULT_CUSTOM_TITLEBAR_HEIGHT, TitlebarStyle, getTitleBarStyle, getWindowControlsStyle, WindowControlsStyle } from '../../../platform/window/common/window.js'; +import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; +import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js'; +import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; +import { DisposableStore } from '../../../base/common/lifecycle.js'; +import { IThemeService } from '../../../platform/theme/common/themeService.js'; +import { TITLE_BAR_ACTIVE_BACKGROUND, TITLE_BAR_ACTIVE_FOREGROUND, TITLE_BAR_INACTIVE_FOREGROUND, TITLE_BAR_INACTIVE_BACKGROUND, TITLE_BAR_BORDER, WORKBENCH_BACKGROUND } from '../../../workbench/common/theme.js'; +import { isMacintosh, isWeb, isNative, platformLocale } from '../../../base/common/platform.js'; +import { Color } from '../../../base/common/color.js'; +import { EventType, EventHelper, Dimension, append, $, addDisposableListener, prepend, getWindow, getWindowId } from '../../../base/browser/dom.js'; +import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { IStorageService } from '../../../platform/storage/common/storage.js'; +import { Parts, IWorkbenchLayoutService } from '../../../workbench/services/layout/browser/layoutService.js'; + +import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; +import { IHostService } from '../../../workbench/services/host/browser/host.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../platform/actions/browser/toolbar.js'; +import { IEditorGroupsContainer } from '../../../workbench/services/editor/common/editorGroupsService.js'; +import { CodeWindow, mainWindow } from '../../../base/browser/window.js'; +import { safeIntl } from '../../../base/common/date.js'; +import { ITitlebarPart, ITitleProperties, ITitleVariable, IAuxiliaryTitlebarPart } from '../../../workbench/browser/parts/titlebar/titlebarPart.js'; +import { Menus } from '../menus.js'; + +/** + * Simplified agent sessions titlebar part. + * + * Three sections driven entirely by menus: + * - **Left**: `Menus.TitleBarLeft` toolbar + * - **Center**: `Menus.CommandCenter` toolbar (renders session picker via IActionViewItemService) + * - **Right**: `Menus.TitleBarRight` toolbar (includes account submenu) + * + * No menubar, no editor actions, no layout controls, no WindowTitle dependency. + */ +export class TitlebarPart extends Part implements ITitlebarPart { + + //#region IView + + readonly minimumWidth: number = 0; + readonly maximumWidth: number = Number.POSITIVE_INFINITY; + + get minimumHeight(): number { + const wcoEnabled = isWeb && isWCOEnabled(); + let value = DEFAULT_CUSTOM_TITLEBAR_HEIGHT; + if (wcoEnabled) { + value = Math.max(value, getWCOTitlebarAreaRect(getWindow(this.element))?.height ?? 0); + } + + return value / (this.preventZoom ? getZoomFactor(getWindow(this.element)) : 1); + } + + get maximumHeight(): number { return this.minimumHeight; } + + //#endregion + + //#region Events + + private readonly _onMenubarVisibilityChange = this._register(new Emitter()); + readonly onMenubarVisibilityChange = this._onMenubarVisibilityChange.event; + + private readonly _onWillDispose = this._register(new Emitter()); + readonly onWillDispose = this._onWillDispose.event; + + //#endregion + + private rootContainer!: HTMLElement; + private windowControlsContainer: HTMLElement | undefined; + + private leftContent!: HTMLElement; + private centerContent!: HTMLElement; + private rightContent!: HTMLElement; + + private readonly titleBarStyle: TitlebarStyle; + private isInactive: boolean = false; + + constructor( + id: string, + targetWindow: CodeWindow, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IConfigurationService protected readonly configurationService: IConfigurationService, + @IInstantiationService protected readonly instantiationService: IInstantiationService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IHostService private readonly hostService: IHostService, + ) { + super(id, { hasTitle: false }, themeService, storageService, layoutService); + + this.titleBarStyle = getTitleBarStyle(this.configurationService); + + this.registerListeners(getWindowId(targetWindow)); + } + + private registerListeners(targetWindowId: number): void { + this._register(this.hostService.onDidChangeFocus(focused => focused ? this.onFocus() : this.onBlur())); + this._register(this.hostService.onDidChangeActiveWindow(windowId => windowId === targetWindowId ? this.onFocus() : this.onBlur())); + } + + private onBlur(): void { + this.isInactive = true; + this.updateStyles(); + } + + private onFocus(): void { + this.isInactive = false; + this.updateStyles(); + } + + updateProperties(_properties: ITitleProperties): void { + // No window title to update in simplified titlebar + } + + registerVariables(_variables: ITitleVariable[]): void { + // No window title variables in simplified titlebar + } + + updateOptions(_options: { compact: boolean }): void { + // No compact mode support in agent sessions titlebar + } + + protected override createContentArea(parent: HTMLElement): HTMLElement { + this.element = parent; + this.rootContainer = append(parent, $('.titlebar-container.has-center')); + + // Draggable region + prepend(this.rootContainer, $('div.titlebar-drag-region')); + + this.leftContent = append(this.rootContainer, $('.titlebar-left')); + this.centerContent = append(this.rootContainer, $('.titlebar-center')); + this.rightContent = append(this.rootContainer, $('.titlebar-right')); + + // Window Controls Container (must be before left toolbar for correct ordering) + if (!hasNativeTitlebar(this.configurationService, this.titleBarStyle)) { + let primaryWindowControlsLocation = isMacintosh ? 'left' : 'right'; + if (isMacintosh && isNative) { + const localeInfo = safeIntl.Locale(platformLocale).value; + const textInfo = (localeInfo as { textInfo?: { direction?: string } }).textInfo; + if (textInfo?.direction === 'rtl') { + primaryWindowControlsLocation = 'right'; + } + } + + if (isMacintosh && isNative && primaryWindowControlsLocation === 'left') { + // macOS native: traffic lights are rendered by the OS at the top-left corner. + // Add a fixed-width spacer to push content past the traffic lights. + const spacer = append(this.leftContent, $('div.window-controls-container')); + spacer.style.width = '70px'; + spacer.style.flexShrink = '0'; + + // Hide spacer in fullscreen (traffic lights are not shown) + const updateSpacerVisibility = () => { + spacer.style.display = isFullscreen(mainWindow) ? 'none' : ''; + }; + updateSpacerVisibility(); + this._register(onDidChangeFullscreen(windowId => { + if (windowId === getWindowId(mainWindow)) { + updateSpacerVisibility(); + } + })); + } else if (getWindowControlsStyle(this.configurationService) === WindowControlsStyle.HIDDEN) { + // controls explicitly disabled + } else { + this.windowControlsContainer = append(primaryWindowControlsLocation === 'left' ? this.leftContent : this.rightContent, $('div.window-controls-container')); + if (isWeb) { + append(primaryWindowControlsLocation === 'left' ? this.rightContent : this.leftContent, $('div.window-controls-container')); + } + + if (isWCOEnabled()) { + this.windowControlsContainer.classList.add('wco-enabled'); + } + } + } + + // Left toolbar (driven by Menus.TitleBarLeft, rendered after window controls via CSS order) + const leftToolbarContainer = append(this.leftContent, $('div.left-toolbar-container')); + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, leftToolbarContainer, Menus.TitleBarLeft, { + contextMenu: Menus.TitleBarContext, + telemetrySource: 'titlePart.left', + hiddenItemStrategy: HiddenItemStrategy.NoHide, + toolbarOptions: { primaryGroup: () => true }, + })); + + // Center toolbar - command center (renders session picker via IActionViewItemService) + // Uses .window-title > .command-center nesting to match default workbench CSS selectors + const windowTitle = append(this.centerContent, $('div.window-title')); + const centerToolbarContainer = append(windowTitle, $('div.command-center')); + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, centerToolbarContainer, Menus.CommandCenter, { + contextMenu: Menus.TitleBarContext, + hiddenItemStrategy: HiddenItemStrategy.NoHide, + telemetrySource: 'commandCenter', + toolbarOptions: { primaryGroup: () => true }, + })); + + // Right toolbar (driven by Menus.TitleBarRight - includes account submenu) + const rightToolbarContainer = append(this.rightContent, $('div.action-toolbar-container')); + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, rightToolbarContainer, Menus.TitleBarRight, { + contextMenu: Menus.TitleBarContext, + telemetrySource: 'titlePart.right', + toolbarOptions: { primaryGroup: () => true }, + })); + + // Context menu on the titlebar + this._register(addDisposableListener(this.rootContainer, EventType.CONTEXT_MENU, e => { + EventHelper.stop(e); + this.onContextMenu(e); + })); + + this.updateStyles(); + + return this.element; + } + + override updateStyles(): void { + super.updateStyles(); + + if (this.element) { + this.element.classList.toggle('inactive', this.isInactive); + + const titleBackground = this.getColor(this.isInactive ? TITLE_BAR_INACTIVE_BACKGROUND : TITLE_BAR_ACTIVE_BACKGROUND, (color, theme) => { + return color.isOpaque() ? color : color.makeOpaque(WORKBENCH_BACKGROUND(theme)); + }) || ''; + this.element.style.backgroundColor = titleBackground; + + if (titleBackground && Color.fromHex(titleBackground).isLighter()) { + this.element.classList.add('light'); + } else { + this.element.classList.remove('light'); + } + + const titleForeground = this.getColor(this.isInactive ? TITLE_BAR_INACTIVE_FOREGROUND : TITLE_BAR_ACTIVE_FOREGROUND); + this.element.style.color = titleForeground || ''; + + const titleBorder = this.getColor(TITLE_BAR_BORDER); + this.element.style.borderBottom = titleBorder ? `1px solid ${titleBorder}` : ''; + } + } + + private onContextMenu(e: MouseEvent): void { + const event = new StandardMouseEvent(getWindow(this.element), e); + this.contextMenuService.showContextMenu({ + getAnchor: () => event, + menuId: Menus.TitleBarContext, + contextKeyService: this.contextKeyService, + domForShadowRoot: isMacintosh && isNative ? event.target : undefined + }); + } + + private lastLayoutDimension: Dimension | undefined; + + get preventZoom(): boolean { + return getZoomFactor(getWindow(this.element)) < 1; + } + + override layout(width: number, height: number): void { + this.lastLayoutDimension = new Dimension(width, height); + this.updateLayout(); + super.layoutContents(width, height); + } + + private updateLayout(): void { + if (!hasCustomTitlebar(this.configurationService, this.titleBarStyle)) { + return; + } + + const zoomFactor = getZoomFactor(getWindow(this.element)); + this.element.style.setProperty('--zoom-factor', zoomFactor.toString()); + this.rootContainer.classList.toggle('counter-zoom', this.preventZoom); + + this.updateCenterOffset(); + } + + private updateCenterOffset(): void { + if (!this.centerContent || !this.lastLayoutDimension) { + return; + } + + // Center the command center relative to the viewport. + // The titlebar only covers the right section (sidebar is to the left), + // so we shift the center content left by half the sidebar width + // using a negative margin. + const windowWidth = this.layoutService.mainContainerDimension.width; + const titlebarWidth = this.lastLayoutDimension.width; + const leftOffset = windowWidth - titlebarWidth; + this.centerContent.style.marginLeft = leftOffset > 0 ? `${-leftOffset / 2}px` : ''; + this.centerContent.style.marginRight = leftOffset > 0 ? `${leftOffset / 2}px` : ''; + } + + focus(): void { + // eslint-disable-next-line no-restricted-syntax + (this.element.querySelector('[tabindex]:not([tabindex="-1"])') as HTMLElement | null)?.focus(); + } + + toJSON(): object { + return { type: Parts.TITLEBAR_PART }; + } + + override dispose(): void { + this._onWillDispose.fire(); + super.dispose(); + } +} + +/** + * Main agent sessions titlebar part (for the main window). + */ +export class MainTitlebarPart extends TitlebarPart { + + constructor( + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IInstantiationService instantiationService: IInstantiationService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @IContextKeyService contextKeyService: IContextKeyService, + @IHostService hostService: IHostService, + ) { + super(Parts.TITLEBAR_PART, mainWindow, contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService); + } +} + +/** + * Auxiliary agent sessions titlebar part (for auxiliary windows). + */ +export class AuxiliaryTitlebarPart extends TitlebarPart implements IAuxiliaryTitlebarPart { + + private static COUNTER = 1; + + get height() { return this.minimumHeight; } + + constructor( + readonly container: HTMLElement, + editorGroupsContainer: IEditorGroupsContainer, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IInstantiationService instantiationService: IInstantiationService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @IContextKeyService contextKeyService: IContextKeyService, + @IHostService hostService: IHostService, + ) { + const id = AuxiliaryTitlebarPart.COUNTER++; + super(`workbench.parts.auxiliaryTitle.${id}`, getWindow(container), contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService); + } +} + +/** + * Agent Sessions title service - manages the titlebar parts. + */ +export class TitleService extends MultiWindowParts implements ITitleService { + + declare _serviceBrand: undefined; + + readonly mainPart: TitlebarPart; + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IStorageService storageService: IStorageService, + @IThemeService themeService: IThemeService + ) { + super('workbench.agentSessionsTitleService', themeService, storageService); + + this.mainPart = this._register(this.instantiationService.createInstance(MainTitlebarPart)); + this.onMenubarVisibilityChange = this.mainPart.onMenubarVisibilityChange; + this._register(this.registerPart(this.mainPart)); + } + + //#region Auxiliary Titlebar Parts + + createAuxiliaryTitlebarPart(container: HTMLElement, editorGroupsContainer: IEditorGroupsContainer, instantiationService: IInstantiationService): IAuxiliaryTitlebarPart { + const titlebarPartContainer = $('.part.titlebar', { role: 'none' }); + titlebarPartContainer.style.position = 'relative'; + container.insertBefore(titlebarPartContainer, container.firstChild); + + const disposables = new DisposableStore(); + + const titlebarPart = instantiationService.createInstance(AuxiliaryTitlebarPart, titlebarPartContainer, editorGroupsContainer); + disposables.add(this.registerPart(titlebarPart)); + + disposables.add(Event.runAndSubscribe(titlebarPart.onDidChange, () => titlebarPartContainer.style.height = `${titlebarPart.height}px`)); + titlebarPart.create(titlebarPartContainer); + + Event.once(titlebarPart.onWillDispose)(() => disposables.dispose()); + + return titlebarPart; + } + + //#endregion + + //#region Service Implementation + + readonly onMenubarVisibilityChange: Event; + + updateProperties(properties: ITitleProperties): void { + for (const part of this.parts) { + part.updateProperties(properties); + } + } + + registerVariables(variables: ITitleVariable[]): void { + for (const part of this.parts) { + part.registerVariables(variables); + } + } + + //#endregion +} diff --git a/src/vs/sessions/browser/style.css b/src/vs/sessions/browser/style.css new file mode 100644 index 0000000000000..aa371216552a2 --- /dev/null +++ b/src/vs/sessions/browser/style.css @@ -0,0 +1,167 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Ensure workbench container is positioned for overlay */ +.agent-sessions-workbench { + position: relative; +} + +/* ---- Sidebar & Auxiliary Bar Card Appearance ---- */ + +/** + * The auxiliary bar and panel use a card-like appearance with margins and border-radius + * applied directly on the .part element. The grid allocates full space; CSS margin shrinks + * the part within its split-view-view wrapper. Layout dimensions are reduced in code + * (AgenticAuxiliaryBarPart) to keep internal layout correct. + * + * No z-index or stacking-context changes - sashes render naturally on top. + * + * Margin values (must match the constants in the Part classes): + * Sidebar: no card (flush, spans full height) + * Auxiliary bar: top=8, bottom=8, right=8 + * Panel: bottom=8, left=8, right=8 + */ + +.agent-sessions-workbench .part.sidebar { + background: var(--vscode-sideBar-background); + border-right: 1px solid var(--vscode-sideBar-border, transparent); +} + +.agent-sessions-workbench .part.auxiliarybar { + margin: 8px 8px 8px 0; + background: var(--part-background); + border: 1px solid var(--part-border-color, transparent); + border-radius: 8px; +} + +.agent-sessions-workbench .part.panel { + margin: 0 8px 8px 8px; + background: var(--part-background); + border: 1px solid var(--part-border-color, transparent); + border-radius: 8px; +} + +/* Grid background matches the chat bar / sidebar background */ +.agent-sessions-workbench > .monaco-grid-view { + background-color: var(--vscode-sideBar-background); +} + +/* Editor Modal Overlay */ +.agent-sessions-workbench .editor-modal-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + visibility: hidden; +} + +.agent-sessions-workbench .editor-modal-overlay.visible { + pointer-events: auto; + visibility: visible; +} + +/* Modal Backdrop */ +.agent-sessions-workbench .editor-modal-backdrop { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.4); + opacity: 0; + transition: opacity 0.15s ease-out; +} + +.agent-sessions-workbench .editor-modal-overlay.visible .editor-modal-backdrop { + opacity: 1; +} + +/* Modal Container */ +.agent-sessions-workbench .editor-modal-container { + position: relative; + display: flex; + flex-direction: column; + /* Width and height are set dynamically in TypeScript */ + background-color: var(--vscode-editor-background); + border: 1px solid var(--vscode-editorWidget-border, var(--vscode-contrastBorder)); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + overflow: hidden; + transform: scale(0.95); + opacity: 0; + transition: transform 0.15s ease-out, opacity 0.15s ease-out; +} + +.agent-sessions-workbench .editor-modal-overlay.visible .editor-modal-container { + transform: scale(1); + opacity: 1; +} + +/* Modal Header with close button */ +.agent-sessions-workbench .editor-modal-header { + display: flex; + align-items: center; + justify-content: flex-end; + height: 32px; + min-height: 32px; + padding: 0 8px; + background-color: var(--vscode-editorGroupHeader-tabsBackground); + border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder, transparent); +} + +.agent-sessions-workbench .editor-modal-close-button { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + background: transparent; + color: var(--vscode-icon-foreground); + cursor: pointer; + border-radius: 4px; +} + +.agent-sessions-workbench .editor-modal-close-button:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.agent-sessions-workbench .editor-modal-close-button:active { + background-color: var(--vscode-toolbar-activeBackground); +} + +/* Editor Content Area */ +.agent-sessions-workbench .editor-modal-content { + flex: 1; + overflow: hidden; + position: relative; + min-height: 0; /* Allow flexbox shrinking */ +} + +.agent-sessions-workbench .editor-modal-content > .part.editor { + position: absolute !important; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +/* ---- Chat Input ---- */ + +.agent-sessions-workbench .interactive-session .chat-input-container { + border-radius: 8px !important; +} + +.agent-sessions-workbench .interactive-session .interactive-input-part { + margin: 0 8px !important; + display: inherit !important; + padding: 4px 0 8px 0px !important; +} diff --git a/src/vs/sessions/browser/widget/AGENTS_CHAT_WIDGET.md b/src/vs/sessions/browser/widget/AGENTS_CHAT_WIDGET.md new file mode 100644 index 0000000000000..6b600f9441cf6 --- /dev/null +++ b/src/vs/sessions/browser/widget/AGENTS_CHAT_WIDGET.md @@ -0,0 +1,503 @@ +# Agent Sessions Chat Widget Architecture + +This document describes the architecture of the **Agent Sessions Chat Widget** (`AgentSessionsChatWidget`), a new extensible chat widget designed for the agent sessions window. It replaces the tightly-coupled agent session logic inside `ChatWidget` and `ChatInputPart` with a clean, composable system built around the wrapper pattern. + +--- + +## 1. Motivation: Why a New Architecture? + +### The Problem with Patching Core Widgets + +The original approach to supporting agent sessions involved adding agent-specific logic directly into the core `ChatWidget` and `ChatInputPart`. Over time, this led to significant coupling and code complexity: + +**Inside `ChatWidget` (~100+ lines of agent-specific code):** +- `ChatFullWelcomePart` is directly instantiated inside `ChatWidget.render()`, with the widget reaching into the welcome part's DOM to reparent the input element between `fullWelcomePart.inputSlot` and `mainInputContainer` +- `showFullWelcome` creates a forked rendering path — 5+ conditional branches in `render()`, `updateChatViewVisibility()`, and `renderWelcomeViewContentIfNeeded()` +- `lockToCodingAgent()` / `unlockFromCodingAgent()` add ~55 lines of method code plus ~20 lines of scattered `_lockedAgent`-gated logic throughout `clear()`, `forcedAgent` computation, welcome content generation, and scroll lock behavior +- The `_lockedToCodingAgentContextKey` context key is set/read in many places, creating implicit coupling between agent session state and widget rendering + +**Inside `ChatInputPart` (~50+ lines of agent-specific code):** +- Imports `AgentSessionProviders`, `getAgentSessionProvider`, `getAgentSessionProviderName` directly +- Manages `_pendingDelegationTarget`, `agentSessionTypeKey` context key, and `sessionTargetWidget` +- Has ~15 call sites checking `sessionTypePickerDelegate?.getActiveSessionProvider()` to determine option groups, picker rendering, and session type handling +- `getEffectiveSessionType()` resolves session type through a delegate → session context → fallback chain + +**Consequences:** +- **Fragile changes** — modifying agent session behavior requires touching core `ChatWidget` internals, risking regressions in the standard chat experience +- **Testing difficulty** — agent session logic is interleaved with general chat logic, making it hard to test either in isolation +- **Feature creep** — every new agent session feature (target restriction, deferred creation, cached option groups) adds more conditional branches to shared code +- **Unclear ownership** — it's hard to tell where "chat widget" ends and "agent sessions" begins + +### The Solution: Composition Over Modification + +The `AgentSessionsChatWidget` wraps `ChatWidget` instead of patching it. Agent-specific behavior lives in self-contained components that compose with the core widget through well-defined interfaces (`submitHandler`, `hiddenPickerIds`, `excludeOptionGroup`, `ISessionTypePickerDelegate` bridge). The core `ChatWidget` requires no agent-specific modifications. + +--- + +## 2. Overview + +The Agent Sessions Chat Widget provides: + +- **Deferred session creation** — the UI is fully interactive before any session resource exists +- **Target configuration** — users select which agent provider (Local, Cloud, etc.) to use +- **Welcome view** — a branded empty-state experience with mascot, target buttons, and option pickers +- **Initial session options** — option selections travel atomically with the first request to the extension +- **Configurable picker placement** — pickers can be rendered in the welcome view, input toolbar, or both + +```mermaid +graph TD + A[AgentSessionsChatWidget] --> B[ChatWidget] + A --> C[AgentSessionsChatWelcomePart] + A --> D[AgentSessionsChatTargetConfig] + + B --> F[ChatInputPart] + C --> G[Target Buttons] + C --> H[Option Pickers] + C --> I[Input Slot] + D --> J[Observable Target State] + E[AgentSessionsChatInputPart] -.->|standalone adapter| F + E -.-> D +``` + +> **Note:** `AgentSessionsChatInputPart` is a standalone adapter that bridges `IAgentChatTargetConfig` to `ChatInputPart`. It is available for consumers that need a `ChatInputPart` outside of a full `ChatWidget`, but `AgentSessionsChatWidget` itself creates the bridge delegate inline and passes it through `wrappedViewOptions` to the `ChatWidget`'s own `ChatInputPart`. + +--- + +## 3. Key Components + +### 3.1 `AgentSessionsChatWidget` + +**Location:** `src/vs/sessions/browser/widget/agentSessionsChatWidget.ts` + +The main wrapper around `ChatWidget`. It: + +1. **Owns the target config** — creates `AgentSessionsChatTargetConfig` from provided options +2. **Intercepts submission via two mechanisms** — uses `submitHandler` to create the session on first send, and monkey-patches `acceptInput` to attach `initialSessionOptions` to the session context +3. **Manages the welcome view** — shows `AgentSessionsChatWelcomePart` when the chat is empty +4. **Gathers initial options** — collects all option selections and attaches them to the session context +5. **Hides duplicate pickers** — uses `hiddenPickerIds` and `excludeOptionGroup` to avoid showing pickers in both the welcome view and input toolbar +6. **Caches option groups** — persists extension-contributed option groups to `StorageService` so pickers render immediately on next load before extensions activate + +#### Submission Interception: Two Mechanisms + +The widget uses two complementary interception points: + +- **`submitHandler`** (in `wrappedViewOptions`): Called by `ChatWidget._acceptInput()` before the normal send flow. If the session hasn't been created yet, it calls `_createSessionForCurrentTarget()`, restores the input text (which gets cleared by `setModel()`), and returns `false` to let the normal flow continue. +- **Monkey-patched `acceptInput`**: Called when `ChatSubmitAction` directly invokes `chatWidget.acceptInput()`. This captures the input text, creates the session if needed, then calls `_gatherAllOptionSelections()` to merge all option picks and attaches them to `contributedChatSession.initialSessionOptions` before delegating to the original `acceptInput`. + +Both paths converge on the same session creation and option gathering logic. The `submitHandler` handles the ChatWidget-internal send path, while the monkey-patch handles external callers (like `ChatSubmitAction`). + +```mermaid +sequenceDiagram + participant User + participant Widget as AgentSessionsChatWidget + participant Welcome as AgentSessionsChatWelcomePart + participant ChatWidget + participant Extension + + User->>Widget: Opens agent sessions window + Widget->>ChatWidget: render() with wrappedViewOptions + Widget->>Welcome: Create welcome view + Welcome-->>User: Shows mascot + target buttons + input + + User->>Welcome: Selects "Cloud" target + Welcome->>Widget: targetConfig.setSelectedTarget(Cloud) + Widget->>ChatWidget: Bridge to sessionTypePickerDelegate + + User->>Welcome: Picks repository + Welcome-->>Welcome: Stores in _selectedOptions + + User->>ChatWidget: Types message + clicks Send + Note over ChatWidget: ChatSubmitAction calls acceptInput() + ChatWidget->>Widget: Monkey-patched acceptInput() + Widget->>Widget: Capture input text + Widget->>Widget: _createSessionForCurrentTarget() + Widget->>Widget: _gatherAllOptionSelections() + Widget->>ChatWidget: setContributedChatSession({...initialSessionOptions}) + Widget->>ChatWidget: originalAcceptInput(capturedInput) + Note over ChatWidget: _acceptInput() → submitHandler check (already created, returns false) + ChatWidget->>Extension: $invokeAgent(request, {chatSessionContext: {initialSessionOptions}}) +``` + +### 3.2 `AgentSessionsChatTargetConfig` + +**Location:** `src/vs/sessions/browser/widget/agentSessionsChatTargetConfig.ts` + +A reactive configuration object that tracks: + +- **Allowed targets** — which agent providers are available (e.g., `[Background, Cloud]`) +- **Selected target** — which provider the user has chosen +- **Events** — fires when the target or allowed set changes + +```typescript +interface IAgentChatTargetConfig { + readonly allowedTargets: IObservable>; + readonly selectedTarget: IObservable; + readonly onDidChangeSelectedTarget: Event; + readonly onDidChangeAllowedTargets: Event>; + setSelectedTarget(target: AgentSessionProviders): void; + addAllowedTarget(target: AgentSessionProviders): void; + removeAllowedTarget(target: AgentSessionProviders): void; + setAllowedTargets(targets: AgentSessionProviders[]): void; +} +``` + +The target config is **purely UI state** — changing targets does NOT create sessions or resources. + +### 3.3 `AgentSessionsChatWelcomePart` + +**Location:** `src/vs/sessions/browser/parts/agentSessionsChatWelcomePart.ts` + +Renders the welcome view when the chat is empty: + +- **Mascot** — product branding image +- **Target buttons** — Local / Cloud toggle with sliding indicator +- **Option pickers** — extension-contributed option groups (repository, folder, etc.) +- **Input slot** — where the chat input is placed when in welcome mode + +The welcome part reads from `IAgentChatTargetConfig` and the `IChatSessionsService` for option groups. + +### 3.4 `AgentSessionsChatInputPart` + +**Location:** `src/vs/sessions/browser/parts/agentSessionsChatInputPart.ts` + +A standalone adapter around `ChatInputPart` that bridges `IAgentChatTargetConfig` to the existing `ISessionTypePickerDelegate` interface. It creates a `createTargetConfigDelegate()` bridge so the standard `ChatInputPart` can work with the new target config system without modifications. + +**Important:** `AgentSessionsChatWidget` does *not* use this adapter directly. Instead, it creates its own bridge delegate inline and passes it to `ChatWidget` via `wrappedViewOptions.sessionTypePickerDelegate`. The `AgentSessionsChatInputPart` is available for consumers that need a `ChatInputPart` with target config integration outside the context of a full `ChatWidget` (e.g., a detached input field). + +### 3.5 `AgentSessionsTargetPickerActionItem` + +**Location:** `src/vs/sessions/browser/widget/agentSessionsTargetPickerActionItem.ts` + +A dropdown picker action item for the input toolbar that reads available targets from `IAgentChatTargetConfig` (rather than `chatSessionsService`). Selection calls `targetConfig.setSelectedTarget()` with no session creation side effects. It renders the current target's icon and name, with a chevron to open the dropdown of allowed targets. The picker automatically re-renders when the selected target or allowed targets change. + +--- + +## 4. Chat Input Lifecycle: First Load vs New Session + +The chat input behaves differently depending on whether it's the very first load (before the extension activates) or a subsequent "New Session" after the extension is already active. + +### 4.1 First Load (Extension Not Yet Activated) + +When the agent sessions window opens for the first time: + +1. The `ChatWidget` renders with **no model** — `viewModel` is `undefined` +2. `ChatInputPart` has no `sessionResource`, so pickers query the `sessionTypePickerDelegate` for the effective session type +3. The extension hasn't activated yet, so: + - `chatSessionHasModels` context key is `false` (no option groups registered) + - `lockedToCodingAgent` is `false` (contribution not available yet) + - The `ChatSessionPrimaryPickerAction` menu item is **hidden** (its `when` clause requires both) +4. **Cached option groups** (from a previous run) are loaded from storage and seeded into the service, allowing pickers to render immediately with stale-but-useful data +5. **Pending session resource** — `_generatePendingSessionResource()` generates a lightweight URI (e.g., `copilotcli:/untitled-`) synchronously. No async work or extension activation needed. This resource allows picker commands and `notifySessionOptionsChange` events to flow through the existing pipeline. +6. When the extension activates: + - `onDidChangeAvailability` fires → `updateWidgetLockStateFromSessionType` sets `lockedToCodingAgent = true` + - `onDidChangeOptionGroups` fires with fresh data → `chatSessionHasModels = true` + - The `when` clause is now satisfied → toolbar re-renders with the picker action + - The welcome part re-renders pickers with live data from the extension +7. **Extension can now fire `notifySessionOptionsChange`** with the pending resource — the service stores values in `_pendingSessionOptions`, fires `onDidChangeSessionOptions`, and the welcome part and `ChatInputPart` match the resource and sync picker state. + +``` +State: No viewModel, _pendingSessionResource is set immediately (sync) +ChatInputPart: Uses delegate.getActiveSessionProvider() for session type +Pickers: Initially hidden, appear when extension activates +Option groups: Cached from storage → overwritten by extension +Session options: Stored in lightweight _pendingSessionOptions map (no ContributedChatSessionData) +``` + +### 4.2 New Session (Extension Already Active) + +When the user clicks "New Session" after completing a request: + +1. `resetSession()` is called +2. The old model is cleared via `setModel(undefined)` and the model ref is disposed +3. `_sessionCreated` is reset to `false` +4. `_pendingSessionResource` is cleared +5. Pending option selections from `ChatInputPart` are cleared via `takePendingOptionSelections()` +6. The welcome view becomes visible and pickers are re-rendered via `resetSelectedOptions()` +7. `_generatePendingSessionResource()` generates a fresh pending resource (synchronous) +8. The `ChatWidget` again has **no model** — same as first load from the input's perspective +9. BUT the extension is already active, so: + - `lockedToCodingAgent` is already `true` (contribution is available) + - `chatSessionHasModels` is already `true` (option groups are registered) + - Pickers render **immediately** with live data — no waiting for extension activation + - Option groups are fresh (not stale cached data) + - `getOrCreateChatSession` resolves quickly since the content provider is already registered + +``` +State: No viewModel, _pendingSessionResource set after init +ChatInputPart: Uses delegate.getActiveSessionProvider() for session type +Pickers: Render immediately (extension already active, context keys already set) +Option groups: Live data from extension (already registered) +``` + +### 4.3 Key Differences + +| Aspect | First Load | New Session | +|--------|-----------|-------------| +| Extension state | Not activated | Already active | +| `lockedToCodingAgent` | `false` → `true` (async) | Already `true` | +| `chatSessionHasModels` | `false` → `true` (async) | Already `true` | +| Input toolbar pickers | Hidden → appear on activation | Visible immediately | +| Welcome part pickers | Cached → replaced with live data | Live data from start | +| Session resource | Generated as pending, session data created eagerly | Old cleared, new pending generated | +| `_pendingSessionResource` | Set after `getOrCreateChatSession` completes | Cleared and re-initialized | +| `_pendingOptionSelections` | Empty | Cleared via `takePendingOptionSelections()` | +| Extension option changes | Received after pending init completes | Received immediately | + +### 4.4 The `locked` Flag and Session Reset + +Extensions can mark option items as `locked` (e.g., locking the folder picker after a request starts). This is a **session-specific** concept: + +- During an active session, the extension sends `notifySessionOptionsChange` with `{ ...option, locked: true }` +- The welcome part syncs these via `syncOptionsFromSession`, but **strips the `locked` flag** before storing in `_selectedOptions` +- This ensures that when the welcome view re-renders (e.g., after reset), pickers are always interactive +- Locking only affects the `ChatSessionPickerActionItem` widget's `currentOption.locked` check, which disables the dropdown + +--- + +## 5. Resourceless Chat Input + +### The Problem + +Traditional chat sessions require a session resource (URI) to exist before the user can interact. This means: +- Extensions must register and load before the UI is usable +- Creating a session involves an async round-trip to the extension +- The user sees a loading state instead of being productive + +### The Solution + +The Agent Sessions Chat Widget defers **chat model creation** to the **moment of first submit**, but eagerly initializes **session data** so extensions can interact with options before the user sends a message: + +```mermaid +stateDiagram-v2 + [*] --> PendingSession: Widget renders + PendingSession --> PendingSession: getOrCreateChatSession (session data only) + PendingSession --> PendingSession: Extension fires notifySessionOptionsChange + PendingSession --> PendingSession: User selects target + PendingSession --> PendingSession: User picks options + PendingSession --> PendingSession: User types message + PendingSession --> SessionCreated: User clicks Send + SessionCreated --> Active: Chat model created + model set + Active --> Active: Subsequent sends go through normally + Active --> PendingSession: User clears/resets +``` + +**Before chat model creation (pending session state):** +- A **pending session resource** is generated via `getResourceForNewChatSession()` and `chatSessionsService.getOrCreateChatSession()` is called eagerly. This creates session data (options store) and invokes `provideChatSessionContent` so the extension knows the resource. +- The extension can fire `notifySessionOptionsChange(pendingResource, updates)` at any time — the welcome part matches the pending resource and syncs option values. +- Target selection is tracked in `AgentSessionsChatTargetConfig` +- User option selections are cached in `_pendingOptionSelections` (ChatInputPart) and `_selectedOptions` (welcome part), AND forwarded to the extension via `notifySessionOptionsChange` using the pending resource. +- The chat input works normally — user can type, attach context, change mode + +**At chat model creation (triggered by either `submitHandler` or the patched `acceptInput`):** +1. `_createSessionForCurrentTarget()` reads the current target from the config +2. **Reuses the pending session resource** (the same URI used for session data) — no new resource is generated +3. For non-local targets, calls `loadSessionForResource(resource, location, CancellationToken.None)` which reuses the existing session data from `getOrCreateChatSession()`; for local targets, calls `startSession(location)` directly +4. Sets the model on the `ChatWidget` via `setModel()` (this clears the input editor, so the input text is captured and restored) +5. `_gatherAllOptionSelections()` collects options from welcome part + input toolbar +6. Options are attached to `contributedChatSession.initialSessionOptions` via `model.setContributedChatSession()` +7. The request proceeds through the normal `ChatWidget._acceptInput` flow + +--- + +## 6. Initial Session Options (`initialSessionOptions`) + +### The Problem + +When a session is created on first submit, the extension needs to know what options the user selected (model, repository, agent, etc.). But the traditional `provideHandleOptionsChange` mechanism is async and fire-and-forget — there's no guarantee the extension processes it before the request arrives. + +### The Solution + +Options travel **atomically with the first request** via `initialSessionOptions` on the `ChatSessionContext`: + +```mermaid +flowchart LR + A[Welcome Part
_selectedOptions] -->|merge| C[_gatherAllOptionSelections] + B[Input Toolbar
_pendingOptionSelections] -->|merge| C + C --> D[model.setContributedChatSession
initialSessionOptions] + D --> E[mainThreadChatAgents2
serialize to DTO] + E --> F[extHostChatAgents2
pass to extension] + F --> G[Extension handler
reads from context] +``` + +### Data Flow + +| Layer | Type | Field | +|-------|------|-------| +| Internal model | `IChatSessionContext` | `initialSessionOptions?: ReadonlyArray<{optionId: string, value: string \| { id: string; name: string }}>` | +| Protocol DTO | `IChatSessionContextDto` | `initialSessionOptions?: ReadonlyArray<{optionId: string, value: string}>` | +| Extension API | `ChatSessionContext` | `initialSessionOptions?: ReadonlyArray<{optionId: string, value: string}>` | + +> **Note:** The internal model allows `value` to be either a `string` or `{ id, name }` (matching `IChatSessionProviderOptionItem`'s structural type). During serialization to the protocol DTO in `mainThreadChatAgents2`, the value is converted to `string`. The extension always receives `string` values. + +### Extension Usage + +```typescript +// In the extension's request handler: +async handleRequest(request, context, stream, token) { + const { chatSessionContext } = context; + + // ⚠️ IMPORTANT: Apply initial options BEFORE any code that reads + // folder/model/agent state (e.g., lockRepoOption, hasUncommittedChanges). + // The initialSessionOptions override defaults set by provideChatSessionContent. + const initialOptions = chatSessionContext?.initialSessionOptions; + if (initialOptions) { + for (const { optionId, value } of initialOptions) { + // Apply options to internal state + if (optionId === 'model') { setModel(value); } + if (optionId === 'repository') { setRepository(value); } + } + } + + // Now downstream reads (trust checks, uncommitted changes, etc.) + // see the correct options. + // ... +} +``` + +### Priority Order + +When `_gatherAllOptionSelections()` merges options: + +1. **Welcome part selections** (lowest priority) — includes defaults for repository/folder pickers +2. **Input toolbar selections** (highest priority) — explicit user picks override welcome defaults + +--- + +## 7. Picker Placement + +Pickers can appear in two locations: +- **Welcome view** — above the input, managed by `AgentSessionsChatWelcomePart` +- **Input toolbar** — inside the chat input, managed by `ChatInputPart` + +To avoid duplication, the widget uses two mechanisms: + +### `hiddenPickerIds` + +Hides entire picker actions from the input toolbar: + +```typescript +hiddenPickerIds: new Set([ + OpenSessionTargetPickerAction.ID, // Target picker in welcome + OpenModePickerAction.ID, // Mode picker hidden + OpenModelPickerAction.ID, // Model picker hidden + ConfigureToolsAction.ID, // Tools config hidden +]) +``` + +### `excludeOptionGroup` + +Selectively excludes specific option groups from `ChatSessionPrimaryPickerAction` in the input toolbar while keeping others: + +```typescript +excludeOptionGroup: (group) => { + const idLower = group.id.toLowerCase(); + const nameLower = group.name.toLowerCase(); + // Repository/folder pickers are in the welcome view + return idLower === 'repositories' || idLower === 'folders' || + nameLower === 'repository' || nameLower === 'repositories' || + nameLower === 'folder' || nameLower === 'folders'; +} +``` + +This allows the input toolbar to still show model pickers from `ChatSessionPrimaryPickerAction` while the welcome view handles the repository picker. + +```mermaid +graph TB + subgraph "Welcome View (above input)" + T[Target Buttons
Local | Cloud] + R[Repository Picker
via excludeOptionGroup] + end + + subgraph "Input Toolbar (inside input)" + M[Model Picker
from ChatSessionPrimaryPickerAction] + S[Send Button] + end + + subgraph "Hidden from toolbar" + H1[Target Picker
hiddenPickerIds] + H2[Mode Picker
hiddenPickerIds] + end +``` + +--- + +## 8. File Structure + +``` +src/vs/sessions/browser/widget/ +├── AGENTS_CHAT_WIDGET.md # This document +├── agentSessionsChatWidget.ts # Main widget wrapper +├── agentSessionsChatTargetConfig.ts # Target configuration (observable) +├── agentSessionsTargetPickerActionItem.ts # Target picker for input toolbar +└── media/ + └── agentSessionsChatWidget.css # Widget-specific styles + +src/vs/sessions/browser/parts/ +├── agentSessionsChatInputPart.ts # Input part adapter +└── agentSessionsChatWelcomePart.ts # Welcome view (mascot + pickers) +``` + +--- + +## 9. Adding a New Agent Provider + +To add a new agent provider (e.g., "Codex"): + +1. **Add to `AgentSessionProviders` enum** in `agentSessions.ts` +2. **Update target config** in `chatViewPane.ts`: + ```typescript + allowedTargets: [Background, Cloud, Codex] + ``` +3. **Register session content provider** in the extension +4. **Handle `initialSessionOptions`** in the extension's request handler +5. **Register option groups** via `provideChatSessionProviderOptions` + +The welcome part and input toolbar automatically pick up new targets and option groups. + +--- + +## 10. Comparison with Old Architecture + +### Side-by-Side + +| Aspect | Old (`ChatFullWelcomePart` inside `ChatWidget`) | New (`AgentSessionsChatWidget` wrapper) | +|--------|---------------------------|--------------------------------| +| Session creation | Eager (on load) | Deferred (on first send) | +| Target selection | `ISessionTypePickerDelegate` callback | `IAgentChatTargetConfig` observable | +| Option delivery | `provideHandleOptionsChange` (async, fire-and-forget) | `initialSessionOptions` (atomic with request) | +| Welcome view | Inside `ChatWidget` via `showFullWelcome` flag | Separate `AgentSessionsChatWelcomePart` | +| Picker placement | Hardcoded in `ChatInputPart` | Configurable via `hiddenPickerIds` + `excludeOptionGroup` | +| Input reparenting | `ChatWidget` reaches into welcome part's DOM | `AgentSessionsChatWidget` manages its own DOM layout | +| Agent lock state | `lockToCodingAgent()` / `unlockFromCodingAgent()` on `ChatWidget` | Not needed — target config is external state | +| Extensibility | Requires modifying `ChatWidget` internals | Self-contained, composable components | + +### Benefits of the New Architecture + +**1. Clean Separation of Concerns** + +The old approach embeds agent session logic (target selection, welcome view, lock state, option caching) directly inside `ChatWidget`. This means every agent feature touches the same file that powers the standard chat experience. The new architecture keeps `ChatWidget` focused on its core responsibility — rendering a chat conversation — and pushes agent-specific behavior into dedicated components. + +**2. Reduced Risk of Regressions** + +In the old architecture, `ChatWidget.render()` has forked control flow gated on `showFullWelcome`, and `ChatInputPart` has ~15 call sites checking session type delegates. A change to how pickers render could break the standard chat. In the new architecture, `AgentSessionsChatWidget` composes with `ChatWidget` through stable, narrow interfaces (`submitHandler`, `hiddenPickerIds`, `excludeOptionGroup`), so changes to agent session behavior cannot break the core widget. + +**3. Testable in Isolation** + +`AgentSessionsChatTargetConfig` can be unit-tested independently — it's a pure observable state container with no DOM or service dependencies beyond `Disposable`. The old `ISessionTypePickerDelegate` was an ad-hoc callback interface defined inline, making it harder to mock and test. + +**4. Deferred Session Creation** + +The old architecture creates sessions eagerly, requiring an async round-trip to the extension before the UI is usable. The new architecture lets the user interact immediately (type, select targets, pick options) and only creates the session on first send. This eliminates the loading state and makes the initial experience feel instant. + +**5. Atomic Option Delivery** + +The old `provideHandleOptionsChange` mechanism sends option changes asynchronously — if the user changes a repository picker and immediately sends a message, there's a race condition where the extension might not have processed the option change yet. The new `initialSessionOptions` mechanism bundles all option selections with the first request, guaranteeing the extension sees the correct state. + +**6. Easier to Add New Agent Providers** + +Adding a new provider in the old architecture requires modifying `ChatWidget`, `ChatInputPart`, and `ChatFullWelcomePart`. In the new architecture, it's a matter of adding to the `AgentSessionProviders` enum and updating the `allowedTargets` config — the welcome part and input toolbar automatically discover new targets and option groups. + +**7. No Core Widget Modifications Required** + +The entire agent sessions feature works by wrapping `ChatWidget` with composition hooks that `ChatWidget` already exposes (`submitHandler`, `viewOptions`). This means the agent sessions team can iterate independently without coordinating changes to shared core widget code. diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts new file mode 100644 index 0000000000000..a7c2ce6026f9a --- /dev/null +++ b/src/vs/sessions/browser/workbench.ts @@ -0,0 +1,1438 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import '../../workbench/browser/style.js'; +import './style.css'; +import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../base/common/lifecycle.js'; +import { Emitter, Event, setGlobalLeakWarningThreshold } from '../../base/common/event.js'; +import { getActiveDocument, getActiveElement, getClientArea, getWindowId, getWindows, IDimension, isAncestorUsingFlowTo, size, Dimension, runWhenWindowIdle } from '../../base/browser/dom.js'; +import { DeferredPromise, RunOnceScheduler } from '../../base/common/async.js'; +import { isFullscreen, onDidChangeFullscreen, isChrome, isFirefox, isSafari } from '../../base/browser/browser.js'; +import { mark } from '../../base/common/performance.js'; +import { onUnexpectedError, setUnexpectedErrorHandler } from '../../base/common/errors.js'; +import { isWindows, isLinux, isWeb, isNative, isMacintosh } from '../../base/common/platform.js'; +import { Parts, Position, PanelAlignment, IWorkbenchLayoutService, SINGLE_WINDOW_PARTS, MULTI_WINDOW_PARTS, IPartVisibilityChangeEvent, positionToString } from '../../workbench/services/layout/browser/layoutService.js'; +import { ILayoutOffsetInfo } from '../../platform/layout/browser/layoutService.js'; +import { Part } from '../../workbench/browser/part.js'; +import { Direction, ISerializableView, ISerializedGrid, ISerializedLeafNode, ISerializedNode, IViewSize, Orientation, SerializableGrid } from '../../base/browser/ui/grid/grid.js'; +import { IEditorGroupsService } from '../../workbench/services/editor/common/editorGroupsService.js'; +import { IEditorService } from '../../workbench/services/editor/common/editorService.js'; +import { IPaneCompositePartService } from '../../workbench/services/panecomposite/browser/panecomposite.js'; +import { IViewDescriptorService, ViewContainerLocation } from '../../workbench/common/views.js'; +import { ILogService } from '../../platform/log/common/log.js'; +import { IInstantiationService, ServicesAccessor } from '../../platform/instantiation/common/instantiation.js'; +import { ITitleService } from '../../workbench/services/title/browser/titleService.js'; +import { mainWindow, CodeWindow } from '../../base/browser/window.js'; +import { coalesce } from '../../base/common/arrays.js'; +import { ServiceCollection } from '../../platform/instantiation/common/serviceCollection.js'; +import { InstantiationService } from '../../platform/instantiation/common/instantiationService.js'; +import { getSingletonServiceDescriptors } from '../../platform/instantiation/common/extensions.js'; +import { ILifecycleService, LifecyclePhase, WillShutdownEvent } from '../../workbench/services/lifecycle/common/lifecycle.js'; +import { IStorageService, WillSaveStateReason, StorageScope, StorageTarget } from '../../platform/storage/common/storage.js'; +import { IConfigurationChangeEvent, IConfigurationService } from '../../platform/configuration/common/configuration.js'; +import { IHostService } from '../../workbench/services/host/browser/host.js'; +import { IDialogService } from '../../platform/dialogs/common/dialogs.js'; +import { INotificationService } from '../../platform/notification/common/notification.js'; +import { NotificationService } from '../../workbench/services/notification/common/notificationService.js'; +import { IHoverService, WorkbenchHoverDelegate } from '../../platform/hover/browser/hover.js'; +import { setHoverDelegateFactory } from '../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { setBaseLayerHoverDelegate } from '../../base/browser/ui/hover/hoverDelegate2.js'; +import { Registry } from '../../platform/registry/common/platform.js'; +import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from '../../workbench/common/contributions.js'; +import { IEditorFactoryRegistry, EditorExtensions } from '../../workbench/common/editor.js'; +import { setARIAContainer } from '../../base/browser/ui/aria/aria.js'; +import { FontMeasurements } from '../../editor/browser/config/fontMeasurements.js'; +import { createBareFontInfoFromRawSettings } from '../../editor/common/config/fontInfoFromSettings.js'; +import { toErrorMessage } from '../../base/common/errorMessage.js'; +import { WorkbenchContextKeysHandler } from '../../workbench/browser/contextkeys.js'; +import { PixelRatio } from '../../base/browser/pixelRatio.js'; +import { AccessibilityProgressSignalScheduler } from '../../platform/accessibilitySignal/browser/progressAccessibilitySignalScheduler.js'; +import { setProgressAccessibilitySignalScheduler } from '../../base/browser/ui/progressbar/progressAccessibilitySignal.js'; +import { AccessibleViewRegistry } from '../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { NotificationAccessibleView } from '../../workbench/browser/parts/notifications/notificationAccessibleView.js'; +import { NotificationsCenter } from '../../workbench/browser/parts/notifications/notificationsCenter.js'; +import { NotificationsAlerts } from '../../workbench/browser/parts/notifications/notificationsAlerts.js'; +import { NotificationsStatus } from '../../workbench/browser/parts/notifications/notificationsStatus.js'; +import { registerNotificationCommands } from '../../workbench/browser/parts/notifications/notificationsCommands.js'; +import { NotificationsToasts } from '../../workbench/browser/parts/notifications/notificationsToasts.js'; +import { IMarkdownRendererService } from '../../platform/markdown/browser/markdownRenderer.js'; +import { EditorMarkdownCodeBlockRenderer } from '../../editor/browser/widget/markdownRenderer/browser/editorMarkdownCodeBlockRenderer.js'; +import { EditorModal } from './parts/editorModal.js'; +import { SyncDescriptor } from '../../platform/instantiation/common/descriptors.js'; +import { TitleService } from './parts/titlebarPart.js'; + +//#region Workbench Options + +export interface IWorkbenchOptions { + /** + * Extra classes to be added to the workbench container. + */ + extraClasses?: string[]; +} + +//#endregion + +//#region Layout Classes + +enum LayoutClasses { + SIDEBAR_HIDDEN = 'nosidebar', + MAIN_EDITOR_AREA_HIDDEN = 'nomaineditorarea', + PANEL_HIDDEN = 'nopanel', + AUXILIARYBAR_HIDDEN = 'noauxiliarybar', + CHATBAR_HIDDEN = 'nochatbar', + FULLSCREEN = 'fullscreen', + MAXIMIZED = 'maximized', + EDITOR_MODAL_VISIBLE = 'editor-modal-visible' +} + +//#endregion + +//#region Part Visibility State + +interface IPartVisibilityState { + sidebar: boolean; + auxiliaryBar: boolean; + editor: boolean; + panel: boolean; + chatBar: boolean; +} + +//#endregion + +export class Workbench extends Disposable implements IWorkbenchLayoutService { + + declare readonly _serviceBrand: undefined; + + //#region Lifecycle Events + + private readonly _onWillShutdown = this._register(new Emitter()); + readonly onWillShutdown = this._onWillShutdown.event; + + private readonly _onDidShutdown = this._register(new Emitter()); + readonly onDidShutdown = this._onDidShutdown.event; + + //#endregion + + //#region Events + + private readonly _onDidChangeZenMode = this._register(new Emitter()); + readonly onDidChangeZenMode = this._onDidChangeZenMode.event; + + private readonly _onDidChangeMainEditorCenteredLayout = this._register(new Emitter()); + readonly onDidChangeMainEditorCenteredLayout = this._onDidChangeMainEditorCenteredLayout.event; + + private readonly _onDidChangePanelAlignment = this._register(new Emitter()); + readonly onDidChangePanelAlignment = this._onDidChangePanelAlignment.event; + + private readonly _onDidChangeWindowMaximized = this._register(new Emitter<{ windowId: number; maximized: boolean }>()); + readonly onDidChangeWindowMaximized = this._onDidChangeWindowMaximized.event; + + private readonly _onDidChangePanelPosition = this._register(new Emitter()); + readonly onDidChangePanelPosition = this._onDidChangePanelPosition.event; + + private readonly _onDidChangePartVisibility = this._register(new Emitter()); + readonly onDidChangePartVisibility = this._onDidChangePartVisibility.event; + + private readonly _onDidChangeNotificationsVisibility = this._register(new Emitter()); + readonly onDidChangeNotificationsVisibility = this._onDidChangeNotificationsVisibility.event; + + private readonly _onDidChangeAuxiliaryBarMaximized = this._register(new Emitter()); + readonly onDidChangeAuxiliaryBarMaximized = this._onDidChangeAuxiliaryBarMaximized.event; + + private readonly _onDidLayoutMainContainer = this._register(new Emitter()); + readonly onDidLayoutMainContainer = this._onDidLayoutMainContainer.event; + + private readonly _onDidLayoutActiveContainer = this._register(new Emitter()); + readonly onDidLayoutActiveContainer = this._onDidLayoutActiveContainer.event; + + private readonly _onDidLayoutContainer = this._register(new Emitter<{ container: HTMLElement; dimension: IDimension }>()); + readonly onDidLayoutContainer = this._onDidLayoutContainer.event; + + private readonly _onDidAddContainer = this._register(new Emitter<{ container: HTMLElement; disposables: DisposableStore }>()); + readonly onDidAddContainer = this._onDidAddContainer.event; + + private readonly _onDidChangeActiveContainer = this._register(new Emitter()); + readonly onDidChangeActiveContainer = this._onDidChangeActiveContainer.event; + + //#endregion + + //#region Properties + + readonly mainContainer = document.createElement('div'); + + get activeContainer(): HTMLElement { + return this.getContainerFromDocument(getActiveDocument()); + } + + get containers(): Iterable { + const containers: HTMLElement[] = []; + for (const { window } of getWindows()) { + containers.push(this.getContainerFromDocument(window.document)); + } + return containers; + } + + private getContainerFromDocument(targetDocument: Document): HTMLElement { + if (targetDocument === this.mainContainer.ownerDocument) { + return this.mainContainer; + } else { + // eslint-disable-next-line no-restricted-syntax + return targetDocument.body.getElementsByClassName('monaco-workbench')[0] as HTMLElement; + } + } + + private _mainContainerDimension!: IDimension; + get mainContainerDimension(): IDimension { return this._mainContainerDimension; } + + get activeContainerDimension(): IDimension { + return this.getContainerDimension(this.activeContainer); + } + + private getContainerDimension(container: HTMLElement): IDimension { + if (container === this.mainContainer) { + return this.mainContainerDimension; + } else { + return getClientArea(container); + } + } + + get mainContainerOffset(): ILayoutOffsetInfo { + return this.computeContainerOffset(); + } + + get activeContainerOffset(): ILayoutOffsetInfo { + return this.computeContainerOffset(); + } + + private computeContainerOffset(): ILayoutOffsetInfo { + let top = 0; + let quickPickTop = 0; + + if (this.isVisible(Parts.TITLEBAR_PART, mainWindow)) { + top = this.getPart(Parts.TITLEBAR_PART).maximumHeight; + quickPickTop = top; + } + + return { top, quickPickTop }; + } + + //#endregion + + //#region State + + private readonly parts = new Map(); + private workbenchGrid!: SerializableGrid; + + private titleBarPartView!: ISerializableView; + private sideBarPartView!: ISerializableView; + private panelPartView!: ISerializableView; + private auxiliaryBarPartView!: ISerializableView; + + // Editor modal + private editorModal!: EditorModal; + private chatBarPartView!: ISerializableView; + + private readonly partVisibility: IPartVisibilityState = { + sidebar: true, + auxiliaryBar: true, + editor: false, + panel: false, + chatBar: true + }; + + private mainWindowFullscreen = false; + private readonly maximized = new Set(); + + private readonly restoredPromise = new DeferredPromise(); + readonly whenRestored = this.restoredPromise.p; + private restored = false; + + readonly openedDefaultEditors = false; + + //#endregion + + //#region Services + + private editorGroupService!: IEditorGroupsService; + private editorService!: IEditorService; + private paneCompositeService!: IPaneCompositePartService; + private viewDescriptorService!: IViewDescriptorService; + + //#endregion + + constructor( + protected readonly parent: HTMLElement, + private readonly options: IWorkbenchOptions | undefined, + private readonly serviceCollection: ServiceCollection, + private readonly logService: ILogService + ) { + super(); + + // Perf: measure workbench startup time + mark('code/willStartWorkbench'); + + this.registerErrorHandler(logService); + } + + //#region Error Handling + + private registerErrorHandler(logService: ILogService): void { + // Increase stack trace limit for better errors stacks + if (!isFirefox) { + Error.stackTraceLimit = 100; + } + + // Listen on unhandled rejection events + // Note: intentionally not registered as disposable to handle + // errors that can occur during shutdown phase. + mainWindow.addEventListener('unhandledrejection', (event) => { + // See https://developer.mozilla.org/en-US/docs/Web/API/PromiseRejectionEvent + onUnexpectedError(event.reason); + + // Prevent the printing of this event to the console + event.preventDefault(); + }); + + // Install handler for unexpected errors + setUnexpectedErrorHandler(error => this.handleUnexpectedError(error, logService)); + } + + private previousUnexpectedError: { message: string | undefined; time: number } = { message: undefined, time: 0 }; + private handleUnexpectedError(error: unknown, logService: ILogService): void { + const message = toErrorMessage(error, true); + if (!message) { + return; + } + + const now = Date.now(); + if (message === this.previousUnexpectedError.message && now - this.previousUnexpectedError.time <= 1000) { + return; // Return if error message identical to previous and shorter than 1 second + } + + this.previousUnexpectedError.time = now; + this.previousUnexpectedError.message = message; + + // Log it + logService.error(message); + } + + //#endregion + + //#region Startup + + startup(): IInstantiationService { + try { + // Configure emitter leak warning threshold + this._register(setGlobalLeakWarningThreshold(175)); + + // Services + const instantiationService = this.initServices(this.serviceCollection); + + instantiationService.invokeFunction(accessor => { + const lifecycleService = accessor.get(ILifecycleService); + const storageService = accessor.get(IStorageService); + const configurationService = accessor.get(IConfigurationService); + const hostService = accessor.get(IHostService); + const hoverService = accessor.get(IHoverService); + const dialogService = accessor.get(IDialogService); + const notificationService = accessor.get(INotificationService) as NotificationService; + const markdownRendererService = accessor.get(IMarkdownRendererService); + + // Set code block renderer for markdown rendering + markdownRendererService.setDefaultCodeBlockRenderer(instantiationService.createInstance(EditorMarkdownCodeBlockRenderer)); + + // Default Hover Delegate must be registered before creating any workbench/layout components + setHoverDelegateFactory((placement, enableInstantHover) => instantiationService.createInstance(WorkbenchHoverDelegate, placement, { instantHover: enableInstantHover }, {})); + setBaseLayerHoverDelegate(hoverService); + + // Layout + this.initLayout(accessor); + + // Registries - this creates and registers all parts + Registry.as(WorkbenchExtensions.Workbench).start(accessor); + Registry.as(EditorExtensions.EditorFactory).start(accessor); + + // Context Keys + this._register(instantiationService.createInstance(WorkbenchContextKeysHandler)); + + // Register Listeners + this.registerListeners(lifecycleService, storageService, configurationService, hostService, dialogService); + + // Render Workbench + this.renderWorkbench(instantiationService, notificationService, storageService, configurationService); + + // Workbench Layout + this.createWorkbenchLayout(); + + // Workbench Management + this.createWorkbenchManagement(instantiationService); + + // Layout + this.layout(); + + // Restore + this.restore(lifecycleService); + }); + + return instantiationService; + } catch (error) { + onUnexpectedError(error); + + throw error; // rethrow because this is a critical issue we cannot handle properly here + } + } + + private initServices(serviceCollection: ServiceCollection): IInstantiationService { + // Layout Service + serviceCollection.set(IWorkbenchLayoutService, this); + + // Title Service - agent sessions titlebar with dedicated part overrides + serviceCollection.set(ITitleService, new SyncDescriptor(TitleService, [])); + + // All Contributed Services + const contributedServices = getSingletonServiceDescriptors(); + for (const [id, descriptor] of contributedServices) { + serviceCollection.set(id, descriptor); + } + + const instantiationService = new InstantiationService(serviceCollection, true); + + // Wrap up + instantiationService.invokeFunction(accessor => { + const lifecycleService = accessor.get(ILifecycleService); + + // TODO@Sandeep debt around cyclic dependencies + const configurationService = accessor.get(IConfigurationService); + // eslint-disable-next-line local/code-no-in-operator + if (configurationService && 'acquireInstantiationService' in configurationService) { + (configurationService as { acquireInstantiationService: (instantiationService: unknown) => void }).acquireInstantiationService(instantiationService); + } + + // Signal to lifecycle that services are set + lifecycleService.phase = LifecyclePhase.Ready; + }); + + return instantiationService; + } + + private registerListeners(lifecycleService: ILifecycleService, storageService: IStorageService, configurationService: IConfigurationService, hostService: IHostService, dialogService: IDialogService): void { + // Configuration changes + this._register(configurationService.onDidChangeConfiguration(e => this.updateFontAliasing(e, configurationService))); + + // Font Info + if (isNative) { + this._register(storageService.onWillSaveState(e => { + if (e.reason === WillSaveStateReason.SHUTDOWN) { + this.storeFontInfo(storageService); + } + })); + } else { + this._register(lifecycleService.onWillShutdown(() => this.storeFontInfo(storageService))); + } + + // Lifecycle + this._register(lifecycleService.onWillShutdown(event => this._onWillShutdown.fire(event))); + this._register(lifecycleService.onDidShutdown(() => { + this._onDidShutdown.fire(); + this.dispose(); + })); + + // Flush storage on window focus loss + this._register(hostService.onDidChangeFocus(focus => { + if (!focus) { + storageService.flush(); + } + })); + + // Dialogs showing/hiding + this._register(dialogService.onWillShowDialog(() => this.mainContainer.classList.add('modal-dialog-visible'))); + this._register(dialogService.onDidShowDialog(() => this.mainContainer.classList.remove('modal-dialog-visible'))); + } + + //#region Font Aliasing and Caching + + private fontAliasing: 'default' | 'antialiased' | 'none' | 'auto' | undefined; + private updateFontAliasing(e: IConfigurationChangeEvent | undefined, configurationService: IConfigurationService) { + if (!isMacintosh) { + return; // macOS only + } + + if (e && !e.affectsConfiguration('workbench.fontAliasing')) { + return; + } + + const aliasing = configurationService.getValue<'default' | 'antialiased' | 'none' | 'auto'>('workbench.fontAliasing'); + if (this.fontAliasing === aliasing) { + return; + } + + this.fontAliasing = aliasing; + + // Remove all + const fontAliasingValues: (typeof aliasing)[] = ['antialiased', 'none', 'auto']; + this.mainContainer.classList.remove(...fontAliasingValues.map(value => `monaco-font-aliasing-${value}`)); + + // Add specific + if (fontAliasingValues.some(option => option === aliasing)) { + this.mainContainer.classList.add(`monaco-font-aliasing-${aliasing}`); + } + } + + private restoreFontInfo(storageService: IStorageService, configurationService: IConfigurationService): void { + const storedFontInfoRaw = storageService.get('editorFontInfo', StorageScope.APPLICATION); + if (storedFontInfoRaw) { + try { + const storedFontInfo = JSON.parse(storedFontInfoRaw); + if (Array.isArray(storedFontInfo)) { + FontMeasurements.restoreFontInfo(mainWindow, storedFontInfo); + } + } catch (err) { + /* ignore */ + } + } + + FontMeasurements.readFontInfo(mainWindow, createBareFontInfoFromRawSettings(configurationService.getValue('editor'), PixelRatio.getInstance(mainWindow).value)); + } + + private storeFontInfo(storageService: IStorageService): void { + const serializedFontInfo = FontMeasurements.serializeFontInfo(mainWindow); + if (serializedFontInfo) { + storageService.store('editorFontInfo', JSON.stringify(serializedFontInfo), StorageScope.APPLICATION, StorageTarget.MACHINE); + } + } + + //#endregion + + private renderWorkbench(instantiationService: IInstantiationService, notificationService: NotificationService, storageService: IStorageService, configurationService: IConfigurationService): void { + // ARIA & Signals + setARIAContainer(this.mainContainer); + setProgressAccessibilitySignalScheduler((msDelayTime: number, msLoopTime?: number) => instantiationService.createInstance(AccessibilityProgressSignalScheduler, msDelayTime, msLoopTime)); + + // State specific classes + const platformClass = isWindows ? 'windows' : isLinux ? 'linux' : 'mac'; + const workbenchClasses = coalesce([ + 'monaco-workbench', + 'agent-sessions-workbench', + platformClass, + isWeb ? 'web' : undefined, + isChrome ? 'chromium' : isFirefox ? 'firefox' : isSafari ? 'safari' : undefined, + ...this.getLayoutClasses(), + ...(this.options?.extraClasses ? this.options.extraClasses : []) + ]); + + this.mainContainer.classList.add(...workbenchClasses); + + // Apply font aliasing + this.updateFontAliasing(undefined, configurationService); + + // Warm up font cache information before building up too many dom elements + this.restoreFontInfo(storageService, configurationService); + + // Create Parts (excluding editor - it will be in a modal) + for (const { id, role, classes } of [ + { id: Parts.TITLEBAR_PART, role: 'none', classes: ['titlebar'] }, + { id: Parts.SIDEBAR_PART, role: 'none', classes: ['sidebar', 'left'] }, + { id: Parts.AUXILIARYBAR_PART, role: 'none', classes: ['auxiliarybar', 'basepanel', 'right'] }, + { id: Parts.CHATBAR_PART, role: 'main', classes: ['chatbar', 'basepanel', 'right'] }, + { id: Parts.PANEL_PART, role: 'none', classes: ['panel', 'basepanel', positionToString(this.getPanelPosition())] }, + ]) { + const partContainer = this.createPartContainer(id, role, classes); + + mark(`code/willCreatePart/${id}`); + this.getPart(id).create(partContainer); + mark(`code/didCreatePart/${id}`); + } + + // Create Editor Part in modal + this.createEditorModal(); + + // Notification Handlers + this.createNotificationsHandlers(instantiationService, notificationService); + + // Add Workbench to DOM + this.parent.appendChild(this.mainContainer); + } + + private createNotificationsHandlers(instantiationService: IInstantiationService, notificationService: NotificationService): void { + // Instantiate Notification components + const notificationsCenter = this._register(instantiationService.createInstance(NotificationsCenter, this.mainContainer, notificationService.model)); + const notificationsToasts = this._register(instantiationService.createInstance(NotificationsToasts, this.mainContainer, notificationService.model)); + this._register(instantiationService.createInstance(NotificationsAlerts, notificationService.model)); + const notificationsStatus = instantiationService.createInstance(NotificationsStatus, notificationService.model); + + // Visibility + this._register(notificationsCenter.onDidChangeVisibility(() => { + notificationsStatus.update(notificationsCenter.isVisible, notificationsToasts.isVisible); + notificationsToasts.update(notificationsCenter.isVisible); + })); + + this._register(notificationsToasts.onDidChangeVisibility(() => { + notificationsStatus.update(notificationsCenter.isVisible, notificationsToasts.isVisible); + })); + + // Register Commands + registerNotificationCommands(notificationsCenter, notificationsToasts, notificationService.model); + + // Register notification accessible view + AccessibleViewRegistry.register(new NotificationAccessibleView()); + + // Register with Layout + this.registerNotifications({ + onDidChangeNotificationsVisibility: Event.map(Event.any(notificationsToasts.onDidChangeVisibility, notificationsCenter.onDidChangeVisibility), () => notificationsToasts.isVisible || notificationsCenter.isVisible) + }); + } + + private createPartContainer(id: string, role: string, classes: string[]): HTMLElement { + const part = document.createElement('div'); + part.classList.add('part', ...classes); + part.id = id; + part.setAttribute('role', role); + return part; + } + + private createEditorModal(): void { + const editorPart = this.getPart(Parts.EDITOR_PART); + this.editorModal = this._register(new EditorModal( + this.mainContainer, + editorPart, + this.editorGroupService + )); + } + + private restore(lifecycleService: ILifecycleService): void { + // Update perf marks + mark('code/didStartWorkbench'); + performance.measure('perf: workbench create & restore', 'code/didLoadWorkbenchMain', 'code/didStartWorkbench'); + + // Restore parts (open default view containers) + this.restoreParts(); + + // Set lifecycle phase to `Restored` + lifecycleService.phase = LifecyclePhase.Restored; + + // Mark as restored + this.setRestored(); + + // Set lifecycle phase to `Eventually` after a short delay and when idle (min 2.5sec, max 5sec) + const eventuallyPhaseScheduler = this._register(new RunOnceScheduler(() => { + this._register(runWhenWindowIdle(mainWindow, () => lifecycleService.phase = LifecyclePhase.Eventually, 2500)); + }, 2500)); + eventuallyPhaseScheduler.schedule(); + } + + private restoreParts(): void { + // Open default view containers for each visible part + const partsToRestore: { location: ViewContainerLocation; visible: boolean }[] = [ + { location: ViewContainerLocation.Sidebar, visible: this.partVisibility.sidebar }, + { location: ViewContainerLocation.Panel, visible: this.partVisibility.panel }, + { location: ViewContainerLocation.AuxiliaryBar, visible: this.partVisibility.auxiliaryBar }, + { location: ViewContainerLocation.ChatBar, visible: this.partVisibility.chatBar }, + ]; + + for (const { location, visible } of partsToRestore) { + if (visible) { + const defaultViewContainer = this.viewDescriptorService.getDefaultViewContainer(location); + if (defaultViewContainer) { + this.paneCompositeService.openPaneComposite(defaultViewContainer.id, location); + } + } + } + } + + //#endregion + + //#region Initialization + + initLayout(accessor: ServicesAccessor): void { + // Services - accessing these triggers their instantiation + // which creates and registers the parts + this.editorGroupService = accessor.get(IEditorGroupsService); + this.editorService = accessor.get(IEditorService); + this.paneCompositeService = accessor.get(IPaneCompositePartService); + this.viewDescriptorService = accessor.get(IViewDescriptorService); + accessor.get(ITitleService); + + // Register layout listeners + this.registerLayoutListeners(); + + // Show editor part when an editor opens + this._register(this.editorService.onWillOpenEditor(() => { + if (!this.partVisibility.editor) { + this.setEditorHidden(false); + } + })); + + // Hide editor part when last editor closes + this._register(this.editorService.onDidCloseEditor(() => { + if (this.partVisibility.editor && this.areAllGroupsEmpty()) { + this.setEditorHidden(true); + } + })); + + // Initialize layout state (must be done before createWorkbenchLayout) + this._mainContainerDimension = getClientArea(this.parent, new Dimension(800, 600)); + } + + private areAllGroupsEmpty(): boolean { + for (const group of this.editorGroupService.groups) { + if (!group.isEmpty) { + return false; + } + } + return true; + } + + private registerLayoutListeners(): void { + // Fullscreen changes + this._register(onDidChangeFullscreen(windowId => { + if (windowId === getWindowId(mainWindow)) { + this.mainWindowFullscreen = isFullscreen(mainWindow); + this.updateFullscreenClass(); + this.layout(); + } + })); + } + + private updateFullscreenClass(): void { + if (this.mainWindowFullscreen) { + this.mainContainer.classList.add(LayoutClasses.FULLSCREEN); + } else { + this.mainContainer.classList.remove(LayoutClasses.FULLSCREEN); + } + } + + //#endregion + + //#region Workbench Layout Creation + + createWorkbenchLayout(): void { + const titleBar = this.getPart(Parts.TITLEBAR_PART); + const editorPart = this.getPart(Parts.EDITOR_PART); + const panelPart = this.getPart(Parts.PANEL_PART); + const auxiliaryBarPart = this.getPart(Parts.AUXILIARYBAR_PART); + const sideBar = this.getPart(Parts.SIDEBAR_PART); + const chatBarPart = this.getPart(Parts.CHATBAR_PART); + + // View references for parts in the grid (editor is NOT in grid) + this.titleBarPartView = titleBar; + this.sideBarPartView = sideBar; + this.panelPartView = panelPart; + this.auxiliaryBarPartView = auxiliaryBarPart; + this.chatBarPartView = chatBarPart; + + const viewMap: { [key: string]: ISerializableView } = { + [Parts.TITLEBAR_PART]: this.titleBarPartView, + [Parts.PANEL_PART]: this.panelPartView, + [Parts.SIDEBAR_PART]: this.sideBarPartView, + [Parts.AUXILIARYBAR_PART]: this.auxiliaryBarPartView, + [Parts.CHATBAR_PART]: this.chatBarPartView + }; + + const fromJSON = ({ type }: { type: string }) => viewMap[type]; + const workbenchGrid = SerializableGrid.deserialize( + this.createGridDescriptor(), + { fromJSON }, + { proportionalLayout: false } + ); + + this.mainContainer.prepend(workbenchGrid.element); + this.mainContainer.setAttribute('role', 'application'); + this.workbenchGrid = workbenchGrid; + this.workbenchGrid.edgeSnapping = this.mainWindowFullscreen; + + // Listen for part visibility changes (for parts in grid) + for (const part of [titleBar, panelPart, sideBar, auxiliaryBarPart, chatBarPart]) { + this._register(part.onDidVisibilityChange(visible => { + if (part === sideBar) { + this.setSideBarHidden(!visible); + } else if (part === panelPart) { + this.setPanelHidden(!visible); + } else if (part === auxiliaryBarPart) { + this.setAuxiliaryBarHidden(!visible); + } else if (part === chatBarPart) { + this.setChatBarHidden(!visible); + } + + this._onDidChangePartVisibility.fire({ partId: part.getId(), visible }); + this.handleContainerDidLayout(this.mainContainer, this._mainContainerDimension); + })); + } + + // Listen for editor part visibility changes (modal) + this._register(editorPart.onDidVisibilityChange(visible => { + this.setEditorHidden(!visible); + this._onDidChangePartVisibility.fire({ partId: editorPart.getId(), visible }); + this.handleContainerDidLayout(this.mainContainer, this._mainContainerDimension); + })); + } + + createWorkbenchManagement(_instantiationService: IInstantiationService): void { + // No floating toolbars in this layout + } + + /** + * Creates the grid descriptor for the Agent Sessions layout. + * Editor is NOT included - it's rendered as a modal overlay. + * + * Structure (horizontal orientation): + * - Sidebar (left, spans full height from top to bottom) + * - Right section (vertical): + * - Titlebar (top of right section) + * - Top right (horizontal): Chat Bar | Auxiliary Bar + * - Panel (below chat and auxiliary bar only) + */ + private createGridDescriptor(): ISerializedGrid { + const { width, height } = this._mainContainerDimension; + + // Default sizes + const sideBarSize = 300; + const auxiliaryBarSize = 300; + const panelSize = 300; + const titleBarHeight = this.titleBarPartView?.minimumHeight ?? 30; + + // Calculate right section width and chat bar width + const rightSectionWidth = Math.max(0, width - sideBarSize); + const chatBarWidth = Math.max(0, rightSectionWidth - auxiliaryBarSize); + + const contentHeight = height - titleBarHeight; + const topRightHeight = contentHeight - panelSize; + + const titleBarNode: ISerializedLeafNode = { + type: 'leaf', + data: { type: Parts.TITLEBAR_PART }, + size: titleBarHeight, + visible: true + }; + + const sideBarNode: ISerializedLeafNode = { + type: 'leaf', + data: { type: Parts.SIDEBAR_PART }, + size: sideBarSize, + visible: this.partVisibility.sidebar + }; + + const auxiliaryBarNode: ISerializedLeafNode = { + type: 'leaf', + data: { type: Parts.AUXILIARYBAR_PART }, + size: auxiliaryBarSize, + visible: this.partVisibility.auxiliaryBar + }; + + const chatBarNode: ISerializedLeafNode = { + type: 'leaf', + data: { type: Parts.CHATBAR_PART }, + size: chatBarWidth, + visible: this.partVisibility.chatBar + }; + + const panelNode: ISerializedLeafNode = { + type: 'leaf', + data: { type: Parts.PANEL_PART }, + size: panelSize, + visible: this.partVisibility.panel + }; + + // Top right section: Chat Bar | Auxiliary Bar (horizontal) + const topRightSection: ISerializedNode = { + type: 'branch', + data: [chatBarNode, auxiliaryBarNode], + size: topRightHeight + }; + + // Right section: Titlebar | Top Right | Panel (vertical) + const rightSection: ISerializedNode = { + type: 'branch', + data: [titleBarNode, topRightSection, panelNode], + size: rightSectionWidth + }; + + const result: ISerializedGrid = { + root: { + type: 'branch', + size: height, + data: [ + sideBarNode, + rightSection + ] + }, + orientation: Orientation.HORIZONTAL, + width, + height + }; + + return result; + } + + //#endregion + + //#region Layout Methods + + layout(): void { + this._mainContainerDimension = getClientArea( + this.mainWindowFullscreen ? mainWindow.document.body : this.parent + ); + this.logService.trace(`Workbench#layout, height: ${this._mainContainerDimension.height}, width: ${this._mainContainerDimension.width}`); + + size(this.mainContainer, this._mainContainerDimension.width, this._mainContainerDimension.height); + + // Layout the grid widget + this.workbenchGrid.layout(this._mainContainerDimension.width, this._mainContainerDimension.height); + + // Layout the editor modal with workbench dimensions + this.editorModal.layout(this._mainContainerDimension.width, this._mainContainerDimension.height); + + // Emit as event + this.handleContainerDidLayout(this.mainContainer, this._mainContainerDimension); + } + + private handleContainerDidLayout(container: HTMLElement, dimension: IDimension): void { + this._onDidLayoutContainer.fire({ container, dimension }); + if (container === this.mainContainer) { + this._onDidLayoutMainContainer.fire(dimension); + } + if (container === this.activeContainer) { + this._onDidLayoutActiveContainer.fire(dimension); + } + } + + getLayoutClasses(): string[] { + return coalesce([ + !this.partVisibility.sidebar ? LayoutClasses.SIDEBAR_HIDDEN : undefined, + !this.partVisibility.editor ? LayoutClasses.MAIN_EDITOR_AREA_HIDDEN : undefined, + !this.partVisibility.panel ? LayoutClasses.PANEL_HIDDEN : undefined, + !this.partVisibility.auxiliaryBar ? LayoutClasses.AUXILIARYBAR_HIDDEN : undefined, + !this.partVisibility.chatBar ? LayoutClasses.CHATBAR_HIDDEN : undefined, + this.mainWindowFullscreen ? LayoutClasses.FULLSCREEN : undefined + ]); + } + + //#endregion + + //#region Part Management + + registerPart(part: Part): IDisposable { + const id = part.getId(); + this.parts.set(id, part); + return toDisposable(() => this.parts.delete(id)); + } + + getPart(key: Parts): Part { + const part = this.parts.get(key); + if (!part) { + throw new Error(`Unknown part ${key}`); + } + return part; + } + + hasFocus(part: Parts): boolean { + const container = this.getContainer(mainWindow, part); + if (!container) { + return false; + } + + const activeElement = getActiveElement(); + if (!activeElement) { + return false; + } + + return isAncestorUsingFlowTo(activeElement, container); + } + + focusPart(part: MULTI_WINDOW_PARTS, targetWindow: Window): void; + focusPart(part: SINGLE_WINDOW_PARTS): void; + focusPart(part: Parts, targetWindow: Window = mainWindow): void { + switch (part) { + case Parts.EDITOR_PART: + this.editorGroupService.activeGroup.focus(); + break; + case Parts.PANEL_PART: + this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Panel)?.focus(); + break; + case Parts.SIDEBAR_PART: + this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)?.focus(); + break; + case Parts.AUXILIARYBAR_PART: + this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)?.focus(); + break; + case Parts.CHATBAR_PART: + this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.ChatBar)?.focus(); + break; + default: { + const container = this.getContainer(targetWindow, part); + container?.focus(); + } + } + } + + focus(): void { + this.focusPart(Parts.CHATBAR_PART); + } + + //#endregion + + //#region Container Methods + + getContainer(targetWindow: Window): HTMLElement; + getContainer(targetWindow: Window, part: Parts): HTMLElement | undefined; + getContainer(targetWindow: Window, part?: Parts): HTMLElement | undefined { + if (typeof part === 'undefined') { + return this.getContainerFromDocument(targetWindow.document); + } + + if (targetWindow === mainWindow) { + return this.parts.get(part)?.getContainer(); + } + + // For auxiliary windows, only editor part is supported + if (part === Parts.EDITOR_PART) { + const container = this.getContainerFromDocument(targetWindow.document); + const partCandidate = this.editorGroupService.getPart(container); + if (partCandidate instanceof Part) { + return partCandidate.getContainer(); + } + } + + return undefined; + } + + whenContainerStylesLoaded(_window: CodeWindow): Promise | undefined { + return undefined; + } + + //#endregion + + //#region Part Visibility + + isActivityBarHidden(): boolean { + return true; // No activity bar in this layout + } + + isVisible(part: SINGLE_WINDOW_PARTS): boolean; + isVisible(part: MULTI_WINDOW_PARTS, targetWindow: Window): boolean; + isVisible(part: Parts, targetWindow?: Window): boolean { + switch (part) { + case Parts.TITLEBAR_PART: + return true; // Always visible + case Parts.SIDEBAR_PART: + return this.partVisibility.sidebar; + case Parts.AUXILIARYBAR_PART: + return this.partVisibility.auxiliaryBar; + case Parts.EDITOR_PART: + return this.partVisibility.editor; + case Parts.PANEL_PART: + return this.partVisibility.panel; + case Parts.CHATBAR_PART: + return this.partVisibility.chatBar; + case Parts.ACTIVITYBAR_PART: + case Parts.STATUSBAR_PART: + case Parts.BANNER_PART: + default: + return false; + } + } + + setPartHidden(hidden: boolean, part: Parts): void { + switch (part) { + case Parts.SIDEBAR_PART: + this.setSideBarHidden(hidden); + break; + case Parts.AUXILIARYBAR_PART: + this.setAuxiliaryBarHidden(hidden); + break; + case Parts.EDITOR_PART: + this.setEditorHidden(hidden); + break; + case Parts.PANEL_PART: + this.setPanelHidden(hidden); + break; + case Parts.CHATBAR_PART: + this.setChatBarHidden(hidden); + break; + } + } + + private setSideBarHidden(hidden: boolean): void { + if (this.partVisibility.sidebar === !hidden) { + return; + } + + this.partVisibility.sidebar = !hidden; + + // Adjust CSS - for hiding, defer adding the class until animation + // completes so the part stays visible during the exit animation. + if (!hidden) { + this.mainContainer.classList.remove(LayoutClasses.SIDEBAR_HIDDEN); + } + + // Propagate to grid + this.workbenchGrid.setViewVisible( + this.sideBarPartView, + !hidden, + ); + + // If sidebar becomes visible, show last active Viewlet or default viewlet + if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)) { + const viewletToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.Sidebar); + if (viewletToOpen) { + this.paneCompositeService.openPaneComposite(viewletToOpen, ViewContainerLocation.Sidebar); + } + } + } + + private setAuxiliaryBarHidden(hidden: boolean): void { + if (this.partVisibility.auxiliaryBar === !hidden) { + return; + } + + this.partVisibility.auxiliaryBar = !hidden; + + // Adjust CSS - for hiding, defer adding the class until animation + // completes so the part stays visible during the exit animation. + if (!hidden) { + this.mainContainer.classList.remove(LayoutClasses.AUXILIARYBAR_HIDDEN); + } + + // Propagate to grid + this.workbenchGrid.setViewVisible( + this.auxiliaryBarPartView, + !hidden, + ); + + // If auxiliary bar becomes visible, show last active pane composite + if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) { + const paneCompositeToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.AuxiliaryBar); + if (paneCompositeToOpen) { + this.paneCompositeService.openPaneComposite(paneCompositeToOpen, ViewContainerLocation.AuxiliaryBar); + } + } + } + + private setEditorHidden(hidden: boolean): void { + if (this.partVisibility.editor === !hidden) { + return; + } + + this.partVisibility.editor = !hidden; + + // Adjust CSS for main container + if (hidden) { + this.mainContainer.classList.add(LayoutClasses.MAIN_EDITOR_AREA_HIDDEN); + this.mainContainer.classList.remove(LayoutClasses.EDITOR_MODAL_VISIBLE); + } else { + this.mainContainer.classList.remove(LayoutClasses.MAIN_EDITOR_AREA_HIDDEN); + this.mainContainer.classList.add(LayoutClasses.EDITOR_MODAL_VISIBLE); + } + + // Show/hide modal + if (hidden) { + this.editorModal.hide(); + } else { + this.editorModal.show(); + } + } + + private setPanelHidden(hidden: boolean): void { + if (this.partVisibility.panel === !hidden) { + return; + } + + // If hiding and the panel is maximized, exit maximized state first + if (hidden && this.workbenchGrid.hasMaximizedView()) { + this.workbenchGrid.exitMaximizedView(); + } + + this.partVisibility.panel = !hidden; + + // Adjust CSS - for hiding, defer adding the class until animation + // completes because `.nopanel .part.panel { display: none !important }` + // would instantly hide the panel content mid-animation. + if (!hidden) { + this.mainContainer.classList.remove(LayoutClasses.PANEL_HIDDEN); + } + + // Propagate to grid + this.workbenchGrid.setViewVisible( + this.panelPartView, + !hidden, + ); + + // If panel becomes visible, show last active panel + if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Panel)) { + const panelToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.Panel); + if (panelToOpen) { + this.paneCompositeService.openPaneComposite(panelToOpen, ViewContainerLocation.Panel); + } + } + } + + private setChatBarHidden(hidden: boolean): void { + if (this.partVisibility.chatBar === !hidden) { + return; + } + + this.partVisibility.chatBar = !hidden; + + // Adjust CSS + if (hidden) { + this.mainContainer.classList.add(LayoutClasses.CHATBAR_HIDDEN); + } else { + this.mainContainer.classList.remove(LayoutClasses.CHATBAR_HIDDEN); + } + + // Propagate to grid + this.workbenchGrid.setViewVisible(this.chatBarPartView, !hidden); + + // If chat bar becomes hidden, also hide the current active pane composite + if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.ChatBar)) { + this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.ChatBar); + } + + // If chat bar becomes visible, show last active pane composite or default + if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.ChatBar)) { + const paneCompositeToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.ChatBar) ?? + this.viewDescriptorService.getDefaultViewContainer(ViewContainerLocation.ChatBar)?.id; + if (paneCompositeToOpen) { + this.paneCompositeService.openPaneComposite(paneCompositeToOpen, ViewContainerLocation.ChatBar); + } + } + } + + //#endregion + + //#region Position Methods (Fixed - Not Configurable) + + getSideBarPosition(): Position { + return Position.LEFT; // Always left in this layout + } + + getPanelPosition(): Position { + return Position.BOTTOM; // Always bottom in this layout + } + + setPanelPosition(_position: Position): void { + // No-op: Panel position is fixed in this layout + } + + getPanelAlignment(): PanelAlignment { + return 'justify'; // Full width panel + } + + setPanelAlignment(_alignment: PanelAlignment): void { + // No-op: Panel alignment is fixed in this layout + } + + //#endregion + + //#region Size Methods + + getSize(part: Parts): IViewSize { + const view = this.getPartView(part); + if (!view) { + return { width: 0, height: 0 }; + } + return this.workbenchGrid.getViewSize(view); + } + + setSize(part: Parts, size: IViewSize): void { + const view = this.getPartView(part); + if (view) { + this.workbenchGrid.resizeView(view, size); + } + } + + resizePart(part: Parts, sizeChangeWidth: number, sizeChangeHeight: number): void { + const view = this.getPartView(part); + if (!view) { + return; + } + + const currentSize = this.workbenchGrid.getViewSize(view); + this.workbenchGrid.resizeView(view, { + width: currentSize.width + sizeChangeWidth, + height: currentSize.height + sizeChangeHeight + }); + } + + private getPartView(part: Parts): ISerializableView | undefined { + switch (part) { + case Parts.TITLEBAR_PART: + return this.titleBarPartView; + case Parts.SIDEBAR_PART: + return this.sideBarPartView; + case Parts.AUXILIARYBAR_PART: + return this.auxiliaryBarPartView; + case Parts.EDITOR_PART: + return undefined; // Editor is not in the grid, it's a modal + case Parts.PANEL_PART: + return this.panelPartView; + case Parts.CHATBAR_PART: + return this.chatBarPartView; + default: + return undefined; + } + } + + getMaximumEditorDimensions(_container: HTMLElement): IDimension { + // Return the available space for editor (excluding other parts) + const sidebarWidth = this.partVisibility.sidebar ? this.workbenchGrid.getViewSize(this.sideBarPartView).width : 0; + const auxiliaryBarWidth = this.partVisibility.auxiliaryBar ? this.workbenchGrid.getViewSize(this.auxiliaryBarPartView).width : 0; + const panelHeight = this.partVisibility.panel ? this.workbenchGrid.getViewSize(this.panelPartView).height : 0; + const titleBarHeight = this.workbenchGrid.getViewSize(this.titleBarPartView).height; + + return new Dimension( + this._mainContainerDimension.width - sidebarWidth - auxiliaryBarWidth, + this._mainContainerDimension.height - titleBarHeight - panelHeight + ); + } + + //#endregion + + //#region Unsupported Features (No-ops) + + toggleMaximizedPanel(): void { + if (!this.workbenchGrid) { + return; + } + + if (this.isPanelMaximized()) { + this.workbenchGrid.exitMaximizedView(); + } else { + this.workbenchGrid.maximizeView(this.panelPartView, [this.titleBarPartView, this.sideBarPartView]); + } + } + + isPanelMaximized(): boolean { + if (!this.workbenchGrid) { + return false; + } + + return this.workbenchGrid.isViewMaximized(this.panelPartView); + } + + toggleMaximizedAuxiliaryBar(): void { + // No-op: Maximize not supported in this layout + } + + setAuxiliaryBarMaximized(_maximized: boolean): boolean { + return false; // Maximize not supported + } + + isAuxiliaryBarMaximized(): boolean { + return false; // Maximize not supported + } + + toggleZenMode(): void { + // No-op: Zen mode not supported in this layout + } + + toggleMenuBar(): void { + // No-op: Menu bar toggle not supported in this layout + } + + isMainEditorLayoutCentered(): boolean { + return false; // Centered layout not supported + } + + centerMainEditorLayout(_active: boolean): void { + // No-op: Centered layout not supported in this layout + } + + hasMainWindowBorder(): boolean { + return false; + } + + getMainWindowBorderRadius(): string | undefined { + return undefined; + } + + //#endregion + + //#region Window Maximized State + + isWindowMaximized(targetWindow: Window): boolean { + return this.maximized.has(getWindowId(targetWindow)); + } + + updateWindowMaximizedState(targetWindow: Window, maximized: boolean): void { + const windowId = getWindowId(targetWindow); + if (maximized) { + this.maximized.add(windowId); + if (targetWindow === mainWindow) { + this.mainContainer.classList.add(LayoutClasses.MAXIMIZED); + } + } else { + this.maximized.delete(windowId); + if (targetWindow === mainWindow) { + this.mainContainer.classList.remove(LayoutClasses.MAXIMIZED); + } + } + + this._onDidChangeWindowMaximized.fire({ windowId, maximized }); + } + + //#endregion + + //#region Neighbor Parts + + getVisibleNeighborPart(part: Parts, direction: Direction): Parts | undefined { + if (!this.workbenchGrid) { + return undefined; + } + + const view = this.getPartView(part); + if (!view) { + return undefined; + } + + const neighbor = this.workbenchGrid.getNeighborViews(view, direction, false); + if (neighbor.length === 0) { + return undefined; + } + + const neighborView = neighbor[0]; + + if (neighborView === this.titleBarPartView) { + return Parts.TITLEBAR_PART; + } + if (neighborView === this.sideBarPartView) { + return Parts.SIDEBAR_PART; + } + if (neighborView === this.auxiliaryBarPartView) { + return Parts.AUXILIARYBAR_PART; + } + // Editor is not in the grid - it's rendered as a modal + if (neighborView === this.panelPartView) { + return Parts.PANEL_PART; + } + if (neighborView === this.chatBarPartView) { + return Parts.CHATBAR_PART; + } + + return undefined; + } + + //#endregion + + //#region Restore + + isRestored(): boolean { + return this.restored; + } + + setRestored(): void { + this.restored = true; + this.restoredPromise.complete(); + } + + //#endregion + + //#region Notifications Registration + + registerNotifications(delegate: { onDidChangeNotificationsVisibility: Event }): void { + this._register(delegate.onDidChangeNotificationsVisibility(visible => this._onDidChangeNotificationsVisibility.fire(visible))); + } + + //#endregion +} diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts new file mode 100644 index 0000000000000..f07b24f2ff607 --- /dev/null +++ b/src/vs/sessions/common/contextkeys.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { RawContextKey } from '../../platform/contextkey/common/contextkey.js'; + +//#region < --- Chat Bar --- > + +export const ActiveChatBarContext = new RawContextKey('activeChatBar', '', localize('activeChatBar', "The identifier of the active chat bar panel")); +export const ChatBarFocusContext = new RawContextKey('chatBarFocus', false, localize('chatBarFocus', "Whether the chat bar has keyboard focus")); +export const ChatBarVisibleContext = new RawContextKey('chatBarVisible', false, localize('chatBarVisible', "Whether the chat bar is visible")); + +//#endregion diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts new file mode 100644 index 0000000000000..fc9b4c7aedc8a --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -0,0 +1,281 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/accountWidget.css'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, MenuRegistry, registerAction2, IMenuService, MenuId } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { appendUpdateMenuItems as registerUpdateMenuItems } from '../../../../workbench/contrib/update/browser/update.js'; +import { Menus } from '../../../browser/menus.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { AnchorAlignment } from '../../../../base/browser/ui/contextview/contextview.js'; +import { fillInActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { $, append } from '../../../../base/browser/dom.js'; +import { ActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { IAction } from '../../../../base/common/actions.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { IUpdateService, StateType } from '../../../../platform/update/common/update.js'; + +// --- Account Menu Items --- // +const AccountMenu = new MenuId('SessionsAccountMenu'); + +// Sign In (shown when signed out) +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.agenticSignIn', + title: localize2('signIn', 'Sign In'), + menu: { + id: AccountMenu, + when: ContextKeyExpr.notEquals('defaultAccountStatus', 'available'), + group: '1_account', + order: 1, + } + }); + } + async run(accessor: ServicesAccessor): Promise { + const defaultAccountService = accessor.get(IDefaultAccountService); + await defaultAccountService.signIn(); + } +}); + +// Sign Out (shown when signed in) +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.agenticSignOut', + title: localize2('signOut', 'Sign Out'), + menu: { + id: AccountMenu, + when: ContextKeyExpr.equals('defaultAccountStatus', 'available'), + group: '1_account', + order: 1, + } + }); + } + async run(accessor: ServicesAccessor): Promise { + const defaultAccountService = accessor.get(IDefaultAccountService); + await defaultAccountService.signOut(); + } +}); + +// Settings +MenuRegistry.appendMenuItem(AccountMenu, { + command: { + id: 'workbench.action.openSettings', + title: localize('settings', "Settings"), + }, + group: '2_settings', + order: 1, +}); + +// Update actions +registerUpdateMenuItems(AccountMenu, '3_updates'); + +class AccountWidget extends ActionViewItem { + + private accountButton: Button | undefined; + private updateButton: Button | undefined; + private readonly viewItemDisposables = this._register(new DisposableStore()); + + constructor( + action: IAction, + options: IBaseActionViewItemOptions, + @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IMenuService private readonly menuService: IMenuService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IUpdateService private readonly updateService: IUpdateService, + ) { + super(undefined, action, { ...options, icon: false, label: false }); + } + + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('account-widget'); + + // Account button (left) + const accountContainer = append(container, $('.account-widget-account')); + this.accountButton = this.viewItemDisposables.add(new Button(accountContainer, { + ...defaultButtonStyles, + secondary: true, + title: false, + supportIcons: true, + buttonSecondaryBackground: 'transparent', + buttonSecondaryHoverBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryBorder: undefined, + })); + this.accountButton.element.classList.add('account-widget-account-button'); + + this.updateAccountButton(); + this.viewItemDisposables.add(this.defaultAccountService.onDidChangeDefaultAccount(() => this.updateAccountButton())); + + this.viewItemDisposables.add(this.accountButton.onDidClick(e => { + e?.preventDefault(); + e?.stopPropagation(); + this.showAccountMenu(this.accountButton!.element); + })); + + // Update button (shown for progress and restart-to-update states) + const updateContainer = append(container, $('.account-widget-update')); + this.updateButton = this.viewItemDisposables.add(new Button(updateContainer, { + ...defaultButtonStyles, + secondary: true, + title: false, + supportIcons: true, + buttonSecondaryBackground: 'transparent', + buttonSecondaryHoverBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryBorder: undefined, + })); + this.updateButton.element.classList.add('account-widget-update-button'); + this.updateButton.label = `$(${Codicon.debugRestart.id}) ${localize('update', "Update")}`; + this.viewItemDisposables.add(this.updateButton.onDidClick(() => this.update())); + + this.updateUpdateButton(); + this.viewItemDisposables.add(this.updateService.onStateChange(() => this.updateUpdateButton())); + } + + private isUpdateAvailable(): boolean { + return this.updateService.state.type === StateType.Ready; + } + + private isUpdateInProgress(): boolean { + const type = this.updateService.state.type; + return type === StateType.CheckingForUpdates + || type === StateType.Downloading + || type === StateType.Downloaded + || type === StateType.Updating + || type === StateType.Overwriting; + } + + private showAccountMenu(anchor: HTMLElement): void { + const menu = this.menuService.createMenu(AccountMenu, this.contextKeyService); + const actions: IAction[] = []; + fillInActionBarActions(menu.getActions(), actions); + menu.dispose(); + + if (this.isUpdateAvailable()) { + // Update button visible: open above the button + this.contextMenuService.showContextMenu({ + getAnchor: () => anchor, + getActions: () => actions, + anchorAlignment: AnchorAlignment.LEFT, + }); + } else { + // No update button: open to the right of the button + const rect = anchor.getBoundingClientRect(); + this.contextMenuService.showContextMenu({ + getAnchor: () => ({ x: rect.right, y: rect.top }), + getActions: () => actions, + }); + } + } + + private async updateAccountButton(): Promise { + if (!this.accountButton) { + return; + } + this.accountButton.label = `$(${Codicon.loading.id}~spin) ${localize('loadingAccount', "Loading account...")}`; + this.accountButton.enabled = false; + const account = await this.defaultAccountService.getDefaultAccount(); + this.accountButton.enabled = true; + this.accountButton.label = account + ? `$(${Codicon.account.id}) ${account.accountName} (${account.authenticationProvider.name})` + : `$(${Codicon.account.id}) ${localize('signInLabel', "Sign In")}`; + } + + private updateUpdateButton(): void { + if (!this.updateButton) { + return; + } + + const state = this.updateService.state; + if (this.isUpdateInProgress()) { + this.updateButton.element.parentElement!.style.display = ''; + this.updateButton.enabled = false; + this.updateButton.label = `$(${Codicon.loading.id}~spin) ${this.getUpdateProgressMessage(state.type)}`; + } else if (this.isUpdateAvailable()) { + this.updateButton.element.parentElement!.style.display = ''; + this.updateButton.enabled = true; + this.updateButton.label = `$(${Codicon.debugRestart.id}) ${localize('update', "Update")}`; + } else { + this.updateButton.element.parentElement!.style.display = 'none'; + } + } + + private getUpdateProgressMessage(type: StateType): string { + switch (type) { + case StateType.CheckingForUpdates: + return localize('checkingForUpdates', "Checking for Updates..."); + case StateType.Downloading: + return localize('downloadingUpdate', "Downloading Update..."); + case StateType.Downloaded: + return localize('installingUpdate', "Installing Update..."); + case StateType.Updating: + return localize('updatingApp', "Updating..."); + case StateType.Overwriting: + return localize('overwritingUpdate', "Downloading Update..."); + default: + return localize('updating', "Updating..."); + } + } + + private async update(): Promise { + await this.updateService.quitAndInstall(); + } + + override onClick(): void { + // Handled by custom click handlers + } +} + +// --- Register custom view item --- // + +class AccountWidgetContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.sessionsWidget'; + + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + const sessionsAccountWidgetAction = 'sessions.action.accountWidget'; + this._register(actionViewItemService.register(Menus.SidebarFooter, sessionsAccountWidgetAction, (action, options) => { + return instantiationService.createInstance(AccountWidget, action, options); + }, undefined)); + + // Register the action with menu item after the view item provider + // so the toolbar picks up the custom widget + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: sessionsAccountWidgetAction, + title: localize2('sessionsAccountWidget', 'Sessions Account'), + menu: { + id: Menus.SidebarFooter, + group: 'navigation', + order: 1, + } + }); + } + async run(): Promise { + // Handled by the custom view item + } + })); + } +} + +registerWorkbenchContribution2(AccountWidgetContribution.ID, AccountWidgetContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css new file mode 100644 index 0000000000000..ad72846d5c533 --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Account Widget */ +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget > .action-label { + display: none; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: 8px; +} + +/* Account Button */ +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account { + overflow: hidden; + min-width: 0; + flex: 1; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account-button { + border: none; + padding: 4px 8px; + font-size: 12px; + height: auto; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + background: transparent; + color: var(--vscode-sideBar-foreground); + width: 100%; + text-align: left; + justify-content: flex-start; + border-radius: 4px; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account-button:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +/* Update Button */ +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update { + overflow: hidden; + min-width: 0; + flex-shrink: 1; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update-button { + border: none; + padding: 4px 8px; + font-size: 12px; + height: auto; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + background: transparent; + color: var(--vscode-sideBar-foreground); + width: 100%; + text-align: left; + justify-content: flex-start; + border-radius: 4px; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update-button:hover:not(:disabled) { + background-color: var(--vscode-toolbar-hoverBackground); +} diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/SPEC.md b/src/vs/sessions/contrib/aiCustomizationManagement/browser/SPEC.md new file mode 100644 index 0000000000000..edfbe10fbc22a --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/SPEC.md @@ -0,0 +1,179 @@ +# AI Customization Management Editor Specification + +## Overview + +The AI Customization Management Editor is a global management surface for AI customizations. It provides sectioned navigation and a content area that switches between prompt lists, MCP servers, models, and an embedded editor. + +**Location:** `src/vs/sessions/contrib/aiCustomizationManagement/browser/` + +**Purpose:** Centralized discovery and management across worktree, user, and extension sources, optimized for agent sessions. + +## Architecture + +### Component Hierarchy + +``` +AICustomizationManagementEditor (EditorPane) +├── SplitView (Horizontal orientation) +│ ├── Sidebar Panel (Left) +│ │ └── WorkbenchList (sections) +│ └── Content Panel (Right) +│ ├── PromptsContent (AICustomizationListWidget) +│ ├── MCP Content (McpListWidget) +│ ├── Models Content (ChatModelsWidget) +│ └── Embedded Editor (CodeEditorWidget) +``` + +### File Structure + +``` +aiCustomizationManagement/browser/ +├── aiCustomizationManagement.ts # IDs + context keys +├── aiCustomizationManagement.contribution.ts # Commands + context menus +├── aiCustomizationManagementEditor.ts # SplitView list/editor +├── aiCustomizationManagementEditorInput.ts # Singleton input +├── aiCustomizationListWidget.ts # Search + grouped list +├── customizationCreatorService.ts # AI-guided creation flow +├── mcpListWidget.ts # MCP servers list +├── aiCustomizationOverviewView.ts # Overview view +└── media/ + └── aiCustomizationManagement.css +``` + +## Key Components + +### AICustomizationManagementEditorInput + +**Pattern:** Singleton editor input with dynamic tab title (section label). + +### AICustomizationManagementEditor + +**Responsibilities:** +- Manages section navigation and content swapping. +- Hosts embedded editor view for prompt files. +- Persists selected section and sidebar width. + +**Sections:** +- Agents, Skills, Instructions, Prompts, Hooks, MCP Servers, Models. + +**Embedded Editor:** +- Uses `CodeEditorWidget` for full editor UX. +- Auto-commits worktree files on exit via agent session command. + +**Overview View:** +- A compact view (`AICustomizationOverviewView`) shows counts and deep-links to sections. + +**Creation flows:** +- Manual create (worktree/user) with snippet templates. +- AI-guided create opens a new chat with hidden system instructions. + +### AICustomizationListWidget + +**Responsibilities:** +- Search + grouped list of prompt files by storage (Worktree/User/Extensions). +- Collapsible group headers. +- Storage badges and git status badges. + - Empty state UI with icon, title, and description. + - Section footer with description + docs link. + +**Search behavior:** +- Fuzzy matches across name, description, and filename. +- Debounced (200ms) filtering. + +**Active session scoping:** +- The active worktree comes from `IActiveSessionService` and is the source of truth for scoping. +- Prompt discovery is scoped by the agentic prompt service override using the active session root. +- Views refresh counts/filters when the active session changes. + +**Context menu actions:** +- Open, Run Prompt (prompts), Reveal in OS, Delete. +- Copy full path / relative path actions. + +**Add button behavior:** +- Primary action targets worktree when available, otherwise user. +- Dropdown offers User creation and AI-generated creation. +- Hooks use the built-in Configure Hooks flow and do not offer user-scoped creation. + +### McpListWidget + +**Responsibilities:** +- Lists MCP servers with status and actions. +- Provides add server flow and docs link. + - Search input with debounced filtering and an empty state. + +### Models Widget + +**Responsibilities:** +- Hosts the chat models management widget with a footer link. + +## Registration & Commands + +- Editor pane registered under `AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID`. +- Command `aiCustomization.openManagementEditor` opens the singleton editor. +- Command visibility and actions are gated by `ChatContextKeys.enabled`. + +## State and Context + +- Selected section and sidebar width are persisted to profile storage. +- Context keys: + - `aiCustomizationManagementEditorFocused` + - `aiCustomizationManagementSection` + +## User Workflows + +### Open Management Editor + +1. Run "Open AI Customizations" from the command palette. +2. Editor opens with the last selected section. + +### Create Items + +1. Use the Add button in the list header. +2. Choose worktree or user location (if available). +3. Optionally use "Generate" to start AI-guided creation. + +This is the only UI surface for creating new customizations. + +### Edit Items + +1. Click an item to open the embedded editor. +2. Use back to return to list; worktree files auto-commit. + +### Context Menu Actions + +1. Right-click a list item. +2. Choose Open, Run Prompt (prompts only), Reveal in OS, or Delete. +3. Use Copy Full Path / Copy Relative Path for quick path access. + +## Integration Points + +- `IPromptsService` for agent/skill/prompt/instructions discovery. +- `parseAllHookFiles` for hooks. +- `IActiveSessionService` for worktree filtering. +- `ISCMService` for git status badges. +- `ITextModelService` and `IFileService` for embedded editor I/O. +- `IDialogService` for delete confirmation and extension-file guardrails. +- `IOpenerService` for docs links and external navigation. + +## Service Alignment (Required) + +AI customizations must lean on existing VS Code services with well-defined interfaces. The management surface should not reimplement discovery, storage rules, or MCP lifecycle behavior. + +Browser compatibility is required. Do not use Node.js APIs; rely on VS Code services that work in browser contexts. + +Required services to prefer: +- Prompt discovery and metadata: [src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts](../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.ts) +- Active session scoping for worktrees: [src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsService.ts](../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.ts) +- MCP servers and connections: [src/vs/workbench/contrib/mcp/common/mcpService.ts](../../../../workbench/contrib/mcp/common/mcpService.ts) +- MCP management and gallery: [src/vs/platform/mcp/common/mcpManagement.ts](../../../../platform/mcp/common/mcpManagement.ts) +- Chat models: [src/vs/workbench/contrib/chat/common/chatService/chatService.ts](../../../../workbench/contrib/chat/common/chatService/chatService.ts) + +## Known Gaps + +- No bulk operations or sorting. +- Search query is not persisted between sessions. +- Hooks docs link is a placeholder and should be updated when available. + +--- + +*This specification documents the AI Customization Management Editor in `src/vs/sessions/contrib/aiCustomizationManagement/browser/`.* diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationListWidget.ts b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationListWidget.ts new file mode 100644 index 0000000000000..2b87f4219fa60 --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationListWidget.ts @@ -0,0 +1,1066 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/aiCustomizationManagement.css'; +import * as DOM from '../../../../base/browser/dom.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { basename, dirname } from '../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { localize } from '../../../../nls.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { WorkbenchList } from '../../../../platform/list/browser/listService.js'; +import { IListVirtualDelegate, IListRenderer, IListContextMenuEvent } from '../../../../base/browser/ui/list/list.js'; +import { IPromptsService, PromptsStorage, IPromptPath } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, userIcon, workspaceIcon, extensionIcon } from '../../aiCustomizationTreeView/browser/aiCustomizationTreeViewIcons.js'; +import { AICustomizationManagementItemMenuId, AICustomizationManagementSection, getActiveSessionRoot } from './aiCustomizationManagement.js'; +import { InputBox } from '../../../../base/browser/ui/inputbox/inputBox.js'; +import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { Delayer } from '../../../../base/common/async.js'; +import { IContextMenuService, IContextViewService } from '../../../../platform/contextview/browser/contextView.js'; +import { HighlightedLabel } from '../../../../base/browser/ui/highlightedlabel/highlightedLabel.js'; +import { matchesFuzzy, IMatch } from '../../../../base/common/filters.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { ButtonWithDropdown } from '../../../../base/browser/ui/button/button.js'; +import { IMenuService } from '../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { getFlatContextMenuActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { parseAllHookFiles } from '../../../../workbench/contrib/chat/browser/promptSyntax/hookUtils.js'; +import { OS } from '../../../../base/common/platform.js'; +import { IRemoteAgentService } from '../../../../workbench/services/remote/common/remoteAgentService.js'; +import { ISessionsWorkbenchService } from '../../sessions/browser/sessionsWorkbenchService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { Action, Separator } from '../../../../base/common/actions.js'; +import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; +import { ISCMService } from '../../../../workbench/contrib/scm/common/scm.js'; + +const $ = DOM.$; + +const ITEM_HEIGHT = 44; +const GROUP_HEADER_HEIGHT = 32; +const GROUP_HEADER_HEIGHT_WITH_SEPARATOR = 40; + +/** + * Represents an AI customization item in the list. + */ +export interface IAICustomizationListItem { + readonly id: string; + readonly uri: URI; + readonly name: string; + readonly filename: string; + readonly description?: string; + readonly storage: PromptsStorage; + readonly promptType: PromptsType; + gitStatus?: 'uncommitted' | 'committed'; + nameMatches?: IMatch[]; + descriptionMatches?: IMatch[]; +} + +/** + * Represents a collapsible group header in the list. + */ +interface IGroupHeaderEntry { + readonly type: 'group-header'; + readonly id: string; + readonly storage: PromptsStorage; + readonly label: string; + readonly icon: ThemeIcon; + readonly count: number; + readonly isFirst: boolean; + collapsed: boolean; +} + +/** + * Represents an individual file item in the list. + */ +interface IFileItemEntry { + readonly type: 'file-item'; + readonly item: IAICustomizationListItem; +} + +type IListEntry = IGroupHeaderEntry | IFileItemEntry; + +/** + * Delegate for the AI Customization list. + */ +class AICustomizationListDelegate implements IListVirtualDelegate { + getHeight(element: IListEntry): number { + if (element.type === 'group-header') { + return element.isFirst ? GROUP_HEADER_HEIGHT : GROUP_HEADER_HEIGHT_WITH_SEPARATOR; + } + return ITEM_HEIGHT; + } + + getTemplateId(element: IListEntry): string { + return element.type === 'group-header' ? 'groupHeader' : 'aiCustomizationItem'; + } +} + +interface IAICustomizationItemTemplateData { + readonly container: HTMLElement; + readonly actionsContainer: HTMLElement; + readonly nameLabel: HighlightedLabel; + readonly description: HighlightedLabel; + readonly storageBadge: HTMLElement; + readonly gitStatusBadge: HTMLElement; + readonly disposables: DisposableStore; + readonly elementDisposables: DisposableStore; +} + +interface IGroupHeaderTemplateData { + readonly container: HTMLElement; + readonly chevron: HTMLElement; + readonly icon: HTMLElement; + readonly label: HTMLElement; + readonly count: HTMLElement; + readonly disposables: DisposableStore; +} + +/** + * Renderer for collapsible group headers (Workspace, User, Extensions). + * Note: Click handling is done via the list's onDidOpen event, not here. + */ +class GroupHeaderRenderer implements IListRenderer { + readonly templateId = 'groupHeader'; + + renderTemplate(container: HTMLElement): IGroupHeaderTemplateData { + const disposables = new DisposableStore(); + container.classList.add('ai-customization-group-header'); + + const chevron = DOM.append(container, $('.group-chevron')); + const icon = DOM.append(container, $('.group-icon')); + const label = DOM.append(container, $('.group-label')); + const count = DOM.append(container, $('.group-count')); + + return { container, chevron, icon, label, count, disposables }; + } + + renderElement(element: IGroupHeaderEntry, _index: number, templateData: IGroupHeaderTemplateData): void { + // Chevron + templateData.chevron.className = 'group-chevron'; + templateData.chevron.classList.add(...ThemeIcon.asClassNameArray(element.collapsed ? Codicon.chevronRight : Codicon.chevronDown)); + + // Icon + templateData.icon.className = 'group-icon'; + templateData.icon.classList.add(...ThemeIcon.asClassNameArray(element.icon)); + + // Label + count + templateData.label.textContent = element.label; + templateData.count.textContent = `${element.count}`; + + // Collapsed state and separator for non-first groups + templateData.container.classList.toggle('collapsed', element.collapsed); + templateData.container.classList.toggle('has-previous-group', !element.isFirst); + } + + disposeTemplate(templateData: IGroupHeaderTemplateData): void { + templateData.disposables.dispose(); + } +} + +/** + * Renderer for AI customization list items. + */ +class AICustomizationItemRenderer implements IListRenderer { + readonly templateId = 'aiCustomizationItem'; + + renderTemplate(container: HTMLElement): IAICustomizationItemTemplateData { + const disposables = new DisposableStore(); + const elementDisposables = new DisposableStore(); + + container.classList.add('ai-customization-list-item'); + + const leftSection = DOM.append(container, $('.item-left')); + // Storage badge on left (shows workspace/user/extension) + const storageBadge = DOM.append(leftSection, $('.storage-badge')); + const textContainer = DOM.append(leftSection, $('.item-text')); + const nameLabel = disposables.add(new HighlightedLabel(DOM.append(textContainer, $('.item-name')))); + const description = disposables.add(new HighlightedLabel(DOM.append(textContainer, $('.item-description')))); + + // Git status badge (always visible, outside item-right hover container) + const gitStatusBadge = DOM.append(container, $('.git-status-badge')); + + // Right section for actions (hover-visible) + const actionsContainer = DOM.append(container, $('.item-right')); + + return { + container, + actionsContainer, + nameLabel, + description, + storageBadge, + gitStatusBadge, + disposables, + elementDisposables, + }; + } + + renderElement(entry: IFileItemEntry, index: number, templateData: IAICustomizationItemTemplateData): void { + templateData.elementDisposables.clear(); + const element = entry.item; + + // Name with highlights + templateData.nameLabel.set(element.name, element.nameMatches); + + // Description - show either description or filename as secondary text + const secondaryText = element.description || element.filename; + if (secondaryText) { + templateData.description.set(secondaryText, element.description ? element.descriptionMatches : undefined); + templateData.description.element.style.display = ''; + // Style differently for filename vs description + templateData.description.element.classList.toggle('is-filename', !element.description); + } else { + templateData.description.set('', undefined); + templateData.description.element.style.display = 'none'; + } + + // Storage badge + let storageBadgeIcon: ThemeIcon; + let storageBadgeLabel: string; + switch (element.storage) { + case PromptsStorage.local: + storageBadgeIcon = workspaceIcon; + storageBadgeLabel = localize('worktree', "Worktree"); + break; + case PromptsStorage.user: + storageBadgeIcon = userIcon; + storageBadgeLabel = localize('user', "User"); + break; + case PromptsStorage.extension: + storageBadgeIcon = extensionIcon; + storageBadgeLabel = localize('extension', "Extension"); + break; + } + + templateData.storageBadge.className = 'storage-badge'; + templateData.storageBadge.classList.add(...ThemeIcon.asClassNameArray(storageBadgeIcon)); + templateData.storageBadge.title = storageBadgeLabel; + + // Git status badge + const gitBadge = templateData.gitStatusBadge; + gitBadge.className = 'git-status-badge'; + if (element.gitStatus === 'committed') { + gitBadge.classList.add(...ThemeIcon.asClassNameArray(Codicon.check)); + gitBadge.classList.add('committed'); + gitBadge.textContent = ''; + gitBadge.title = localize('committedStatus', "Committed"); + gitBadge.style.display = ''; + } else { + gitBadge.style.display = 'none'; + } + } + + disposeTemplate(templateData: IAICustomizationItemTemplateData): void { + templateData.elementDisposables.dispose(); + templateData.disposables.dispose(); + } +} + +/** + * Maps section ID to prompt type. + */ +export function sectionToPromptType(section: AICustomizationManagementSection): PromptsType { + switch (section) { + case AICustomizationManagementSection.Agents: + return PromptsType.agent; + case AICustomizationManagementSection.Skills: + return PromptsType.skill; + case AICustomizationManagementSection.Instructions: + return PromptsType.instructions; + case AICustomizationManagementSection.Hooks: + return PromptsType.hook; + case AICustomizationManagementSection.Prompts: + default: + return PromptsType.prompt; + } +} + +/** + * Widget that displays a searchable list of AI customization items. + */ +export class AICustomizationListWidget extends Disposable { + + readonly element: HTMLElement; + + private sectionHeader!: HTMLElement; + private sectionDescription!: HTMLElement; + private sectionLink!: HTMLAnchorElement; + private searchAndButtonContainer!: HTMLElement; + private searchContainer!: HTMLElement; + private searchInput!: InputBox; + private addButton!: ButtonWithDropdown; + private listContainer!: HTMLElement; + private list!: WorkbenchList; + private emptyStateContainer!: HTMLElement; + private emptyStateIcon!: HTMLElement; + private emptyStateText!: HTMLElement; + private emptyStateSubtext!: HTMLElement; + + private currentSection: AICustomizationManagementSection = AICustomizationManagementSection.Agents; + private allItems: IAICustomizationListItem[] = []; + private displayEntries: IListEntry[] = []; + private searchQuery: string = ''; + private readonly collapsedGroups = new Set(); + + private readonly delayedFilter = new Delayer(200); + + private readonly _onDidSelectItem = this._register(new Emitter()); + readonly onDidSelectItem: Event = this._onDidSelectItem.event; + + private readonly _onDidChangeItemCount = this._register(new Emitter()); + readonly onDidChangeItemCount: Event = this._onDidChangeItemCount.event; + + private readonly _onDidRequestCreate = this._register(new Emitter()); + readonly onDidRequestCreate: Event = this._onDidRequestCreate.event; + + private readonly _onDidRequestCreateManual = this._register(new Emitter<{ type: PromptsType; target: 'worktree' | 'user' }>()); + readonly onDidRequestCreateManual: Event<{ type: PromptsType; target: 'worktree' | 'user' }> = this._onDidRequestCreateManual.event; + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IPromptsService private readonly promptsService: IPromptsService, + @IContextViewService private readonly contextViewService: IContextViewService, + @IOpenerService private readonly openerService: IOpenerService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IMenuService private readonly menuService: IMenuService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IFileService private readonly fileService: IFileService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @IPathService private readonly pathService: IPathService, + @ILabelService private readonly labelService: ILabelService, + @IRemoteAgentService private readonly remoteAgentService: IRemoteAgentService, + @ISessionsWorkbenchService private readonly activeSessionService: ISessionsWorkbenchService, + @ILogService private readonly logService: ILogService, + @IClipboardService private readonly clipboardService: IClipboardService, + @ISCMService private readonly scmService: ISCMService, + ) { + super(); + this.element = $('.ai-customization-list-widget'); + this.create(); + + this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => this.refresh())); + this._register(autorun(reader => { + this.activeSessionService.activeSession.read(reader); + this.updateAddButton(); + this.refresh(); + })); + + // Re-filter when SCM repositories change (updates git status badges after commits) + const trackRepoChanges = (repo: { provider: { onDidChangeResources: Event } }) => { + this._register(repo.provider.onDidChangeResources(() => { + this.updateGitStatus(this.allItems); + this.filterItems(); + })); + }; + for (const repo of [...this.scmService.repositories]) { + trackRepoChanges(repo); + } + this._register(this.scmService.onDidAddRepository(repo => trackRepoChanges(repo))); + + } + + private create(): void { + // Search and button container + this.searchAndButtonContainer = DOM.append(this.element, $('.list-search-and-button-container')); + + // Search container + this.searchContainer = DOM.append(this.searchAndButtonContainer, $('.list-search-container')); + this.searchInput = this._register(new InputBox(this.searchContainer, this.contextViewService, { + placeholder: localize('searchPlaceholder', "Type to search..."), + inputBoxStyles: defaultInputBoxStyles, + })); + + this._register(this.searchInput.onDidChange(() => { + this.searchQuery = this.searchInput.value; + this.delayedFilter.trigger(() => this.filterItems()); + })); + + // Add button with dropdown next to search + const addButtonContainer = DOM.append(this.searchAndButtonContainer, $('.list-add-button-container')); + this.addButton = this._register(new ButtonWithDropdown(addButtonContainer, { + ...defaultButtonStyles, + supportIcons: true, + contextMenuProvider: this.contextMenuService, + addPrimaryActionToDropdown: false, + actions: { getActions: () => this.getDropdownActions() }, + })); + this.addButton.element.classList.add('list-add-button'); + this._register(this.addButton.onDidClick(() => this.executePrimaryCreateAction())); + this.updateAddButton(); + + // List container + this.listContainer = DOM.append(this.element, $('.list-container')); + + // Empty state container + this.emptyStateContainer = DOM.append(this.element, $('.list-empty-state')); + this.emptyStateIcon = DOM.append(this.emptyStateContainer, $('.empty-state-icon')); + this.emptyStateText = DOM.append(this.emptyStateContainer, $('.empty-state-text')); + this.emptyStateSubtext = DOM.append(this.emptyStateContainer, $('.empty-state-subtext')); + this.emptyStateContainer.style.display = 'none'; + + // Create list + this.list = this._register(this.instantiationService.createInstance( + WorkbenchList, + 'AICustomizationManagementList', + this.listContainer, + new AICustomizationListDelegate(), + [ + new GroupHeaderRenderer(), + this.instantiationService.createInstance(AICustomizationItemRenderer), + ], + { + identityProvider: { + getId: (entry: IListEntry) => entry.type === 'group-header' ? entry.id : entry.item.id, + }, + accessibilityProvider: { + getAriaLabel: (entry: IListEntry) => { + if (entry.type === 'group-header') { + return localize('groupAriaLabel', "{0}, {1} items, {2}", entry.label, entry.count, entry.collapsed ? localize('collapsed', "collapsed") : localize('expanded', "expanded")); + } + return entry.item.description + ? localize('itemAriaLabel', "{0}, {1}", entry.item.name, entry.item.description) + : entry.item.name; + }, + getWidgetAriaLabel: () => localize('listAriaLabel', "AI Customizations"), + }, + keyboardNavigationLabelProvider: { + getKeyboardNavigationLabel: (entry: IListEntry) => entry.type === 'group-header' ? entry.label : entry.item.name, + }, + multipleSelectionSupport: false, + openOnSingleClick: true, + } + )); + + // Handle item selection (single click opens item, group header toggles) + this._register(this.list.onDidOpen(e => { + if (e.element) { + if (e.element.type === 'group-header') { + this.toggleGroup(e.element); + } else { + this._onDidSelectItem.fire(e.element.item); + } + } + })); + + // Handle context menu + this._register(this.list.onContextMenu(e => this.onContextMenu(e))); + + // Subscribe to prompt service changes + this._register(this.promptsService.onDidChangeCustomAgents(() => this.refresh())); + this._register(this.promptsService.onDidChangeSlashCommands(() => this.refresh())); + + // Section footer at bottom with description and link + this.sectionHeader = DOM.append(this.element, $('.section-footer')); + this.sectionDescription = DOM.append(this.sectionHeader, $('p.section-footer-description')); + this.sectionLink = DOM.append(this.sectionHeader, $('a.section-footer-link')) as HTMLAnchorElement; + this._register(DOM.addDisposableListener(this.sectionLink, 'click', (e) => { + e.preventDefault(); + const href = this.sectionLink.href; + if (href) { + this.openerService.open(URI.parse(href)); + } + })); + this.updateSectionHeader(); + } + + /** + * Handles context menu for list items. + */ + private onContextMenu(e: IListContextMenuEvent): void { + if (!e.element || e.element.type !== 'file-item') { + return; + } + + const item = e.element.item; + + // Create context for the menu actions + const context = { + uri: item.uri.toString(), + name: item.name, + promptType: item.promptType, + storage: item.storage, + }; + + // Get menu actions + const actions = this.menuService.getMenuActions(AICustomizationManagementItemMenuId, this.contextKeyService, { + arg: context, + shouldForwardArgs: true, + }); + + const flatActions = getFlatContextMenuActions(actions); + + // Add copy path actions + const copyActions = [ + new Separator(), + new Action('copyFullPath', localize('copyFullPath', "Copy Full Path"), undefined, true, async () => { + await this.clipboardService.writeText(item.uri.fsPath); + }), + new Action('copyRelativePath', localize('copyRelativePath', "Copy Relative Path"), undefined, true, async () => { + const basePath = getActiveSessionRoot(this.activeSessionService); + if (basePath && item.uri.fsPath.startsWith(basePath.fsPath)) { + const relative = item.uri.fsPath.substring(basePath.fsPath.length + 1); + await this.clipboardService.writeText(relative); + } else { + // Fallback to workspace-relative via label service + const relativePath = this.labelService.getUriLabel(item.uri, { relative: true }); + await this.clipboardService.writeText(relativePath); + } + }), + ]; + + this.contextMenuService.showContextMenu({ + getAnchor: () => e.anchor, + getActions: () => [...flatActions, ...copyActions], + }); + } + + /** + * Sets the current section and loads items for that section. + */ + async setSection(section: AICustomizationManagementSection): Promise { + this.currentSection = section; + this.updateSectionHeader(); + this.updateAddButton(); + await this.loadItems(); + } + + /** + * Updates the section header based on the current section. + */ + private updateSectionHeader(): void { + let description: string; + let docsUrl: string; + let learnMoreLabel: string; + switch (this.currentSection) { + case AICustomizationManagementSection.Agents: + description = localize('agentsDescription', "Configure the AI to adopt different personas tailored to specific development tasks. Each agent has its own instructions, tools, and behavior."); + docsUrl = 'https://code.visualstudio.com/docs/copilot/customization/custom-agents'; + learnMoreLabel = localize('learnMoreAgents', "Learn more about custom agents"); + break; + case AICustomizationManagementSection.Skills: + description = localize('skillsDescription', "Folders of instructions, scripts, and resources that Copilot loads when relevant to perform specialized tasks."); + docsUrl = 'https://code.visualstudio.com/docs/copilot/customization/agent-skills'; + learnMoreLabel = localize('learnMoreSkills', "Learn more about agent skills"); + break; + case AICustomizationManagementSection.Instructions: + description = localize('instructionsDescription', "Define common guidelines and rules that automatically influence how AI generates code and handles development tasks."); + docsUrl = 'https://code.visualstudio.com/docs/copilot/customization/custom-instructions'; + learnMoreLabel = localize('learnMoreInstructions', "Learn more about custom instructions"); + break; + case AICustomizationManagementSection.Hooks: + description = localize('hooksDescription', "Prompts executed at specific points during an agentic lifecycle."); + docsUrl = 'https://code.visualstudio.com/docs/copilot/customization/hooks'; + learnMoreLabel = localize('learnMoreHooks', "Learn more about hooks"); + break; + case AICustomizationManagementSection.Prompts: + default: + description = localize('promptsDescription', "Reusable prompts for common development tasks like generating code, performing reviews, or scaffolding components."); + docsUrl = 'https://code.visualstudio.com/docs/copilot/customization/prompt-files'; + learnMoreLabel = localize('learnMorePrompts', "Learn more about prompt files"); + break; + } + this.sectionDescription.textContent = description; + this.sectionLink.textContent = learnMoreLabel; + this.sectionLink.href = docsUrl; + } + + /** + * Updates the add button label based on the current section. + */ + private updateAddButton(): void { + const typeLabel = this.getTypeLabel(); + if (this.currentSection === AICustomizationManagementSection.Hooks) { + this.addButton.label = `$(${Codicon.add.id}) New ${typeLabel}`; + const hasWorktree = !!this.activeSessionService.getActiveSession()?.worktree; + this.addButton.enabled = hasWorktree; + const disabledTitle = hasWorktree + ? '' + : localize('hooksCreateDisabled', "Open a session with a worktree to configure hooks."); + this.addButton.primaryButton.setTitle(disabledTitle); + this.addButton.dropdownButton.setTitle(disabledTitle); + return; + } + this.addButton.primaryButton.setTitle(''); + this.addButton.dropdownButton.setTitle(''); + this.addButton.enabled = true; + const hasWorktree = this.hasActiveWorktree(); + if (hasWorktree) { + this.addButton.label = `$(${Codicon.add.id}) New ${typeLabel} (Worktree)`; + } else { + this.addButton.label = `$(${Codicon.add.id}) New ${typeLabel} (User)`; + } + } + + /** + * Gets the dropdown actions for the add button. + */ + private getDropdownActions(): Action[] { + const typeLabel = this.getTypeLabel(); + const actions: Action[] = []; + const promptType = sectionToPromptType(this.currentSection); + const hasWorktree = this.hasActiveWorktree(); + + if (hasWorktree && promptType !== PromptsType.hook) { + // Primary is worktree - dropdown shows user + generate + actions.push(new Action('createUser', `$(${Codicon.account.id}) New ${typeLabel} (User)`, undefined, true, () => { + this._onDidRequestCreateManual.fire({ type: promptType, target: 'user' }); + })); + } + + actions.push(new Action('createWithAI', `$(${Codicon.sparkle.id}) Generate ${typeLabel}`, undefined, true, () => { + this._onDidRequestCreate.fire(promptType); + })); + + return actions; + } + + /** + * Checks if there's an active session root (worktree or repository). + */ + private hasActiveWorktree(): boolean { + return !!getActiveSessionRoot(this.activeSessionService); + } + + /** + * Executes the primary create action based on context. + */ + private executePrimaryCreateAction(): void { + const promptType = sectionToPromptType(this.currentSection); + if (promptType === PromptsType.hook && !this.activeSessionService.getActiveSession()?.worktree) { + return; + } + const target = this.hasActiveWorktree() || promptType === PromptsType.hook ? 'worktree' : 'user'; + this._onDidRequestCreateManual.fire({ type: promptType, target }); + } + + /** + * Gets the type label for the current section. + */ + private getTypeLabel(): string { + switch (this.currentSection) { + case AICustomizationManagementSection.Agents: + return localize('agent', "Agent"); + case AICustomizationManagementSection.Skills: + return localize('skill', "Skill"); + case AICustomizationManagementSection.Instructions: + return localize('instructions', "Instructions"); + case AICustomizationManagementSection.Hooks: + return localize('hook', "Hook"); + case AICustomizationManagementSection.Prompts: + default: + return localize('prompt', "Prompt"); + } + } + + /** + * Refreshes the current section's items. + */ + async refresh(): Promise { + this.updateAddButton(); + await this.loadItems(); + } + + /** + * Loads items for the current section. + */ + private async loadItems(): Promise { + const promptType = sectionToPromptType(this.currentSection); + const items: IAICustomizationListItem[] = []; + + const folders = this.workspaceContextService.getWorkspace().folders; + const activeRepo = getActiveSessionRoot(this.activeSessionService); + this.logService.info(`[AICustomizationListWidget] loadItems: section=${this.currentSection}, promptType=${promptType}, workspaceFolders=[${folders.map(f => f.uri.toString()).join(', ')}], activeRepo=${activeRepo?.toString() ?? 'none'}`); + + + if (promptType === PromptsType.agent) { + // Use getCustomAgents which has parsed name/description from frontmatter + const agents = await this.promptsService.getCustomAgents(CancellationToken.None); + for (const agent of agents) { + const filename = basename(agent.uri); + items.push({ + id: agent.uri.toString(), + uri: agent.uri, + name: agent.name, + filename, + description: agent.description, + storage: agent.source.storage, + promptType, + }); + } + } else if (promptType === PromptsType.skill) { + // Use findAgentSkills which has parsed name/description from frontmatter + const skills = await this.promptsService.findAgentSkills(CancellationToken.None); + for (const skill of skills || []) { + const filename = basename(skill.uri); + const skillName = skill.name || basename(dirname(skill.uri)) || filename; + items.push({ + id: skill.uri.toString(), + uri: skill.uri, + name: skillName, + filename, + description: skill.description, + storage: skill.storage, + promptType, + }); + } + } else if (promptType === PromptsType.prompt) { + // Use getPromptSlashCommands which has parsed name/description from frontmatter + const commands = await this.promptsService.getPromptSlashCommands(CancellationToken.None); + for (const command of commands) { + const filename = basename(command.promptPath.uri); + items.push({ + id: command.promptPath.uri.toString(), + uri: command.promptPath.uri, + name: command.name, + filename, + description: command.description, + storage: command.promptPath.storage, + promptType, + }); + } + } else if (promptType === PromptsType.hook) { + // Parse hook files and display individual hooks + const workspaceFolder = this.workspaceContextService.getWorkspace().folders[0]; + const workspaceRootUri = workspaceFolder?.uri; + const userHomeUri = await this.pathService.userHome(); + const userHome = userHomeUri.fsPath ?? userHomeUri.path; + const remoteEnv = await this.remoteAgentService.getEnvironment(); + const targetOS = remoteEnv?.os ?? OS; + + const parsedHooks = await parseAllHookFiles( + this.promptsService, + this.fileService, + this.labelService, + workspaceRootUri, + userHome, + targetOS, + CancellationToken.None + ); + + for (const hook of parsedHooks) { + // Determine storage from the file path + const storage = hook.filePath.startsWith('~') ? PromptsStorage.user : PromptsStorage.local; + + items.push({ + id: `${hook.fileUri.toString()}#${hook.hookType}-${hook.index}`, + uri: hook.fileUri, + name: `${hook.hookTypeLabel}: ${hook.commandLabel}`, + filename: basename(hook.fileUri), + description: hook.filePath, + storage, + promptType, + }); + } + } else { + // For instructions, fetch once and group by storage + const allItems = await this.promptsService.listPromptFiles(promptType, CancellationToken.None); + const workspaceItems = allItems.filter(item => item.storage === PromptsStorage.local); + const userItems = allItems.filter(item => item.storage === PromptsStorage.user); + const extensionItems = allItems.filter(item => item.storage === PromptsStorage.extension); + + const mapToListItem = (item: IPromptPath): IAICustomizationListItem => { + const filename = basename(item.uri); + // For instructions, derive a friendly name from filename + const friendlyName = item.name || this.getFriendlyName(filename); + return { + id: item.uri.toString(), + uri: item.uri, + name: friendlyName, + filename, + description: item.description, + storage: item.storage, + promptType, + }; + }; + + items.push(...workspaceItems.map(mapToListItem)); + items.push(...userItems.map(mapToListItem)); + items.push(...extensionItems.map(mapToListItem)); + } + + // Sort items by name + items.sort((a, b) => a.name.localeCompare(b.name)); + + // Set git status for worktree (local) items + this.updateGitStatus(items); + + this.logService.info(`[AICustomizationListWidget] loadItems complete: ${items.length} items loaded [${items.map(i => `${i.name}(${i.storage}:${i.uri.toString()})`).join(', ')}]`); + + this.allItems = items; + this.filterItems(); + this._onDidChangeItemCount.fire(items.length); + } + + /** + * Updates git status on worktree items by checking SCM resource groups. + * Files found in resource groups have uncommitted changes; others are committed. + */ + private updateGitStatus(items: IAICustomizationListItem[]): void { + // Build a set of URIs that have uncommitted changes in SCM + const uncommittedUris = new Set(); + for (const repo of [...this.scmService.repositories]) { + for (const group of repo.provider.groups) { + for (const resource of group.resources) { + uncommittedUris.add(resource.sourceUri.toString()); + } + } + } + + for (const item of items) { + if (item.storage === PromptsStorage.local) { + item.gitStatus = uncommittedUris.has(item.uri.toString()) ? 'uncommitted' : 'committed'; + } + } + } + + /** + * Derives a friendly name from a filename by removing extension suffixes. + */ + private getFriendlyName(filename: string): string { + // Remove common prompt file extensions like .instructions.md, .prompt.md, etc. + let name = filename + .replace(/\.instructions\.md$/i, '') + .replace(/\.prompt\.md$/i, '') + .replace(/\.agent\.md$/i, '') + .replace(/\.md$/i, ''); + + // Convert kebab-case or snake_case to Title Case + name = name + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, c => c.toUpperCase()); + + return name || filename; + } + + /** + * Filters items based on the current search query and builds grouped display entries. + */ + private filterItems(): void { + let matchedItems: IAICustomizationListItem[]; + + if (!this.searchQuery.trim()) { + matchedItems = this.allItems.map(item => ({ ...item, nameMatches: undefined, descriptionMatches: undefined })); + } else { + const query = this.searchQuery.toLowerCase(); + matchedItems = []; + + for (const item of this.allItems) { + const nameMatches = matchesFuzzy(query, item.name, true); + const descriptionMatches = item.description ? matchesFuzzy(query, item.description, true) : null; + const filenameMatches = matchesFuzzy(query, item.filename, true); + + if (nameMatches || descriptionMatches || filenameMatches) { + matchedItems.push({ + ...item, + nameMatches: nameMatches || undefined, + descriptionMatches: descriptionMatches || undefined, + }); + } + } + } + + const totalBeforeFilter = matchedItems.length; + this.logService.info(`[AICustomizationListWidget] filterItems: allItems=${this.allItems.length}, matched=${totalBeforeFilter}`); + + // Group items by storage + const groups: { storage: PromptsStorage; label: string; icon: ThemeIcon; items: IAICustomizationListItem[] }[] = [ + { storage: PromptsStorage.local, label: localize('worktreeGroup', "Worktree"), icon: workspaceIcon, items: [] }, + { storage: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, items: [] }, + { storage: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, items: [] }, + ]; + + for (const item of matchedItems) { + const group = groups.find(g => g.storage === item.storage); + if (group) { + group.items.push(item); + } + } + + // Sort items within each group + for (const group of groups) { + group.items.sort((a, b) => a.name.localeCompare(b.name)); + } + + // Build display entries: group header + items (hidden if collapsed) + this.displayEntries = []; + let isFirstGroup = true; + for (const group of groups) { + if (group.items.length === 0) { + continue; + } + + const collapsed = this.collapsedGroups.has(group.storage); + + this.displayEntries.push({ + type: 'group-header', + id: `group-${group.storage}`, + storage: group.storage, + label: group.label, + icon: group.icon, + count: group.items.length, + isFirst: isFirstGroup, + collapsed, + }); + isFirstGroup = false; + + if (!collapsed) { + for (const item of group.items) { + this.displayEntries.push({ type: 'file-item', item }); + } + } + } + + this.list.splice(0, this.list.length, this.displayEntries); + this.logService.info(`[AICustomizationListWidget] filterItems complete: ${this.displayEntries.length} display entries spliced into list`); + this.updateEmptyState(); + } + + /** + * Toggles the collapsed state of a group. + */ + private toggleGroup(entry: IGroupHeaderEntry): void { + if (this.collapsedGroups.has(entry.storage)) { + this.collapsedGroups.delete(entry.storage); + } else { + this.collapsedGroups.add(entry.storage); + } + this.filterItems(); + } + + private updateEmptyState(): void { + const hasItems = this.displayEntries.length > 0; + if (!hasItems) { + this.emptyStateContainer.style.display = 'flex'; + this.listContainer.style.display = 'none'; + + // Update icon based on section + this.emptyStateIcon.className = 'empty-state-icon'; + const sectionIcon = this.getSectionIcon(); + this.emptyStateIcon.classList.add(...ThemeIcon.asClassNameArray(sectionIcon)); + + if (this.searchQuery.trim()) { + // Search with no results + this.emptyStateText.textContent = localize('noMatchingItems', "No items match '{0}'", this.searchQuery); + this.emptyStateSubtext.textContent = localize('tryDifferentSearch', "Try a different search term"); + } else { + // No items at all - show empty state with create hint + const emptyInfo = this.getEmptyStateInfo(); + this.emptyStateText.textContent = emptyInfo.title; + this.emptyStateSubtext.textContent = emptyInfo.description; + } + } else { + this.emptyStateContainer.style.display = 'none'; + this.listContainer.style.display = ''; + } + } + + private getSectionIcon(): ThemeIcon { + switch (this.currentSection) { + case AICustomizationManagementSection.Agents: + return agentIcon; + case AICustomizationManagementSection.Skills: + return skillIcon; + case AICustomizationManagementSection.Instructions: + return instructionsIcon; + case AICustomizationManagementSection.Hooks: + return hookIcon; + case AICustomizationManagementSection.Prompts: + default: + return promptIcon; + } + } + + private getEmptyStateInfo(): { title: string; description: string } { + switch (this.currentSection) { + case AICustomizationManagementSection.Agents: + return { + title: localize('noAgents', "No agents yet"), + description: localize('createFirstAgent', "Create your first custom agent to get started"), + }; + case AICustomizationManagementSection.Skills: + return { + title: localize('noSkills', "No skills yet"), + description: localize('createFirstSkill', "Create your first skill to extend agent capabilities"), + }; + case AICustomizationManagementSection.Instructions: + return { + title: localize('noInstructions', "No instructions yet"), + description: localize('createFirstInstructions', "Add instructions to teach Copilot about your codebase"), + }; + case AICustomizationManagementSection.Hooks: + return { + title: localize('noHooks', "No hooks yet"), + description: localize('createFirstHook', "Create hooks to execute commands at agent lifecycle events"), + }; + case AICustomizationManagementSection.Prompts: + default: + return { + title: localize('noPrompts', "No prompts yet"), + description: localize('createFirstPrompt', "Create reusable prompts for common tasks"), + }; + } + } + + /** + * Sets the search query programmatically. + */ + setSearchQuery(query: string): void { + this.searchInput.value = query; + } + + /** + * Clears the search query. + */ + clearSearch(): void { + this.searchInput.value = ''; + } + + /** + * Focuses the search input. + */ + focusSearch(): void { + this.searchInput.focus(); + } + + /** + * Focuses the list. + */ + focusList(): void { + this.list.domFocus(); + if (this.displayEntries.length > 0) { + this.list.setFocus([0]); + } + } + + /** + * Layouts the widget. + */ + layout(height: number, width: number): void { + const sectionFooterHeight = this.sectionHeader.offsetHeight || 100; + const searchBarHeight = this.searchAndButtonContainer.offsetHeight || 40; + const margins = 12; // search margin (6+6), not included in offsetHeight + const listHeight = height - sectionFooterHeight - searchBarHeight - margins; + + this.searchInput.layout(); + this.listContainer.style.height = `${Math.max(0, listHeight)}px`; + this.list.layout(Math.max(0, listHeight), width); + } + + /** + * Gets the total item count (before filtering). + */ + get itemCount(): number { + return this.allItems.length; + } +} diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.contribution.ts b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.contribution.ts new file mode 100644 index 0000000000000..9450cd347d37b --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.contribution.ts @@ -0,0 +1,294 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { IEditorPaneRegistry, EditorPaneDescriptor } from '../../../../workbench/browser/editor.js'; +import { EditorExtensions, IEditorFactoryRegistry, IEditorSerializer } from '../../../../workbench/common/editor.js'; +import { EditorInput } from '../../../../workbench/common/editor/editorInput.js'; +import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; +import { AICustomizationManagementEditor } from './aiCustomizationManagementEditor.js'; +import { AICustomizationManagementEditorInput } from './aiCustomizationManagementEditorInput.js'; +import { + AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID, + AI_CUSTOMIZATION_MANAGEMENT_EDITOR_INPUT_ID, + AICustomizationManagementCommands, + AICustomizationManagementItemMenuId, +} from './aiCustomizationManagement.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { basename } from '../../../../base/common/resources.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { isWindows, isMacintosh } from '../../../../base/common/platform.js'; + +//#region Editor Registration + +Registry.as(EditorExtensions.EditorPane).registerEditorPane( + EditorPaneDescriptor.create( + AICustomizationManagementEditor, + AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID, + localize('aiCustomizationManagementEditor', "AI Customizations Editor") + ), + [ + // Note: Using the class directly since we use a singleton pattern + new SyncDescriptor(AICustomizationManagementEditorInput as unknown as { new(): AICustomizationManagementEditorInput }) + ] +); + +//#endregion + +//#region Editor Serializer + +class AICustomizationManagementEditorInputSerializer implements IEditorSerializer { + + canSerialize(editorInput: EditorInput): boolean { + return editorInput instanceof AICustomizationManagementEditorInput; + } + + serialize(input: AICustomizationManagementEditorInput): string { + return ''; + } + + deserialize(instantiationService: IInstantiationService): AICustomizationManagementEditorInput { + return AICustomizationManagementEditorInput.getOrCreate(); + } +} + +Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer( + AI_CUSTOMIZATION_MANAGEMENT_EDITOR_INPUT_ID, + AICustomizationManagementEditorInputSerializer +); + +//#endregion + +//#region Context Menu Actions + +/** + * Type for context passed to actions from list context menus. + * Handles both direct URI arguments and serialized context objects. + */ +type AICustomizationContext = { + uri: URI | string; + name?: string; + promptType?: PromptsType; + storage?: PromptsStorage; + [key: string]: unknown; +} | URI | string; + +/** + * Extracts a URI from various context formats. + */ +function extractURI(context: AICustomizationContext): URI { + if (URI.isUri(context)) { + return context; + } + if (typeof context === 'string') { + return URI.parse(context); + } + if (URI.isUri(context.uri)) { + return context.uri; + } + return URI.parse(context.uri as string); +} + +/** + * Extracts storage type from context. + */ +function extractStorage(context: AICustomizationContext): PromptsStorage | undefined { + if (URI.isUri(context) || typeof context === 'string') { + return undefined; + } + return context.storage; +} + +// Open file action +const OPEN_AI_CUSTOMIZATION_MGMT_FILE_ID = 'aiCustomizationManagement.openFile'; +registerAction2(class extends Action2 { + constructor() { + super({ + id: OPEN_AI_CUSTOMIZATION_MGMT_FILE_ID, + title: localize2('open', "Open"), + icon: Codicon.goToFile, + }); + } + async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise { + const editorService = accessor.get(IEditorService); + await editorService.openEditor({ + resource: extractURI(context) + }); + } +}); + + +// Run prompt action +const RUN_PROMPT_MGMT_ID = 'aiCustomizationManagement.runPrompt'; +registerAction2(class extends Action2 { + constructor() { + super({ + id: RUN_PROMPT_MGMT_ID, + title: localize2('runPrompt', "Run Prompt"), + icon: Codicon.play, + }); + } + async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise { + const commandService = accessor.get(ICommandService); + await commandService.executeCommand('workbench.action.chat.run.prompt.current', extractURI(context)); + } +}); + +// Reveal in Finder/Explorer action +const REVEAL_IN_OS_LABEL = isWindows + ? localize2('revealInWindows', "Reveal in File Explorer") + : isMacintosh + ? localize2('revealInMac', "Reveal in Finder") + : localize2('openContainer', "Open Containing Folder"); + +const REVEAL_AI_CUSTOMIZATION_IN_OS_ID = 'aiCustomizationManagement.revealInOS'; +registerAction2(class extends Action2 { + constructor() { + super({ + id: REVEAL_AI_CUSTOMIZATION_IN_OS_ID, + title: REVEAL_IN_OS_LABEL, + icon: Codicon.folderOpened, + }); + } + async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise { + const commandService = accessor.get(ICommandService); + const uri = extractURI(context); + // Use existing reveal command + await commandService.executeCommand('revealFileInOS', uri); + } +}); + +// Delete action +const DELETE_AI_CUSTOMIZATION_ID = 'aiCustomizationManagement.delete'; +registerAction2(class extends Action2 { + constructor() { + super({ + id: DELETE_AI_CUSTOMIZATION_ID, + title: localize2('delete', "Delete"), + icon: Codicon.trash, + }); + } + async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise { + const fileService = accessor.get(IFileService); + const dialogService = accessor.get(IDialogService); + + const uri = extractURI(context); + const fileName = basename(uri); + const storage = extractStorage(context); + + // Extension files cannot be deleted + if (storage === PromptsStorage.extension) { + await dialogService.info( + localize('cannotDeleteExtension', "Cannot Delete Extension File"), + localize('cannotDeleteExtensionDetail', "Files provided by extensions cannot be deleted. You can disable the extension if you no longer want to use this customization.") + ); + return; + } + + // Confirm deletion + const confirmation = await dialogService.confirm({ + message: localize('confirmDelete', "Are you sure you want to delete '{0}'?", fileName), + detail: localize('confirmDeleteDetail', "This action cannot be undone."), + primaryButton: localize('delete', "Delete"), + type: 'warning', + }); + + if (confirmation.confirmed) { + await fileService.del(uri, { useTrash: true }); + } + } +}); + +// Context Key for prompt type to conditionally show "Run Prompt" +const AI_CUSTOMIZATION_ITEM_TYPE_KEY = 'aiCustomizationManagementItemType'; + +// Register context menu items +MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { + command: { id: OPEN_AI_CUSTOMIZATION_MGMT_FILE_ID, title: localize('open', "Open") }, + group: '1_open', + order: 1, +}); + +MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { + command: { id: RUN_PROMPT_MGMT_ID, title: localize('runPrompt', "Run Prompt"), icon: Codicon.play }, + group: '2_run', + order: 1, + when: ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.prompt), +}); + +MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { + command: { id: REVEAL_AI_CUSTOMIZATION_IN_OS_ID, title: REVEAL_IN_OS_LABEL.value }, + group: '3_file', + order: 1, + when: ContextKeyExpr.or( + ContextKeyExpr.regex('aiCustomizationManagementItemUri', new RegExp(`^${Schemas.file}:`)), + ContextKeyExpr.regex('aiCustomizationManagementItemUri', new RegExp(`^${Schemas.vscodeUserData}:`)) + ), +}); + +MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { + command: { id: DELETE_AI_CUSTOMIZATION_ID, title: localize('delete', "Delete") }, + group: '4_modify', + order: 1, +}); + +//#endregion + +//#region Actions + +class AICustomizationManagementActionsContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.aiCustomizationManagementActions'; + + constructor() { + super(); + this.registerActions(); + } + + private registerActions(): void { + // Open AI Customizations Editor + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: AICustomizationManagementCommands.OpenEditor, + title: localize2('openAICustomizations', "Open AI Customizations"), + category: CHAT_CATEGORY, + precondition: ChatContextKeys.enabled, + f1: true, + }); + } + + async run(accessor: ServicesAccessor): Promise { + const editorGroupsService = accessor.get(IEditorGroupsService); + const input = AICustomizationManagementEditorInput.getOrCreate(); + await editorGroupsService.activeGroup.openEditor(input, { pinned: true }); + } + })); + } +} + +registerWorkbenchContribution2( + AICustomizationManagementActionsContribution.ID, + AICustomizationManagementActionsContribution, + WorkbenchPhase.AfterRestored +); + +//#endregion diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.ts b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.ts new file mode 100644 index 0000000000000..e087542891a29 --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagement.ts @@ -0,0 +1,102 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ISessionsWorkbenchService } from '../../sessions/browser/sessionsWorkbenchService.js'; +import { localize } from '../../../../nls.js'; +import { MenuId } from '../../../../platform/actions/common/actions.js'; + +/** + * Editor pane ID for the AI Customizations Management Editor. + */ +export const AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID = 'workbench.editor.aiCustomizationManagement'; + +/** + * Editor input type ID for serialization. + */ +export const AI_CUSTOMIZATION_MANAGEMENT_EDITOR_INPUT_ID = 'workbench.input.aiCustomizationManagement'; + +/** + * Command IDs for the AI Customizations Management Editor. + */ +export const AICustomizationManagementCommands = { + OpenEditor: 'aiCustomization.openManagementEditor', + CreateNewAgent: 'aiCustomization.createNewAgent', + CreateNewSkill: 'aiCustomization.createNewSkill', + CreateNewInstructions: 'aiCustomization.createNewInstructions', + CreateNewPrompt: 'aiCustomization.createNewPrompt', +} as const; + +/** + * Section IDs for the sidebar navigation. + */ +export const AICustomizationManagementSection = { + Agents: 'agents', + Skills: 'skills', + Instructions: 'instructions', + Prompts: 'prompts', + Hooks: 'hooks', + McpServers: 'mcpServers', + Models: 'models', +} as const; + +export type AICustomizationManagementSection = typeof AICustomizationManagementSection[keyof typeof AICustomizationManagementSection]; + +/** + * Context key indicating the AI Customization Management Editor is focused. + */ +export const CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_EDITOR = new RawContextKey( + 'aiCustomizationManagementEditorFocused', + false, + localize('aiCustomizationManagementEditorFocused', "Whether the AI Customizations editor is focused") +); + +/** + * Context key for the currently selected section. + */ +export const CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_SECTION = new RawContextKey( + 'aiCustomizationManagementSection', + AICustomizationManagementSection.Agents, + localize('aiCustomizationManagementSection', "The currently selected section in the AI Customizations editor") +); + +/** + * Menu ID for the AI Customization Management Editor title bar actions. + */ +export const AICustomizationManagementTitleMenuId = MenuId.for('AICustomizationManagementEditorTitle'); + +/** + * Menu ID for the AI Customization Management Editor item context menu. + */ +export const AICustomizationManagementItemMenuId = MenuId.for('AICustomizationManagementEditorItem'); + +/** + * Storage key for persisting the selected section. + */ +export const AI_CUSTOMIZATION_MANAGEMENT_SELECTED_SECTION_KEY = 'aiCustomizationManagement.selectedSection'; + +/** + * Storage key for persisting the sidebar width. + */ +export const AI_CUSTOMIZATION_MANAGEMENT_SIDEBAR_WIDTH_KEY = 'aiCustomizationManagement.sidebarWidth'; + +/** + * Storage key for persisting the search query. + */ +export const AI_CUSTOMIZATION_MANAGEMENT_SEARCH_KEY = 'aiCustomizationManagement.searchQuery'; + +/** + * Layout constants for the editor. + */ +export const SIDEBAR_DEFAULT_WIDTH = 200; +export const SIDEBAR_MIN_WIDTH = 150; +export const SIDEBAR_MAX_WIDTH = 350; +export const CONTENT_MIN_WIDTH = 400; + +export function getActiveSessionRoot(activeSessionService: ISessionsWorkbenchService): URI | undefined { + const session = activeSessionService.getActiveSession(); + return session?.worktree ?? session?.repository; +} diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagementEditor.ts b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagementEditor.ts new file mode 100644 index 0000000000000..10fad486f0ca8 --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagementEditor.ts @@ -0,0 +1,804 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/aiCustomizationManagement.css'; +import * as DOM from '../../../../base/browser/dom.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { DisposableStore, IReference, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Event } from '../../../../base/common/event.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { Orientation, Sizing, SplitView } from '../../../../base/browser/ui/splitview/splitview.js'; +import { CodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { IResolvedTextEditorModel, ITextModelService } from '../../../../editor/common/services/resolverService.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; +import { getSimpleEditorOptions } from '../../../../workbench/contrib/codeEditor/browser/simpleEditorOptions.js'; +import { localize } from '../../../../nls.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; +import { EditorPane } from '../../../../workbench/browser/parts/editor/editorPane.js'; +import { IEditorOpenContext } from '../../../../workbench/common/editor.js'; +import { IEditorGroup } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { WorkbenchList } from '../../../../platform/list/browser/listService.js'; +import { IListVirtualDelegate, IListRenderer } from '../../../../base/browser/ui/list/list.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { basename, isEqual } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { registerColor } from '../../../../platform/theme/common/colorRegistry.js'; +import { PANEL_BORDER } from '../../../../workbench/common/theme.js'; +import { AICustomizationManagementEditorInput } from './aiCustomizationManagementEditorInput.js'; +import { AICustomizationListWidget, IAICustomizationListItem } from './aiCustomizationListWidget.js'; +import { McpListWidget } from './mcpListWidget.js'; +import { + AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID, + AI_CUSTOMIZATION_MANAGEMENT_SIDEBAR_WIDTH_KEY, + AI_CUSTOMIZATION_MANAGEMENT_SELECTED_SECTION_KEY, + AICustomizationManagementSection, + CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_EDITOR, + CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_SECTION, + SIDEBAR_DEFAULT_WIDTH, + SIDEBAR_MIN_WIDTH, + SIDEBAR_MAX_WIDTH, + CONTENT_MIN_WIDTH, + getActiveSessionRoot, +} from './aiCustomizationManagement.js'; +import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon } from '../../aiCustomizationTreeView/browser/aiCustomizationTreeViewIcons.js'; +import { ChatModelsWidget } from '../../../../workbench/contrib/chat/browser/chatManagement/chatModelsWidget.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { INewPromptOptions, NEW_PROMPT_COMMAND_ID, NEW_INSTRUCTIONS_COMMAND_ID, NEW_AGENT_COMMAND_ID, NEW_SKILL_COMMAND_ID } from '../../../../workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.js'; +import { showConfigureHooksQuickPick } from '../../../../workbench/contrib/chat/browser/promptSyntax/hookActions.js'; +import { CustomizationCreatorService } from './customizationCreatorService.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { ISessionsWorkbenchService } from '../../sessions/browser/sessionsWorkbenchService.js'; +import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { IWorkingCopyService } from '../../../../workbench/services/workingCopy/common/workingCopyService.js'; + +const $ = DOM.$; + +export const aiCustomizationManagementSashBorder = registerColor( + 'aiCustomizationManagement.sashBorder', + PANEL_BORDER, + localize('aiCustomizationManagementSashBorder', "The color of the AI Customization Management editor splitview sash border.") +); + +//#region Sidebar Section Item + +interface ISectionItem { + readonly id: AICustomizationManagementSection; + readonly label: string; + readonly icon: ThemeIcon; +} + +class SectionItemDelegate implements IListVirtualDelegate { + getHeight(): number { + return 26; + } + + getTemplateId(): string { + return 'sectionItem'; + } +} + +interface ISectionItemTemplateData { + readonly container: HTMLElement; + readonly icon: HTMLElement; + readonly label: HTMLElement; +} + +class SectionItemRenderer implements IListRenderer { + readonly templateId = 'sectionItem'; + + renderTemplate(container: HTMLElement): ISectionItemTemplateData { + container.classList.add('section-list-item'); + const icon = DOM.append(container, $('.section-icon')); + const label = DOM.append(container, $('.section-label')); + return { container, icon, label }; + } + + renderElement(element: ISectionItem, index: number, templateData: ISectionItemTemplateData): void { + templateData.icon.className = 'section-icon'; + templateData.icon.classList.add(...ThemeIcon.asClassNameArray(element.icon)); + templateData.label.textContent = element.label; + } + + disposeTemplate(): void { } +} + +//#endregion + +/** + * Editor pane for the AI Customizations Management Editor. + * Provides a global view of all AI customizations with a sidebar for navigation + * and a content area showing a searchable list of items. + */ +export class AICustomizationManagementEditor extends EditorPane { + + static readonly ID = AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID; + + private container!: HTMLElement; + private splitViewContainer!: HTMLElement; + private splitView!: SplitView; + private sidebarContainer!: HTMLElement; + private sectionsList!: WorkbenchList; + private contentContainer!: HTMLElement; + private listWidget!: AICustomizationListWidget; + private mcpListWidget!: McpListWidget; + private modelsWidget!: ChatModelsWidget; + private promptsContentContainer!: HTMLElement; + private mcpContentContainer!: HTMLElement; + private modelsContentContainer!: HTMLElement; + private modelsFooterElement!: HTMLElement; + + // Embedded editor state + private editorContentContainer!: HTMLElement; + private embeddedEditorContainer!: HTMLElement; + private embeddedEditor!: CodeEditorWidget; + private editorItemNameElement!: HTMLElement; + private editorItemPathElement!: HTMLElement; + private editorSaveIndicator!: HTMLElement; + private readonly editorModelChangeDisposables = this._register(new DisposableStore()); + private currentEditingUri: URI | undefined; + private currentWorktreeUri: URI | undefined; + private currentEditingIsWorktree = false; + private currentModelRef: IReference | undefined; + private viewMode: 'list' | 'editor' = 'list'; + + private dimension: DOM.Dimension | undefined; + private readonly sections: ISectionItem[] = []; + private selectedSection: AICustomizationManagementSection = AICustomizationManagementSection.Agents; + + private readonly editorDisposables = this._register(new DisposableStore()); + private readonly inputDisposables = this._register(new MutableDisposable()); + private readonly customizationCreator: CustomizationCreatorService; + + private readonly inEditorContextKey: IContextKey; + private readonly sectionContextKey: IContextKey; + + constructor( + group: IEditorGroup, + @ITelemetryService telemetryService: ITelemetryService, + @IThemeService themeService: IThemeService, + @IStorageService private readonly storageService: IStorageService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IOpenerService private readonly openerService: IOpenerService, + @ITextModelService private readonly textModelService: ITextModelService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @ILayoutService private readonly layoutService: ILayoutService, + @ICommandService private readonly commandService: ICommandService, + @ISessionsWorkbenchService private readonly activeSessionService: ISessionsWorkbenchService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IWorkingCopyService private readonly workingCopyService: IWorkingCopyService, + ) { + super(AICustomizationManagementEditor.ID, group, telemetryService, themeService, storageService); + + this.inEditorContextKey = CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_EDITOR.bindTo(contextKeyService); + this.sectionContextKey = CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_SECTION.bindTo(contextKeyService); + + this.customizationCreator = this.instantiationService.createInstance(CustomizationCreatorService); + + this._register(autorun(reader => { + this.activeSessionService.activeSession.read(reader); + if (this.viewMode !== 'editor' || !this.currentEditingIsWorktree) { + return; + } + this.currentWorktreeUri = getActiveSessionRoot(this.activeSessionService); + })); + + // Safety disposal for the embedded editor model reference + this._register(toDisposable(() => { + this.currentModelRef?.dispose(); + this.currentModelRef = undefined; + })); + + this.sections.push( + { id: AICustomizationManagementSection.Agents, label: localize('agents', "Agents"), icon: agentIcon }, + { id: AICustomizationManagementSection.Skills, label: localize('skills', "Skills"), icon: skillIcon }, + { id: AICustomizationManagementSection.Instructions, label: localize('instructions', "Instructions"), icon: instructionsIcon }, + { id: AICustomizationManagementSection.Prompts, label: localize('prompts', "Prompts"), icon: promptIcon }, + { id: AICustomizationManagementSection.Hooks, label: localize('hooks', "Hooks"), icon: hookIcon }, + { id: AICustomizationManagementSection.McpServers, label: localize('mcpServers', "MCP Servers"), icon: Codicon.server }, + { id: AICustomizationManagementSection.Models, label: localize('models', "Models"), icon: Codicon.vm }, + ); + + // Restore selected section from storage + const savedSection = this.storageService.get(AI_CUSTOMIZATION_MANAGEMENT_SELECTED_SECTION_KEY, StorageScope.PROFILE); + if (savedSection && Object.values(AICustomizationManagementSection).includes(savedSection as AICustomizationManagementSection)) { + this.selectedSection = savedSection as AICustomizationManagementSection; + } + } + + protected override createEditor(parent: HTMLElement): void { + this.editorDisposables.clear(); + this.container = DOM.append(parent, $('.ai-customization-management-editor')); + + this.createSplitView(); + this.updateStyles(); + } + + private createSplitView(): void { + this.splitViewContainer = DOM.append(this.container, $('.management-split-view')); + + this.sidebarContainer = $('.management-sidebar'); + this.contentContainer = $('.management-content'); + + this.createSidebar(); + this.createContent(); + + this.splitView = this.editorDisposables.add(new SplitView(this.splitViewContainer, { + orientation: Orientation.HORIZONTAL, + proportionalLayout: true, + })); + + const savedWidth = this.storageService.getNumber(AI_CUSTOMIZATION_MANAGEMENT_SIDEBAR_WIDTH_KEY, StorageScope.PROFILE, SIDEBAR_DEFAULT_WIDTH); + + // Sidebar view + this.splitView.addView({ + onDidChange: Event.None, + element: this.sidebarContainer, + minimumSize: SIDEBAR_MIN_WIDTH, + maximumSize: SIDEBAR_MAX_WIDTH, + layout: (width, _, height) => { + this.sidebarContainer.style.width = `${width}px`; + if (height !== undefined) { + const listHeight = height - 24; + this.sectionsList.layout(listHeight, width); + } + }, + }, savedWidth, undefined, true); + + // Content view + this.splitView.addView({ + onDidChange: Event.None, + element: this.contentContainer, + minimumSize: CONTENT_MIN_WIDTH, + maximumSize: Number.POSITIVE_INFINITY, + layout: (width, _, height) => { + this.contentContainer.style.width = `${width}px`; + if (height !== undefined) { + this.listWidget.layout(height - 16, width - 24); // Account for padding + this.mcpListWidget.layout(height - 16, width - 24); // Account for padding + // Models widget has footer, subtract footer height + const modelsFooterHeight = this.modelsFooterElement?.offsetHeight || 80; + this.modelsWidget.layout(height - 16 - modelsFooterHeight, width); + + // Layout embedded editor when in editor mode + if (this.viewMode === 'editor' && this.embeddedEditor) { + const editorHeaderHeight = 50; // Back button + item info header + const padding = 24; // Content inner padding + const editorHeight = height - editorHeaderHeight - padding; + const editorWidth = width - padding; + this.embeddedEditor.layout({ width: Math.max(0, editorWidth), height: Math.max(0, editorHeight) }); + } + } + }, + }, Sizing.Distribute, undefined, true); + + // Persist sidebar width + this.editorDisposables.add(this.splitView.onDidSashChange(() => { + const width = this.splitView.getViewSize(0); + this.storageService.store(AI_CUSTOMIZATION_MANAGEMENT_SIDEBAR_WIDTH_KEY, width, StorageScope.PROFILE, StorageTarget.USER); + })); + + // Reset on double-click + this.editorDisposables.add(this.splitView.onDidSashReset(() => { + const totalWidth = this.splitView.getViewSize(0) + this.splitView.getViewSize(1); + this.splitView.resizeView(0, SIDEBAR_DEFAULT_WIDTH); + this.splitView.resizeView(1, totalWidth - SIDEBAR_DEFAULT_WIDTH); + })); + } + + private createSidebar(): void { + const sidebarContent = DOM.append(this.sidebarContainer, $('.sidebar-content')); + + // Main sections list container (takes remaining space) + const sectionsListContainer = DOM.append(sidebarContent, $('.sidebar-sections-list')); + + this.sectionsList = this.editorDisposables.add(this.instantiationService.createInstance( + WorkbenchList, + 'AICustomizationManagementSections', + sectionsListContainer, + new SectionItemDelegate(), + [new SectionItemRenderer()], + { + multipleSelectionSupport: false, + setRowLineHeight: false, + horizontalScrolling: false, + accessibilityProvider: { + getAriaLabel: (item: ISectionItem) => item.label, + getWidgetAriaLabel: () => localize('sectionsAriaLabel', "AI Customization Sections"), + }, + openOnSingleClick: true, + identityProvider: { + getId: (item: ISectionItem) => item.id, + }, + } + )); + + this.sectionsList.splice(0, this.sectionsList.length, this.sections); + + // Select the saved section + const selectedIndex = this.sections.findIndex(s => s.id === this.selectedSection); + if (selectedIndex >= 0) { + this.sectionsList.setSelection([selectedIndex]); + } + + this.editorDisposables.add(this.sectionsList.onDidChangeSelection(e => { + if (e.elements.length > 0) { + this.selectSection(e.elements[0].id); + } + })); + } + + private createContent(): void { + const contentInner = DOM.append(this.contentContainer, $('.content-inner')); + + // Container for prompts-based content (Agents, Skills, Instructions, Prompts) + this.promptsContentContainer = DOM.append(contentInner, $('.prompts-content-container')); + this.listWidget = this.editorDisposables.add(this.instantiationService.createInstance(AICustomizationListWidget)); + this.promptsContentContainer.appendChild(this.listWidget.element); + + // Handle item selection - open in embedded editor + this.editorDisposables.add(this.listWidget.onDidSelectItem(item => { + this.openItem(item); + })); + + // Handle create actions - AI-guided creation + this.editorDisposables.add(this.listWidget.onDidRequestCreate(promptType => { + this.createNewItemWithAI(promptType); + })); + + // Handle manual create actions - open editor directly + this.editorDisposables.add(this.listWidget.onDidRequestCreateManual(({ type, target }) => { + this.createNewItemManual(type, target); + })); + + // Container for Models content + this.modelsContentContainer = DOM.append(contentInner, $('.models-content-container')); + + this.modelsWidget = this.editorDisposables.add(this.instantiationService.createInstance(ChatModelsWidget)); + this.modelsContentContainer.appendChild(this.modelsWidget.element); + + // Models description footer + this.modelsFooterElement = DOM.append(this.modelsContentContainer, $('.section-footer')); + const modelsDescription = DOM.append(this.modelsFooterElement, $('p.section-footer-description')); + modelsDescription.textContent = localize('modelsDescription', "Browse and manage language models from different providers. Select models for use in chat, code completion, and other AI features."); + const modelsLink = DOM.append(this.modelsFooterElement, $('a.section-footer-link')) as HTMLAnchorElement; + modelsLink.textContent = localize('learnMoreModels', "Learn more about language models"); + modelsLink.href = 'https://code.visualstudio.com/docs/copilot/customization/language-models'; + this.editorDisposables.add(DOM.addDisposableListener(modelsLink, 'click', (e) => { + e.preventDefault(); + this.openerService.open(URI.parse(modelsLink.href)); + })); + + // Container for MCP content + this.mcpContentContainer = DOM.append(contentInner, $('.mcp-content-container')); + this.mcpListWidget = this.editorDisposables.add(this.instantiationService.createInstance(McpListWidget)); + this.mcpContentContainer.appendChild(this.mcpListWidget.element); + + // Container for embedded editor view + this.editorContentContainer = DOM.append(contentInner, $('.editor-content-container')); + this.createEmbeddedEditor(); + + // Set initial visibility based on selected section + this.updateContentVisibility(); + + // Load items for the initial section + if (this.isPromptsSection(this.selectedSection)) { + void this.listWidget.setSection(this.selectedSection); + } + } + + private isPromptsSection(section: AICustomizationManagementSection): boolean { + return section === AICustomizationManagementSection.Agents || + section === AICustomizationManagementSection.Skills || + section === AICustomizationManagementSection.Instructions || + section === AICustomizationManagementSection.Prompts || + section === AICustomizationManagementSection.Hooks; + } + + private selectSection(section: AICustomizationManagementSection): void { + if (this.selectedSection === section) { + return; + } + + // If in editor view, go back to list first + if (this.viewMode === 'editor') { + this.goBackToList(); + } + + this.selectedSection = section; + this.sectionContextKey.set(section); + + // Persist selection + this.storageService.store(AI_CUSTOMIZATION_MANAGEMENT_SELECTED_SECTION_KEY, section, StorageScope.PROFILE, StorageTarget.USER); + + // Update editor tab title + this.updateEditorTitle(); + + // Update content visibility + this.updateContentVisibility(); + + // Load items for the new section (only for prompts-based sections) + if (this.isPromptsSection(section)) { + void this.listWidget.setSection(section); + } + } + + private updateEditorTitle(): void { + const sectionItem = this.sections.find(s => s.id === this.selectedSection); + if (sectionItem && this.input instanceof AICustomizationManagementEditorInput) { + this.input.setSectionLabel(sectionItem.label); + } + } + + private updateContentVisibility(): void { + const isEditorMode = this.viewMode === 'editor'; + const isPromptsSection = this.isPromptsSection(this.selectedSection); + const isModelsSection = this.selectedSection === AICustomizationManagementSection.Models; + const isMcpSection = this.selectedSection === AICustomizationManagementSection.McpServers; + + // Hide all list containers when in editor mode + this.promptsContentContainer.style.display = !isEditorMode && isPromptsSection ? '' : 'none'; + this.modelsContentContainer.style.display = !isEditorMode && isModelsSection ? '' : 'none'; + this.mcpContentContainer.style.display = !isEditorMode && isMcpSection ? '' : 'none'; + this.editorContentContainer.style.display = isEditorMode ? '' : 'none'; + + // Render and layout models widget when switching to it + if (isModelsSection) { + this.modelsWidget.render(); + if (this.dimension) { + this.layout(this.dimension); + } + } + } + + private openItem(item: IAICustomizationListItem): void { + const isWorktreeFile = item.storage === PromptsStorage.local; + const isReadOnly = item.storage === PromptsStorage.extension; + this.showEmbeddedEditor(item.uri, item.name, isWorktreeFile, isReadOnly); + } + + /** + * Creates the embedded editor container with back button and CodeEditorWidget. + */ + private createEmbeddedEditor(): void { + // Header with back button and item info + const editorHeader = DOM.append(this.editorContentContainer, $('.editor-header')); + + // Back button + const backButton = DOM.append(editorHeader, $('button.editor-back-button')); + backButton.setAttribute('aria-label', localize('backToList', "Back to list")); + const backIcon = DOM.append(backButton, $(`.codicon.codicon-${Codicon.arrowLeft.id}`)); + backIcon.setAttribute('aria-hidden', 'true'); + + this.editorDisposables.add(DOM.addDisposableListener(backButton, 'click', () => { + this.goBackToList(); + })); + + // Item info + const itemInfo = DOM.append(editorHeader, $('.editor-item-info')); + this.editorItemNameElement = DOM.append(itemInfo, $('.editor-item-name')); + this.editorItemPathElement = DOM.append(itemInfo, $('.editor-item-path')); + + // Save indicator (right-aligned in header) + this.editorSaveIndicator = DOM.append(editorHeader, $('.editor-save-indicator')); + + // Editor container + this.embeddedEditorContainer = DOM.append(this.editorContentContainer, $('.embedded-editor-container')); + + // Overflow widgets container - appended to the workbench root container so + // hovers, suggest widgets, etc. are not clipped by overflow:hidden parents. + const overflowWidgetsDomNode = this.layoutService.getContainer(DOM.getWindow(this.embeddedEditorContainer)).appendChild($('.embedded-editor-overflow-widgets.monaco-editor')); + this.editorDisposables.add(toDisposable(() => overflowWidgetsDomNode.remove())); + + // Create the CodeEditorWidget + const editorOptions = { + ...getSimpleEditorOptions(this.configurationService), + readOnly: false, + minimap: { enabled: false }, + lineNumbers: 'on' as const, + wordWrap: 'on' as const, + scrollBeyondLastLine: false, + automaticLayout: false, + folding: true, + renderLineHighlight: 'all' as const, + scrollbar: { + vertical: 'auto' as const, + horizontal: 'auto' as const, + }, + overflowWidgetsDomNode, + }; + + this.embeddedEditor = this.editorDisposables.add(this.instantiationService.createInstance( + CodeEditorWidget, + this.embeddedEditorContainer, + editorOptions, + { + isSimpleWidget: false, + // Use default contributions for full IntelliSense, completions, linting, etc. + } + )); + } + + /** + * Shows the embedded editor with the content of the given item. + */ + private async showEmbeddedEditor(uri: URI, displayName: string, isWorktreeFile = false, isReadOnly = false): Promise { + // Dispose previous model reference if any + this.currentModelRef?.dispose(); + this.currentModelRef = undefined; + this.currentEditingUri = uri; + + this.viewMode = 'editor'; + + // Update header info + this.editorItemNameElement.textContent = displayName; + this.editorItemPathElement.textContent = basename(uri); + + // Track worktree URI for auto-commit on close + const worktreeDir = getActiveSessionRoot(this.activeSessionService); + this.currentWorktreeUri = isWorktreeFile ? worktreeDir : undefined; + this.currentEditingIsWorktree = isWorktreeFile; + + // Update visibility + this.updateContentVisibility(); + + try { + // Get the text model for the file + const ref = await this.textModelService.createModelReference(uri); + this.currentModelRef = ref; + this.embeddedEditor.setModel(ref.object.textEditorModel); + this.embeddedEditor.updateOptions({ readOnly: isReadOnly }); + + // Layout the editor + if (this.dimension) { + this.layout(this.dimension); + } + + // Focus the editor + this.embeddedEditor.focus(); + + // Listen for content changes to show saving spinner + this.editorModelChangeDisposables.clear(); + this.editorModelChangeDisposables.add(ref.object.textEditorModel.onDidChangeContent(() => { + this.showSavingSpinner(); + })); + // Listen for actual save events to show checkmark + this.editorModelChangeDisposables.add(this.workingCopyService.onDidSave(e => { + if (isEqual(e.workingCopy.resource, uri)) { + this.showSavedCheckmark(); + } + })); + } catch (error) { + // If we can't load the model, go back to the list + console.error('Failed to load model for embedded editor:', error); + this.goBackToList(); + } + } + + /** + * Goes back from the embedded editor view to the list view. + */ + private goBackToList(): void { + // Auto-commit worktree files when leaving the embedded editor + const fileUri = this.currentEditingUri; + const worktreeUri = this.currentWorktreeUri; + if (fileUri && worktreeUri) { + this.commitWorktreeFile(worktreeUri, fileUri); + } + + // Dispose model reference + this.currentModelRef?.dispose(); + this.currentModelRef = undefined; + this.currentEditingUri = undefined; + this.currentWorktreeUri = undefined; + this.currentEditingIsWorktree = false; + this.editorModelChangeDisposables.clear(); + this.clearSaveIndicator(); + + // Clear editor model + this.embeddedEditor.setModel(null); + + this.viewMode = 'list'; + + // Update visibility + this.updateContentVisibility(); + + // Re-layout + if (this.dimension) { + this.layout(this.dimension); + } + + // Focus the list + this.listWidget?.focusSearch(); + } + + /** + * Creates a new customization using the AI-guided flow. + * Closes the management editor and opens a chat session with a hidden + * custom agent that guides the user through creating the customization. + */ + private async createNewItemWithAI(type: PromptsType): Promise { + // Close the management editor first so the chat is focused + if (this.input) { + await this.group.closeEditor(this.input); + } + + await this.customizationCreator.createWithAI(type); + } + + /** + * Creates a new prompt file. If there's an active worktree, asks the user + * whether to save in the worktree or user directory first. + */ + private async createNewItemManual(type: PromptsType, target: 'worktree' | 'user'): Promise { + // TODO: When creating a workspace customization file via 'New Workspace X', + // the file is written directly to the worktree but there is currently no way + // to commit it so it shows up in the Changes diff view for the worktree. + // We need integration with the git worktree to stage/commit these new files. + + if (type === PromptsType.hook) { + const isWorktree = target === 'worktree'; + await this.instantiationService.invokeFunction(showConfigureHooksQuickPick, { + openEditor: async (resource, options) => { + await this.showEmbeddedEditor(resource, basename(resource), isWorktree); + return; + }, + onHookFileCreated: isWorktree ? (_uri) => { + // Worktree tracking is handled via showEmbeddedEditor's isWorktreeFile param + } : undefined, + }); + return; + } + + const targetDir = target === 'worktree' + ? this.customizationCreator.resolveTargetDirectory(type) + : await this.customizationCreator.resolveUserDirectory(type); + + const isWorktree = target === 'worktree'; + const options: INewPromptOptions = { + targetFolder: targetDir, + targetStorage: target === 'user' ? PromptsStorage.user : PromptsStorage.local, + openFile: async (uri) => { + await this.showEmbeddedEditor(uri, basename(uri), isWorktree); + return this.embeddedEditor; + }, + }; + + let commandId: string; + switch (type) { + case PromptsType.prompt: commandId = NEW_PROMPT_COMMAND_ID; break; + case PromptsType.instructions: commandId = NEW_INSTRUCTIONS_COMMAND_ID; break; + case PromptsType.agent: commandId = NEW_AGENT_COMMAND_ID; break; + case PromptsType.skill: commandId = NEW_SKILL_COMMAND_ID; break; + default: return; + } + + await this.commandService.executeCommand(commandId, options); + + // Refresh the list so the new item appears + void this.listWidget.refresh(); + } + + override updateStyles(): void { + const borderColor = this.theme.getColor(aiCustomizationManagementSashBorder); + if (borderColor) { + this.splitView?.style({ separatorBorder: borderColor }); + } + } + + override async setInput(input: AICustomizationManagementEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { + this.inEditorContextKey.set(true); + this.sectionContextKey.set(this.selectedSection); + + await super.setInput(input, options, context, token); + + // Set initial editor tab title + this.updateEditorTitle(); + + if (this.dimension) { + this.layout(this.dimension); + } + } + + override clearInput(): void { + this.inEditorContextKey.set(false); + this.inputDisposables.clear(); + + // Clean up embedded editor state + if (this.viewMode === 'editor') { + this.goBackToList(); + } + + super.clearInput(); + } + + override layout(dimension: DOM.Dimension): void { + this.dimension = dimension; + + if (this.container && this.splitView) { + this.splitViewContainer.style.height = `${dimension.height}px`; + this.splitView.layout(dimension.width, dimension.height); + } + } + + override focus(): void { + super.focus(); + // When in editor mode, focus the editor + if (this.viewMode === 'editor') { + this.embeddedEditor?.focus(); + return; + } + if (this.selectedSection === AICustomizationManagementSection.McpServers) { + this.mcpListWidget?.focusSearch(); + } else if (this.selectedSection === AICustomizationManagementSection.Models) { + this.modelsWidget?.focusSearch(); + } else { + this.listWidget?.focusSearch(); + } + } + + /** + * Selects a specific section programmatically. + */ + public selectSectionById(sectionId: AICustomizationManagementSection): void { + const index = this.sections.findIndex(s => s.id === sectionId); + if (index >= 0) { + this.sectionsList.setFocus([index]); + this.sectionsList.setSelection([index]); + } + } + + /** + * Shows the spinning loader to indicate unsaved changes. + */ + private showSavingSpinner(): void { + this.editorSaveIndicator.className = 'editor-save-indicator visible'; + this.editorSaveIndicator.classList.add(...ThemeIcon.asClassNameArray(Codicon.loading), 'codicon-modifier-spin'); + this.editorSaveIndicator.title = localize('saving', "Saving..."); + } + + /** + * Shows the checkmark after the file has been saved to disk. + */ + private showSavedCheckmark(): void { + this.editorSaveIndicator.className = 'editor-save-indicator visible saved'; + this.editorSaveIndicator.classList.add(...ThemeIcon.asClassNameArray(Codicon.check)); + this.editorSaveIndicator.title = localize('saved', "Saved"); + } + + private clearSaveIndicator(): void { + this.editorSaveIndicator.className = 'editor-save-indicator'; + this.editorSaveIndicator.title = ''; + } + + /** + * Commits a worktree file via the extension and refreshes the Changes view. + */ + private async commitWorktreeFile(worktreeUri: URI, fileUri: URI): Promise { + await this.commandService.executeCommand( + 'github.copilot.cli.sessions.commitToWorktree', + { worktreeUri, fileUri } + ); + await this.agentSessionsService.model.resolve(AgentSessionProviders.Background); + this.refreshList(); + } + + /** + * Refreshes the list widget. + */ + public refreshList(): void { + void this.listWidget.refresh(); + } +} diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagementEditorInput.ts b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagementEditorInput.ts new file mode 100644 index 0000000000000..9c1f70b7a7fdf --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationManagementEditorInput.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize } from '../../../../nls.js'; +import { IUntypedEditorInput } from '../../../../workbench/common/editor.js'; +import { EditorInput } from '../../../../workbench/common/editor/editorInput.js'; +import { AI_CUSTOMIZATION_MANAGEMENT_EDITOR_INPUT_ID } from './aiCustomizationManagement.js'; + +/** + * Editor input for the AI Customizations Management Editor. + * This is a singleton-style input with no file resource. + */ +export class AICustomizationManagementEditorInput extends EditorInput { + + static readonly ID: string = AI_CUSTOMIZATION_MANAGEMENT_EDITOR_INPUT_ID; + + readonly resource = undefined; + + private static _instance: AICustomizationManagementEditorInput | undefined; + + private _sectionLabel: string | undefined; + + /** + * Gets or creates the singleton instance of this input. + */ + static getOrCreate(): AICustomizationManagementEditorInput { + if (!AICustomizationManagementEditorInput._instance || AICustomizationManagementEditorInput._instance.isDisposed()) { + AICustomizationManagementEditorInput._instance = new AICustomizationManagementEditorInput(); + } + return AICustomizationManagementEditorInput._instance; + } + + constructor() { + super(); + } + + override matches(otherInput: EditorInput | IUntypedEditorInput): boolean { + return super.matches(otherInput) || otherInput instanceof AICustomizationManagementEditorInput; + } + + override get typeId(): string { + return AICustomizationManagementEditorInput.ID; + } + + override getName(): string { + if (this._sectionLabel) { + return localize('aiCustomizationManagementEditorNameWithSection', "Customizations: {0}", this._sectionLabel); + } + return localize('aiCustomizationManagementEditorName', "Customizations"); + } + + /** + * Updates the section label shown in the editor tab title. + */ + setSectionLabel(label: string): void { + if (this._sectionLabel !== label) { + this._sectionLabel = label; + this._onDidChangeLabel.fire(); + } + } + + override getIcon(): ThemeIcon { + return Codicon.settingsGear; + } + + override async resolve(): Promise { + return null; + } +} diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationOverviewView.ts b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationOverviewView.ts new file mode 100644 index 0000000000000..d6681398747d9 --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationOverviewView.ts @@ -0,0 +1,202 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/aiCustomizationManagement.css'; +import * as DOM from '../../../../base/browser/dom.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize } from '../../../../nls.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; +import { IViewDescriptorService } from '../../../../workbench/common/views.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { AICustomizationManagementSection } from './aiCustomizationManagement.js'; +import { AICustomizationManagementEditorInput } from './aiCustomizationManagementEditorInput.js'; +import { AICustomizationManagementEditor } from './aiCustomizationManagementEditor.js'; +import { agentIcon, instructionsIcon, promptIcon, skillIcon } from '../../aiCustomizationTreeView/browser/aiCustomizationTreeViewIcons.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { ISessionsWorkbenchService } from '../../sessions/browser/sessionsWorkbenchService.js'; + +const $ = DOM.$; + +export const AI_CUSTOMIZATION_OVERVIEW_VIEW_ID = 'workbench.view.aiCustomizationOverview'; + +interface ISectionSummary { + readonly id: AICustomizationManagementSection; + readonly label: string; + readonly icon: ThemeIcon; + count: number; +} + +/** + * A compact overview view that shows a snapshot of AI customizations + * and provides deep-links to the management editor sections. + */ +export class AICustomizationOverviewView extends ViewPane { + + private bodyElement!: HTMLElement; + private container!: HTMLElement; + private sectionsContainer!: HTMLElement; + private readonly sections: ISectionSummary[] = []; + private readonly countElements = new Map(); + + constructor( + options: IViewPaneOptions, + @IKeybindingService keybindingService: IKeybindingService, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IInstantiationService instantiationService: IInstantiationService, + @IOpenerService openerService: IOpenerService, + @IThemeService themeService: IThemeService, + @IHoverService hoverService: IHoverService, + @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + @IPromptsService private readonly promptsService: IPromptsService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @ISessionsWorkbenchService private readonly activeSessionService: ISessionsWorkbenchService, + ) { + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); + + // Initialize sections + this.sections.push( + { id: AICustomizationManagementSection.Agents, label: localize('agents', "Agents"), icon: agentIcon, count: 0 }, + { id: AICustomizationManagementSection.Skills, label: localize('skills', "Skills"), icon: skillIcon, count: 0 }, + { id: AICustomizationManagementSection.Instructions, label: localize('instructions', "Instructions"), icon: instructionsIcon, count: 0 }, + { id: AICustomizationManagementSection.Prompts, label: localize('prompts', "Prompts"), icon: promptIcon, count: 0 }, + ); + + // Listen to changes + this._register(this.promptsService.onDidChangeCustomAgents(() => this.loadCounts())); + this._register(this.promptsService.onDidChangeSlashCommands(() => this.loadCounts())); + + // Listen to workspace folder changes to update counts + this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => this.loadCounts())); + this._register(autorun(reader => { + this.activeSessionService.activeSession.read(reader); + this.loadCounts(); + })); + + } + + protected override renderBody(container: HTMLElement): void { + super.renderBody(container); + + this.bodyElement = container; + this.container = DOM.append(container, $('.ai-customization-overview')); + this.sectionsContainer = DOM.append(this.container, $('.overview-sections')); + + this.renderSections(); + void this.loadCounts(); + + // Force initial layout + this.layoutBody(this.bodyElement.offsetHeight, this.bodyElement.offsetWidth); + } + + private renderSections(): void { + DOM.clearNode(this.sectionsContainer); + this.countElements.clear(); + + for (const section of this.sections) { + const sectionElement = DOM.append(this.sectionsContainer, $('.overview-section')); + sectionElement.tabIndex = 0; + sectionElement.setAttribute('role', 'button'); + sectionElement.setAttribute('aria-label', `${section.label}: ${section.count} items`); + + const iconElement = DOM.append(sectionElement, $('.section-icon')); + iconElement.classList.add(...ThemeIcon.asClassNameArray(section.icon)); + + const textContainer = DOM.append(sectionElement, $('.section-text')); + const labelElement = DOM.append(textContainer, $('.section-label')); + labelElement.textContent = section.label; + + const countElement = DOM.append(sectionElement, $('.section-count')); + countElement.textContent = `${section.count}`; + this.countElements.set(section.id, countElement); + + // Click handler to open management editor at section + this._register(DOM.addDisposableListener(sectionElement, 'click', () => { + this.openSection(section.id); + })); + + // Keyboard support + this._register(DOM.addDisposableListener(sectionElement, 'keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.openSection(section.id); + } + })); + + // Hover tooltip + this._register(this.hoverService.setupDelayedHoverAtMouse(sectionElement, () => ({ + content: localize('openSection', "Open {0} in AI Customizations editor", section.label), + appearance: { compact: true, skipFadeInAnimation: true } + }))); + } + } + + private async loadCounts(): Promise { + const sectionPromptTypes: Array<{ section: AICustomizationManagementSection; type: PromptsType }> = [ + { section: AICustomizationManagementSection.Agents, type: PromptsType.agent }, + { section: AICustomizationManagementSection.Skills, type: PromptsType.skill }, + { section: AICustomizationManagementSection.Instructions, type: PromptsType.instructions }, + { section: AICustomizationManagementSection.Prompts, type: PromptsType.prompt }, + ]; + + await Promise.all(sectionPromptTypes.map(async ({ section, type }) => { + let count = 0; + if (type === PromptsType.skill) { + const skills = await this.promptsService.findAgentSkills(CancellationToken.None); + if (skills) { + count = skills.length; + } + } else { + const allItems = await this.promptsService.listPromptFiles(type, CancellationToken.None); + count = allItems.length; + } + + const sectionData = this.sections.find(s => s.id === section); + if (sectionData) { + sectionData.count = count; + } + })); + + this.updateCountElements(); + } + + private updateCountElements(): void { + for (const section of this.sections) { + const countElement = this.countElements.get(section.id); + if (countElement) { + countElement.textContent = `${section.count}`; + } + } + } + + private async openSection(sectionId: AICustomizationManagementSection): Promise { + const input = AICustomizationManagementEditorInput.getOrCreate(); + const editor = await this.editorGroupsService.activeGroup.openEditor(input, { pinned: true }); + + // Deep-link to the section + if (editor instanceof AICustomizationManagementEditor) { + editor.selectSectionById(sectionId); + } + } + + protected override layoutBody(height: number, width: number): void { + super.layoutBody(height, width); + this.container.style.height = `${height}px`; + } +} diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/customizationCreatorService.ts b/src/vs/sessions/contrib/aiCustomizationManagement/browser/customizationCreatorService.ts new file mode 100644 index 0000000000000..271a1f94d0607 --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/customizationCreatorService.ts @@ -0,0 +1,217 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ISessionsWorkbenchService } from '../../sessions/browser/sessionsWorkbenchService.js'; +import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { getPromptFileDefaultLocations } from '../../../../workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.js'; +import { IPromptsService, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; +import { localize } from '../../../../nls.js'; +import { getActiveSessionRoot } from './aiCustomizationManagement.js'; + +/** + * Service that opens an AI-guided chat session to help the user create + * a new customization (agent, skill, instructions, prompt, hook). + * + * Opens a new chat in agent mode, then sends a request with hidden + * system instructions (modeInstructions) that guide the AI through + * the creation process. The user sees only their message. + */ +export class CustomizationCreatorService { + + constructor( + @ICommandService private readonly commandService: ICommandService, + @IChatService private readonly chatService: IChatService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @ISessionsWorkbenchService private readonly activeSessionService: ISessionsWorkbenchService, + @IPromptsService private readonly promptsService: IPromptsService, + @IQuickInputService private readonly quickInputService: IQuickInputService, + ) { } + + async createWithAI(type: PromptsType): Promise { + // Ask for the name before entering chat + const typeLabel = getTypeLabel(type); + const name = await this.quickInputService.input({ + prompt: localize('generateName', "Name for the new {0}", typeLabel), + placeHolder: localize('generateNamePlaceholder', "e.g., my-{0}", typeLabel), + validateInput: async (value) => { + if (!value || !value.trim()) { + return localize('nameRequired', "Name is required"); + } + return undefined; + } + }); + if (!name) { + return; + } + const trimmedName = name.trim(); + + // TODO: The 'Generate X' flow currently opens a new chat that is not connected + // to the active worktree. For this to fully work, the background agent needs to + // accept a worktree parameter so the new session can write files into the correct + // worktree directory and have those changes tracked in the session's diff view. + + // Capture worktree BEFORE opening new chat (which changes active session) + const targetDir = this.resolveTargetDirectory(type); + const systemInstructions = buildAgentInstructions(type, targetDir, trimmedName); + const userMessage = buildUserMessage(type, targetDir, trimmedName); + + // Start a new chat, then send the request with hidden instructions + await this.commandService.executeCommand('workbench.action.chat.newChat'); + + // Grab the now-active widget's session and send with hidden instructions + const widget = this.chatWidgetService.lastFocusedWidget; + const sessionResource = widget?.viewModel?.sessionResource; + if (!sessionResource) { + return; + } + + await this.chatService.sendRequest(sessionResource, userMessage, { + modeInfo: { + kind: ChatModeKind.Agent, + isBuiltin: false, + modeId: 'custom', + applyCodeBlockSuggestionId: undefined, + modeInstructions: { + name: 'customization-creator', + content: systemInstructions, + toolReferences: [], + }, + }, + }); + } + + /** + * Returns the worktree and repository URIs from the active session. + */ + /** + * Resolves the worktree directory for a new customization file based on the + * active session's worktree (preferred) or repository path. + * Falls back to the first local source folder from promptsService.getSourceFolders() + * if there's no active worktree. + */ + resolveTargetDirectory(type: PromptsType): URI | undefined { + const basePath = getActiveSessionRoot(this.activeSessionService); + if (!basePath) { + return undefined; + } + + // Compute the path within the worktree using default locations + const defaultLocations = getPromptFileDefaultLocations(type); + const localLocation = defaultLocations.find(loc => loc.storage === PromptsStorage.local); + if (!localLocation) { + return basePath; + } + + return URI.joinPath(basePath, localLocation.path); + } + + /** + * Resolves the user-level directory for a new customization file. + * Delegates to IPromptsService.getSourceFolders() which knows the correct + * user data profile path. + */ + async resolveUserDirectory(type: PromptsType): Promise { + const folders = await this.promptsService.getSourceFolders(type); + const userFolder = folders.find(f => f.storage === PromptsStorage.user); + return userFolder?.uri; + } +} + +//#region Agent Instructions + +/** + * Builds the hidden system instructions for the customization creator agent. + * Sent as modeInstructions - invisible to the user. + */ +function buildAgentInstructions(type: PromptsType, targetDir: URI | undefined, name: string): string { + const targetHint = targetDir + ? `\nIMPORTANT: Save the file to this directory: ${targetDir.fsPath}. The name is "${name}".` + : `\nThe name is "${name}".`; + + const writePolicy = ` + +CRITICAL WORKFLOW: +- In your VERY FIRST response, you MUST immediately create the file on disk from a starter template with placeholder content. Do not ask questions first -- write the file first so it appears in the diff view, then ask the user how they want to customize it. +- Every subsequent message from the user should result in you updating that same file on disk with the requested changes. +- Always write the complete file content, not partial diffs.${targetHint}`; + + switch (type) { + case PromptsType.agent: + return `You are a helpful assistant that guides users through creating a new custom AI agent.${writePolicy} + +Create a file named "${name}.agent.md" with YAML frontmatter (name, description, tools) and system instructions. Ask the user what it should do.`; + + case PromptsType.skill: + return `You are a helpful assistant that guides users through creating a new skill.${writePolicy} + +Create a directory named "${name}" with a SKILL.md file inside it. The file should have YAML frontmatter (name, description) and instructions. Ask the user what it does.`; + + case PromptsType.instructions: + return `You are a helpful assistant that guides users through creating a new instructions file.${writePolicy} + +Create a file named "${name}.instructions.md" with YAML frontmatter (description, optional applyTo) and actionable content. Ask the user what it should cover.`; + + case PromptsType.prompt: + return `You are a helpful assistant that guides users through creating a new reusable prompt.${writePolicy} + +Create a file named "${name}.prompt.md" with YAML frontmatter (name, description) and prompt content. Ask the user what it should do.`; + + case PromptsType.hook: + return `You are a helpful assistant that guides users through creating a new hook.${writePolicy} + +Ask the user when the hook should trigger and what it should do, then write the configuration file.`; + + default: + return `You are a helpful assistant that guides users through creating a new AI customization file.${writePolicy} + +Ask the user what they want to create, then guide them step by step.`; + } +} + +//#endregion + +//#region User Messages + +/** + * Builds the user-visible message that opens the chat. + * Includes the target path so the agent knows where to write the file. + */ +function buildUserMessage(type: PromptsType, targetDir: URI | undefined, name: string): string { + const pathHint = targetDir ? ` Write it to \`${targetDir.fsPath}\`.` : ''; + + switch (type) { + case PromptsType.agent: + return `Help me create a new custom agent called "${name}".${pathHint}`; + case PromptsType.skill: + return `Help me create a new skill called "${name}".${pathHint}`; + case PromptsType.instructions: + return `Help me create new instructions called "${name}".${pathHint}`; + case PromptsType.prompt: + return `Help me create a new prompt called "${name}".${pathHint}`; + case PromptsType.hook: + return `Help me create a new hook called "${name}".${pathHint}`; + default: + return `Help me create a new customization called "${name}".${pathHint}`; + } +} + +function getTypeLabel(type: PromptsType): string { + switch (type) { + case PromptsType.agent: return 'agent'; + case PromptsType.skill: return 'skill'; + case PromptsType.instructions: return 'instructions'; + case PromptsType.prompt: return 'prompt'; + case PromptsType.hook: return 'hook'; + default: return 'customization'; + } +} + +//#endregion diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/mcpListWidget.ts b/src/vs/sessions/contrib/aiCustomizationManagement/browser/mcpListWidget.ts new file mode 100644 index 0000000000000..cf97aeb03f828 --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/mcpListWidget.ts @@ -0,0 +1,363 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/aiCustomizationManagement.css'; +import * as DOM from '../../../../base/browser/dom.js'; +import { Disposable, DisposableStore, isDisposable } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { WorkbenchList } from '../../../../platform/list/browser/listService.js'; +import { IListVirtualDelegate, IListRenderer, IListContextMenuEvent } from '../../../../base/browser/ui/list/list.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IMcpWorkbenchService, IWorkbenchMcpServer, McpConnectionState, IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; +import { McpCommandIds } from '../../../../workbench/contrib/mcp/common/mcpCommandIds.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { URI } from '../../../../base/common/uri.js'; +import { InputBox } from '../../../../base/browser/ui/inputbox/inputBox.js'; +import { IContextMenuService, IContextViewService } from '../../../../platform/contextview/browser/contextView.js'; +import { Delayer } from '../../../../base/common/async.js'; +import { IAction, Separator } from '../../../../base/common/actions.js'; +import { getContextMenuActions } from '../../../../workbench/contrib/mcp/browser/mcpServerActions.js'; + +const $ = DOM.$; + +const MCP_ITEM_HEIGHT = 60; + +/** + * Delegate for the MCP server list. + */ +class McpServerItemDelegate implements IListVirtualDelegate { + getHeight(): number { + return MCP_ITEM_HEIGHT; + } + + getTemplateId(): string { + return 'mcpServerItem'; + } +} + +interface IMcpServerItemTemplateData { + readonly container: HTMLElement; + readonly icon: HTMLElement; + readonly name: HTMLElement; + readonly description: HTMLElement; + readonly status: HTMLElement; + readonly disposables: DisposableStore; +} + +/** + * Renderer for MCP server list items. + */ +class McpServerItemRenderer implements IListRenderer { + readonly templateId = 'mcpServerItem'; + + constructor( + @IMcpService private readonly mcpService: IMcpService, + ) { } + + renderTemplate(container: HTMLElement): IMcpServerItemTemplateData { + container.classList.add('mcp-server-item'); + + const icon = DOM.append(container, $('.mcp-server-icon')); + icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.server)); + + const details = DOM.append(container, $('.mcp-server-details')); + const name = DOM.append(details, $('.mcp-server-name')); + const description = DOM.append(details, $('.mcp-server-description')); + + const status = DOM.append(container, $('.mcp-server-status')); + + return { container, icon, name, description, status, disposables: new DisposableStore() }; + } + + renderElement(element: IWorkbenchMcpServer, index: number, templateData: IMcpServerItemTemplateData): void { + templateData.disposables.clear(); + + templateData.name.textContent = element.label; + templateData.description.textContent = element.description || ''; + + // Find the server from IMcpService to get connection state + const server = this.mcpService.servers.get().find(s => s.definition.id === element.id); + templateData.disposables.add(autorun(reader => { + const connectionState = server?.connectionState.read(reader); + this.updateStatus(templateData.status, connectionState?.state); + })); + } + + private updateStatus(statusElement: HTMLElement, state: McpConnectionState.Kind | undefined): void { + statusElement.className = 'mcp-server-status'; + + switch (state) { + case McpConnectionState.Kind.Running: + statusElement.textContent = localize('running', "Running"); + statusElement.classList.add('running'); + break; + case McpConnectionState.Kind.Starting: + statusElement.textContent = localize('starting', "Starting"); + statusElement.classList.add('starting'); + break; + case McpConnectionState.Kind.Error: + statusElement.textContent = localize('error', "Error"); + statusElement.classList.add('error'); + break; + case McpConnectionState.Kind.Stopped: + default: + statusElement.textContent = localize('stopped', "Stopped"); + statusElement.classList.add('stopped'); + break; + } + } + + disposeTemplate(templateData: IMcpServerItemTemplateData): void { + templateData.disposables.dispose(); + } +} + +/** + * Widget that displays a list of MCP servers. + */ +export class McpListWidget extends Disposable { + + readonly element: HTMLElement; + + private sectionHeader!: HTMLElement; + private sectionDescription!: HTMLElement; + private sectionLink!: HTMLAnchorElement; + private searchAndButtonContainer!: HTMLElement; + private searchInput!: InputBox; + private listContainer!: HTMLElement; + private list!: WorkbenchList; + private emptyContainer!: HTMLElement; + private emptyText!: HTMLElement; + private emptySubtext!: HTMLElement; + + private filteredServers: IWorkbenchMcpServer[] = []; + private searchQuery: string = ''; + private readonly delayedFilter = new Delayer(200); + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IMcpWorkbenchService private readonly mcpWorkbenchService: IMcpWorkbenchService, + @IMcpService private readonly mcpService: IMcpService, + @ICommandService private readonly commandService: ICommandService, + @IOpenerService private readonly openerService: IOpenerService, + @IContextViewService private readonly contextViewService: IContextViewService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + ) { + super(); + this.element = $('.mcp-list-widget'); + this.create(); + } + + private create(): void { + // Search and button container + this.searchAndButtonContainer = DOM.append(this.element, $('.list-search-and-button-container')); + + // Search container + const searchContainer = DOM.append(this.searchAndButtonContainer, $('.list-search-container')); + this.searchInput = this._register(new InputBox(searchContainer, this.contextViewService, { + placeholder: localize('searchMcpPlaceholder', "Type to search..."), + inputBoxStyles: defaultInputBoxStyles, + })); + + this._register(this.searchInput.onDidChange(() => { + this.searchQuery = this.searchInput.value; + this.delayedFilter.trigger(() => this.filterServers()); + })); + + // Add button next to search + const addButtonContainer = DOM.append(this.searchAndButtonContainer, $('.list-add-button-container')); + const addButton = this._register(new Button(addButtonContainer, { ...defaultButtonStyles, supportIcons: true })); + addButton.label = `$(${Codicon.add.id}) ${localize('addServer', "Add Server")}`; + addButton.element.classList.add('list-add-button'); + this._register(addButton.onDidClick(() => { + this.commandService.executeCommand(McpCommandIds.AddConfiguration); + })); + + // Empty state + this.emptyContainer = DOM.append(this.element, $('.mcp-empty-state')); + const emptyIcon = DOM.append(this.emptyContainer, $('.empty-icon')); + emptyIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.server)); + this.emptyText = DOM.append(this.emptyContainer, $('.empty-text')); + this.emptySubtext = DOM.append(this.emptyContainer, $('.empty-subtext')); + + // List container + this.listContainer = DOM.append(this.element, $('.mcp-list-container')); + + // Section footer at bottom with description and link + this.sectionHeader = DOM.append(this.element, $('.section-footer')); + this.sectionDescription = DOM.append(this.sectionHeader, $('p.section-footer-description')); + this.sectionDescription.textContent = localize('mcpServersDescription', "An open standard that lets AI use external tools and services. MCP servers provide tools for file operations, databases, APIs, and more."); + this.sectionLink = DOM.append(this.sectionHeader, $('a.section-footer-link')) as HTMLAnchorElement; + this.sectionLink.textContent = localize('learnMoreMcp', "Learn more about MCP servers"); + this.sectionLink.href = 'https://code.visualstudio.com/docs/copilot/chat/mcp-servers'; + this._register(DOM.addDisposableListener(this.sectionLink, 'click', (e) => { + e.preventDefault(); + const href = this.sectionLink.href; + if (href) { + this.openerService.open(URI.parse(href)); + } + })); + + // Create list + const delegate = new McpServerItemDelegate(); + const renderer = this.instantiationService.createInstance(McpServerItemRenderer); + + this.list = this._register(this.instantiationService.createInstance( + WorkbenchList, + 'McpManagementList', + this.listContainer, + delegate, + [renderer], + { + multipleSelectionSupport: false, + setRowLineHeight: false, + horizontalScrolling: false, + accessibilityProvider: { + getAriaLabel(element: IWorkbenchMcpServer) { + return element.label; + }, + getWidgetAriaLabel() { + return localize('mcpServersListAriaLabel', "MCP Servers"); + } + }, + openOnSingleClick: true, + identityProvider: { + getId(element: IWorkbenchMcpServer) { + return element.id; + } + } + } + )); + + this._register(this.list.onDidOpen(e => { + if (e.element) { + this.mcpWorkbenchService.open(e.element); + } + })); + + // Handle context menu + this._register(this.list.onContextMenu(e => this.onContextMenu(e))); + + // Listen to MCP service changes + this._register(this.mcpWorkbenchService.onChange(() => this.refresh())); + this._register(autorun(reader => { + this.mcpService.servers.read(reader); + this.refresh(); + })); + + // Initial refresh + void this.refresh(); + } + + private async refresh(): Promise { + this.filterServers(); + } + + private filterServers(): void { + const query = this.searchQuery.toLowerCase().trim(); + + if (query) { + this.filteredServers = this.mcpWorkbenchService.local.filter(server => + server.label.toLowerCase().includes(query) || + (server.description?.toLowerCase().includes(query)) + ); + } else { + this.filteredServers = [...this.mcpWorkbenchService.local]; + } + + // Show empty state only when there are no servers at all (not when filtered to empty) + if (this.filteredServers.length === 0) { + this.emptyContainer.style.display = 'flex'; + this.listContainer.style.display = 'none'; + + if (this.searchQuery.trim()) { + // Search with no results + this.emptyText.textContent = localize('noMatchingServers', "No servers match '{0}'", this.searchQuery); + this.emptySubtext.textContent = localize('tryDifferentSearch', "Try a different search term"); + } else { + // No servers configured + this.emptyText.textContent = localize('noMcpServers', "No MCP servers configured"); + this.emptySubtext.textContent = localize('addMcpServer', "Add an MCP server configuration to get started"); + } + } else { + this.emptyContainer.style.display = 'none'; + this.listContainer.style.display = ''; + } + + this.list.splice(0, this.list.length, this.filteredServers); + } + + /** + * Layouts the widget. + */ + layout(height: number, width: number): void { + const sectionFooterHeight = this.sectionHeader.offsetHeight || 100; + const searchBarHeight = this.searchAndButtonContainer.offsetHeight || 40; + const margins = 12; // search margin (6+6), not included in offsetHeight + const listHeight = height - sectionFooterHeight - searchBarHeight - margins; + + this.listContainer.style.height = `${Math.max(0, listHeight)}px`; + this.list.layout(Math.max(0, listHeight), width); + } + + /** + * Focuses the search input. + */ + focusSearch(): void { + this.searchInput.focus(); + } + + /** + * Focuses the list. + */ + focus(): void { + this.list.domFocus(); + const servers = this.list.length; + if (servers > 0) { + this.list.setFocus([0]); + } + } + + /** + * Handles context menu for MCP server items. + */ + private onContextMenu(e: IListContextMenuEvent): void { + if (!e.element) { + return; + } + + const disposables = new DisposableStore(); + const mcpServer = this.mcpWorkbenchService.local.find(local => local.id === e.element!.id) || e.element; + + // Get context menu actions from the MCP module + const groups: IAction[][] = getContextMenuActions(mcpServer, false, this.instantiationService); + const actions: IAction[] = []; + for (const menuActions of groups) { + for (const menuAction of menuActions) { + actions.push(menuAction); + if (isDisposable(menuAction)) { + disposables.add(menuAction); + } + } + actions.push(new Separator()); + } + // Remove trailing separator + if (actions.length > 0 && actions[actions.length - 1] instanceof Separator) { + actions.pop(); + } + + this.contextMenuService.showContextMenu({ + getAnchor: () => e.anchor, + getActions: () => actions, + onHide: () => disposables.dispose() + }); + } +} diff --git a/src/vs/sessions/contrib/aiCustomizationManagement/browser/media/aiCustomizationManagement.css b/src/vs/sessions/contrib/aiCustomizationManagement/browser/media/aiCustomizationManagement.css new file mode 100644 index 0000000000000..a0a97bea3bf1d --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationManagement/browser/media/aiCustomizationManagement.css @@ -0,0 +1,763 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* AI Customization Management Editor */ +.ai-customization-management-editor { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + border-top: 1px solid var(--vscode-panel-border); +} + +/* Sidebar */ +.ai-customization-management-editor .management-sidebar { + background-color: var(--vscode-sideBar-background); + height: 100%; + overflow: hidden; + border-right: 1px solid var(--vscode-panel-border); +} + +.ai-customization-management-editor .sidebar-content { + height: 100%; + padding: 12px 0 12px 4px; + display: flex; + flex-direction: column; +} + +.ai-customization-management-editor .sidebar-sections-list { + flex: 1; + overflow: hidden; +} + + + +/* Section list items */ +.ai-customization-management-editor .section-list-item { + display: flex; + align-items: center; + padding: 8px 16px; + gap: 10px; + cursor: pointer; + margin: 2px 6px; + border-radius: 6px; + transition: background-color 0.1s ease, opacity 0.1s ease; +} + +.ai-customization-management-editor .section-list-item:hover { + background-color: var(--vscode-list-hoverBackground); +} + +/* Selected section - using list selection */ +.ai-customization-management-editor .monaco-list .monaco-list-row.selected .section-list-item { + background-color: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +.ai-customization-management-editor .monaco-list .monaco-list-row.selected .section-list-item .section-icon { + color: var(--vscode-list-activeSelectionIconForeground, var(--vscode-list-activeSelectionForeground)); +} + +.ai-customization-management-editor .monaco-list .monaco-list-row.selected .section-list-item .section-label { + color: var(--vscode-list-activeSelectionForeground); +} + +.ai-customization-management-editor .section-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.85; +} + +.ai-customization-management-editor .section-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 400; +} + +/* Content area */ +.ai-customization-management-editor .management-content { + background-color: var(--vscode-editor-background); + height: 100%; + overflow: hidden; +} + +.ai-customization-management-editor .content-inner { + height: 100%; + padding: 8px 12px; + box-sizing: border-box; +} + +/* List Widget */ +.ai-customization-list-widget { + display: flex; + flex-direction: column; + height: 100%; +} + +/* Search and button container - consistent with Models view */ +.ai-customization-list-widget .list-search-and-button-container, +.mcp-list-widget .list-search-and-button-container { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + margin: 6px 0px; +} + +.ai-customization-list-widget .list-search-container, +.mcp-list-widget .list-search-container { + flex: 1; +} + +.ai-customization-list-widget .list-search-container .monaco-inputbox, +.mcp-list-widget .list-search-container .monaco-inputbox { + width: 100%; +} + +.ai-customization-list-widget .list-add-button-container, +.mcp-list-widget .list-add-button-container { + flex-shrink: 0; +} + +.ai-customization-list-widget .list-add-button, +.mcp-list-widget .list-add-button { + white-space: nowrap; +} + +.ai-customization-list-widget .list-container { + flex: 1; + overflow: hidden; +} + +.ai-customization-list-widget .list-empty-message { + padding: 12px; + text-align: center; + color: var(--vscode-descriptionForeground); + font-style: italic; +} + +/* Empty state - engaging design */ +.ai-customization-list-widget .list-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + gap: 12px; + text-align: center; + flex: 1; +} + +.ai-customization-list-widget .list-empty-state .empty-state-icon { + font-size: 48px; + opacity: 0.4; + margin-bottom: 8px; +} + +.ai-customization-list-widget .list-empty-state .empty-state-text { + font-size: 16px; + font-weight: 500; + color: var(--vscode-foreground); +} + +.ai-customization-list-widget .list-empty-state .empty-state-subtext { + font-size: 13px; + color: var(--vscode-descriptionForeground); + max-width: 300px; + line-height: 1.4; +} + +.ai-customization-list-widget .list-empty-state .empty-state-button { + margin-top: 16px; +} + +/* Group header styling */ +.ai-customization-group-header { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 8px 4px 8px; + cursor: pointer; + user-select: none; + border-radius: 4px; +} + +/* Separator line above non-first group headers */ +.ai-customization-group-header.has-previous-group { + border-top: 1px solid var(--vscode-sideBarSectionHeader-border, var(--vscode-panel-border)); + margin-top: 4px; + padding-top: 12px; +} + +.ai-customization-group-header:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.ai-customization-group-header .group-chevron { + flex-shrink: 0; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + opacity: 0.7; +} + +.ai-customization-group-header .group-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.8; +} + +.ai-customization-group-header .group-label { + flex: 1; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--vscode-sideBarSectionHeader-foreground, var(--vscode-foreground)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ai-customization-group-header .group-count { + flex-shrink: 0; + font-size: 10px; + font-weight: 500; + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + padding: 0 5px; + border-radius: 8px; + min-width: 14px; + text-align: center; + line-height: 16px; +} + +.ai-customization-group-header.collapsed .group-label { + opacity: 0.7; +} + +/* List item styling */ +.ai-customization-list-item { + display: flex; + align-items: flex-start; + padding: 6px 8px 6px 24px; + cursor: pointer; + border-radius: 4px; + margin: 2px 0; + min-height: 32px; +} + +.ai-customization-list-item:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.ai-customization-list-item .item-left { + display: flex; + align-items: center; + flex: 1; + overflow: hidden; + gap: 10px; + min-width: 0; +} + +.ai-customization-list-item .storage-badge { + flex-shrink: 0; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.7; +} + +.ai-customization-list-item:hover .storage-badge { + opacity: 1; +} + +.ai-customization-list-item .git-status-badge { + flex-shrink: 0; + font-size: 10px; + font-weight: 600; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 3px; + margin-right: 4px; + opacity: 1; +} + +.ai-customization-list-item .git-status-badge.committed { + color: var(--vscode-charts-green, #89d185); + opacity: 0.6; +} + +.ai-customization-list-item .item-text { + display: flex; + flex-direction: column; + overflow: hidden; + flex: 1; + min-width: 0; +} + +.ai-customization-list-item .item-name { + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 18px; +} + +.ai-customization-list-item .item-description { + font-size: 11px; + color: var(--vscode-descriptionForeground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 14px; +} + +.ai-customization-list-item .item-description.is-filename { + font-family: monospace; + font-size: 10px; + opacity: 0.7; +} + +.ai-customization-list-item .item-right { + display: flex; + align-items: center; + flex-shrink: 0; + margin-left: 8px; + gap: 4px; + opacity: 0; + transition: opacity 0.1s ease; +} + +.ai-customization-list-item:hover .item-right, +.ai-customization-list-item:focus-within .item-right { + opacity: 1; +} + +/* Highlighted matches */ +.ai-customization-list-item .highlight { + font-weight: bold; + color: var(--vscode-list-highlightForeground); +} + +/* Section footer at bottom of list widget */ +.ai-customization-list-widget .section-footer { + flex-shrink: 0; + padding: 12px 4px 6px 4px; + border-top: 1px solid var(--vscode-widget-border); +} + +.ai-customization-list-widget .section-footer .section-footer-description { + font-size: 13px; + color: var(--vscode-descriptionForeground); + line-height: 1.5; + margin: 0 0 8px 0; +} + +.ai-customization-list-widget .section-footer .section-footer-link { + font-size: 13px; + color: var(--vscode-textLink-foreground); + text-decoration: none; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.ai-customization-list-widget .section-footer .section-footer-link:hover { + text-decoration: underline; + color: var(--vscode-textLink-activeForeground); +} + +/* Overview View (compact snapshot) */ +.ai-customization-overview { + display: flex; + flex-direction: column; + height: 100%; + padding: 8px; +} + +.ai-customization-overview .overview-sections { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + + +.ai-customization-overview .overview-section { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + background-color: var(--vscode-sideBar-background); + border: 1px solid var(--vscode-widget-border); + border-radius: 6px; + cursor: pointer; + transition: background-color 0.1s ease; + flex: 1 1 120px; + min-width: 100px; +} + +.ai-customization-overview .overview-section:hover { + background-color: var(--vscode-list-hoverBackground); + border-color: var(--vscode-focusBorder); +} + +.ai-customization-overview .overview-section:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.ai-customization-overview .overview-section .section-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + opacity: 0.9; +} + +.ai-customization-overview .overview-section .section-text { + flex: 1; + min-width: 0; +} + +.ai-customization-overview .overview-section .section-label { + font-size: 12px; + font-weight: 500; + color: var(--vscode-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + + + +.ai-customization-overview .overview-section .section-count { + flex-shrink: 0; + font-size: 9px; + font-weight: 600; + padding: 1px 5px; + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + border-radius: 8px; + border: 1px solid transparent; + min-width: 14px; + text-align: center; +} + +/* Content container visibility */ +.ai-customization-management-editor .prompts-content-container, +.ai-customization-management-editor .mcp-content-container, +.ai-customization-management-editor .models-content-container { + height: 100%; + display: flex; + flex-direction: column; +} + +/* Models section footer */ +.ai-customization-management-editor .models-content-container .section-footer { + flex-shrink: 0; + padding: 12px 4px 6px 4px; + border-top: 1px solid var(--vscode-widget-border); +} + +.ai-customization-management-editor .models-content-container .section-footer-description { + font-size: 13px; + color: var(--vscode-descriptionForeground); + line-height: 1.5; + margin: 0 0 8px 0; +} + +.ai-customization-management-editor .models-content-container .section-footer-link { + font-size: 13px; + color: var(--vscode-textLink-foreground); + text-decoration: none; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.ai-customization-management-editor .models-content-container .section-footer-link:hover { + text-decoration: underline; + color: var(--vscode-textLink-activeForeground); +} + +/* Override models widget padding when embedded in management editor */ +.ai-customization-management-editor .models-content-container .models-widget { + padding-right: 0; + flex: 1; + overflow: hidden; +} + +/* MCP List Widget */ +.mcp-list-widget { + display: flex; + flex-direction: column; + height: 100%; +} + +.mcp-list-widget .section-footer { + flex-shrink: 0; + padding: 12px 4px 6px 4px; + border-top: 1px solid var(--vscode-widget-border); +} + +.mcp-list-widget .section-footer .section-footer-description { + font-size: 13px; + color: var(--vscode-descriptionForeground); + line-height: 1.5; + margin: 0 0 8px 0; +} + +.mcp-list-widget .section-footer .section-footer-link { + font-size: 13px; + color: var(--vscode-textLink-foreground); + text-decoration: none; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.mcp-list-widget .section-footer .section-footer-link:hover { + text-decoration: underline; + color: var(--vscode-textLink-activeForeground); +} + +.mcp-list-widget .mcp-list-container { + flex: 1; + overflow: hidden; +} + +/* MCP Empty State */ +.mcp-list-widget .mcp-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + gap: 12px; + text-align: center; + flex: 1; +} + +.mcp-list-widget .mcp-empty-state .empty-icon { + font-size: 48px; + opacity: 0.4; + margin-bottom: 8px; +} + +.mcp-list-widget .mcp-empty-state .empty-text { + font-size: 16px; + font-weight: 500; + color: var(--vscode-foreground); +} + +.mcp-list-widget .mcp-empty-state .empty-subtext { + font-size: 13px; + color: var(--vscode-descriptionForeground); + max-width: 300px; + line-height: 1.4; +} + +.mcp-list-widget .mcp-empty-state .monaco-button { + margin-top: 16px; +} + +/* MCP Server Item */ +.mcp-server-item { + display: flex; + align-items: center; + padding: 8px 12px; + cursor: pointer; + border-radius: 4px; + margin: 2px 0; + gap: 12px; +} + +.mcp-server-item:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.mcp-server-item .mcp-server-icon { + flex-shrink: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.8; +} + +.mcp-server-item .mcp-server-details { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; +} + +.mcp-server-item .mcp-server-name { + font-size: 13px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mcp-server-item .mcp-server-description { + font-size: 11px; + color: var(--vscode-descriptionForeground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mcp-server-item .mcp-server-status { + flex-shrink: 0; + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; + font-weight: 500; +} + +.mcp-server-item .mcp-server-status.running { + background-color: var(--vscode-terminal-ansiGreen); + color: var(--vscode-editor-background); +} + +.mcp-server-item .mcp-server-status.starting { + background-color: var(--vscode-terminal-ansiYellow); + color: var(--vscode-editor-background); +} + +.mcp-server-item .mcp-server-status.error { + background-color: var(--vscode-terminal-ansiRed); + color: var(--vscode-editor-background); +} + +.mcp-server-item .mcp-server-status.stopped { + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); +} + +/* Embedded Editor View */ +.ai-customization-management-editor .editor-content-container { + height: 100%; + display: flex; + flex-direction: column; +} + +.ai-customization-management-editor .editor-header { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 4px 12px 4px; + border-bottom: 1px solid var(--vscode-widget-border); + flex-shrink: 0; +} + +.ai-customization-management-editor .editor-back-button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 4px; + cursor: pointer; + background: transparent; + border: none; + color: var(--vscode-foreground); + opacity: 0.8; + transition: background-color 0.1s ease, opacity 0.1s ease; +} + +.ai-customization-management-editor .editor-back-button:hover { + background-color: var(--vscode-toolbar-hoverBackground); + opacity: 1; +} + +.ai-customization-management-editor .editor-back-button:active { + background-color: var(--vscode-toolbar-activeBackground); +} + +.ai-customization-management-editor .editor-item-info { + display: flex; + flex-direction: column; + overflow: hidden; + flex: 1; + min-width: 0; +} + +.ai-customization-management-editor .editor-item-name { + font-size: 14px; + font-weight: 500; + color: var(--vscode-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ai-customization-management-editor .editor-item-path { + font-size: 11px; + color: var(--vscode-descriptionForeground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--monaco-monospace-font); +} + +.ai-customization-management-editor .editor-save-indicator { + flex-shrink: 0; + width: 16px; + height: 16px; + font-size: 16px; + line-height: 16px; + text-align: center; + color: var(--vscode-descriptionForeground); + opacity: 0; + transition: opacity 0.2s ease; + margin-right: 4px; +} + +.ai-customization-management-editor .editor-save-indicator.visible { + opacity: 0.7; +} + +.ai-customization-management-editor .editor-save-indicator.saved { + color: var(--vscode-testing-iconPassed, #73c991); + opacity: 1; +} + +.ai-customization-management-editor .embedded-editor-container { + flex: 1; + overflow: hidden; + margin-top: 8px; + border: 1px solid var(--vscode-widget-border); + border-radius: 4px; +} + +.ai-customization-management-editor .embedded-editor-container .monaco-editor { + border-radius: 4px; +} diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/SPEC.md b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/SPEC.md new file mode 100644 index 0000000000000..77434957f9a6a --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/SPEC.md @@ -0,0 +1,116 @@ +# AI Customization Tree View Specification + +## Overview + +The AI Customization Tree View is a sidebar tree that groups AI customization files by type and storage. It is optimized for agent sessions and filters worktree items to the active session repository. + +**Location:** `src/vs/sessions/contrib/aiCustomizationTreeView/browser/` + +## Architecture + +### Component Hierarchy + +``` +View Container (Sidebar) +└── AICustomizationViewPane + └── WorkbenchAsyncDataTree + ├── UnifiedAICustomizationDataSource + ├── AICustomizationTreeDelegate + └── Renderers (category, group, file) +``` + +### Tree Structure + +``` +ROOT +├── Custom Agents +│ ├── Workspace (N) +│ ├── User (N) +│ └── Extensions (N) +├── Skills +│ ├── Workspace (N) +│ ├── User (N) +│ └── Extensions (N) +├── Instructions +└── Prompts +``` + +### File Structure + +``` +aiCustomizationTreeView/browser/ +├── aiCustomizationTreeView.ts +├── aiCustomizationTreeView.contribution.ts +├── aiCustomizationTreeViewViews.ts +├── aiCustomizationTreeViewIcons.ts +└── media/ + └── aiCustomizationTreeView.css +``` + +## Key Components + +### AICustomizationViewPane + +**Responsibilities:** +- Creates the tree and renderers. +- Auto-expands categories on load/refresh. +- Refreshes on prompt service changes, workspace changes, and active session changes. +- Updates `aiCustomization.isEmpty` based on total item count. +- Worktree scoping comes from the agentic prompt service override. + +### UnifiedAICustomizationDataSource + +**Responsibilities:** +- Caches per-type data for efficient expansion. +- Builds storage groups only when items exist. +- Labels groups with counts (e.g., "Workspace (3)"). +- Uses `findAgentSkills()` to derive skill names. + - Logs errors via `ILogService` when fetching children fails. + +## Actions + +### View Title + +- **Refresh** reloads data and re-expands categories. +- **Collapse All** collapses the tree. + +### Context Menu (file items) + +- Open +- Run Prompt (prompts only) + +## Context Keys + +- `aiCustomization.isEmpty` is set based on total items for welcome content. +- `aiCustomizationItemType` controls prompt-specific context menu actions. + +## Accessibility + +- Category/group/file items provide aria labels. +- File item aria labels include description when present. + +## Integration Points + +- `IPromptsService` for agents/skills/instructions/prompts. +- `IActiveSessionService` for worktree filtering. +- `IWorkspaceContextService` to refresh on workspace changes. +- `ILogService` for error reporting during data fetch. + +## Service Alignment (Required) + +AI customizations must lean on existing VS Code services with well-defined interfaces. The tree view should rely on the prompt discovery service rather than scanning the file system directly. + +Required services to prefer: +- Prompt discovery and metadata: [src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts](../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.ts) +- Active session scoping for worktrees: [src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsService.ts](../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.ts) + +## Notes + +- Storage groups are labeled with counts; icons are not shown for group rows. +- Skills display the frontmatter name when available, falling back to the folder name. +- Creation actions are intentionally centralized in the Management Editor. +- Refresh clears cached data before rebuilding the tree. + +--- + +*This specification documents the AI Customization Tree View in `src/vs/sessions/contrib/aiCustomizationTreeView/browser/`.* diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts new file mode 100644 index 0000000000000..bf2487a9e37ea --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { AICustomizationItemMenuId } from './aiCustomizationTreeView.js'; +import { AICustomizationItemTypeContextKey } from './aiCustomizationTreeViewViews.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; + +//#region Utilities + +/** + * Type for context passed to actions from tree context menus. + * Handles both direct URI arguments and serialized context objects. + */ +type URIContext = { uri: URI | string;[key: string]: unknown } | URI | string; + +/** + * Extracts a URI from various context formats. + * Context can be a URI, string, or an object with uri property. + */ +function extractURI(context: URIContext): URI { + if (URI.isUri(context)) { + return context; + } + if (typeof context === 'string') { + return URI.parse(context); + } + if (URI.isUri(context.uri)) { + return context.uri; + } + return URI.parse(context.uri as string); +} + +//#endregion + +//#region Context Menu Actions + +// Open file action +const OPEN_AI_CUSTOMIZATION_FILE_ID = 'aiCustomization.openFile'; +registerAction2(class extends Action2 { + constructor() { + super({ + id: OPEN_AI_CUSTOMIZATION_FILE_ID, + title: localize2('open', "Open"), + icon: Codicon.goToFile, + }); + } + async run(accessor: ServicesAccessor, context: URIContext): Promise { + const editorService = accessor.get(IEditorService); + await editorService.openEditor({ + resource: extractURI(context) + }); + } +}); + + +// Run prompt action +const RUN_PROMPT_FROM_VIEW_ID = 'aiCustomization.runPrompt'; +registerAction2(class extends Action2 { + constructor() { + super({ + id: RUN_PROMPT_FROM_VIEW_ID, + title: localize2('runPrompt', "Run Prompt"), + icon: Codicon.play, + }); + } + async run(accessor: ServicesAccessor, context: URIContext): Promise { + const commandService = accessor.get(ICommandService); + await commandService.executeCommand('workbench.action.chat.run.prompt.current', extractURI(context)); + } +}); + +// Register context menu items +MenuRegistry.appendMenuItem(AICustomizationItemMenuId, { + command: { id: OPEN_AI_CUSTOMIZATION_FILE_ID, title: localize('open', "Open") }, + group: '1_open', + order: 1, +}); + +MenuRegistry.appendMenuItem(AICustomizationItemMenuId, { + command: { id: RUN_PROMPT_FROM_VIEW_ID, title: localize('runPrompt', "Run Prompt"), icon: Codicon.play }, + group: '2_run', + order: 1, + when: ContextKeyExpr.equals(AICustomizationItemTypeContextKey.key, PromptsType.prompt), +}); + +//#endregion + +//#endregion diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.ts new file mode 100644 index 0000000000000..7a88e1d94bcfd --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize2 } from '../../../../nls.js'; +import { MenuId } from '../../../../platform/actions/common/actions.js'; + +/** + * View container ID for the AI Customization sidebar. + */ +export const AI_CUSTOMIZATION_VIEWLET_ID = 'workbench.view.aiCustomization'; + +/** + * View ID for the unified AI Customization tree view. + */ +export const AI_CUSTOMIZATION_VIEW_ID = 'aiCustomization.view'; + +/** + * Storage IDs for view state persistence. + */ +export const AI_CUSTOMIZATION_STORAGE_ID = 'workbench.aiCustomization.views.state'; + +/** + * Category for AI Customization commands. + */ +export const AI_CUSTOMIZATION_CATEGORY = localize2('aiCustomization', "AI Customization"); + +//#region Menu IDs + +// Context menu for file items (right-click on items) +export const AICustomizationItemMenuId = new MenuId('aiCustomization.item'); +// Submenu for creating new items +export const AICustomizationNewMenuId = new MenuId('aiCustomization.new'); +//#endregion diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewIcons.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewIcons.ts new file mode 100644 index 0000000000000..fbe3fa1d95008 --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewIcons.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { localize } from '../../../../nls.js'; +import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; + +/** + * Icon for the AI Customization view container (sidebar). + */ +export const aiCustomizationViewIcon = registerIcon('ai-customization-view-icon', Codicon.sparkle, localize('aiCustomizationViewIcon', "Icon for the AI Customization view.")); + +/** + * Icon for custom agents. + */ +export const agentIcon = registerIcon('ai-customization-agent', Codicon.agent, localize('aiCustomizationAgentIcon', "Icon for custom agents.")); + +/** + * Icon for skills. + */ +export const skillIcon = registerIcon('ai-customization-skill', Codicon.lightbulb, localize('aiCustomizationSkillIcon', "Icon for skills.")); + +/** + * Icon for instructions. + */ +export const instructionsIcon = registerIcon('ai-customization-instructions', Codicon.book, localize('aiCustomizationInstructionsIcon', "Icon for instruction files.")); + +/** + * Icon for prompts. + */ +export const promptIcon = registerIcon('ai-customization-prompt', Codicon.bookmark, localize('aiCustomizationPromptIcon', "Icon for prompt files.")); + +/** + * Icon for hooks. + */ +export const hookIcon = registerIcon('ai-customization-hook', Codicon.zap, localize('aiCustomizationHookIcon', "Icon for hooks.")); + +/** + * Icon for adding a new item. + */ +export const addIcon = registerIcon('ai-customization-add', Codicon.add, localize('aiCustomizationAddIcon', "Icon for adding new items.")); + +/** + * Icon for the run action. + */ +export const runIcon = registerIcon('ai-customization-run', Codicon.play, localize('aiCustomizationRunIcon', "Icon for running a prompt or agent.")); + +/** + * Icon for workspace storage. + */ +export const workspaceIcon = registerIcon('ai-customization-workspace', Codicon.folder, localize('aiCustomizationWorkspaceIcon', "Icon for workspace items.")); + +/** + * Icon for user storage. + */ +export const userIcon = registerIcon('ai-customization-user', Codicon.account, localize('aiCustomizationUserIcon', "Icon for user items.")); + +/** + * Icon for extension storage. + */ +export const extensionIcon = registerIcon('ai-customization-extension', Codicon.extensions, localize('aiCustomizationExtensionIcon', "Icon for extension-contributed items.")); diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts new file mode 100644 index 0000000000000..b8948468fea04 --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts @@ -0,0 +1,657 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/aiCustomizationTreeView.css'; +import * as dom from '../../../../base/browser/dom.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { basename, dirname } from '../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { getContextMenuActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { IMenuService } from '../../../../platform/actions/common/actions.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { WorkbenchAsyncDataTree } from '../../../../platform/list/browser/listService.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; +import { IViewDescriptorService } from '../../../../workbench/common/views.js'; +import { IPromptsService, PromptsStorage, IAgentSkill, IPromptPath } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { agentIcon, extensionIcon, instructionsIcon, promptIcon, skillIcon, userIcon, workspaceIcon } from './aiCustomizationTreeViewIcons.js'; +import { AICustomizationItemMenuId } from './aiCustomizationTreeView.js'; +import { IAsyncDataSource, ITreeNode, ITreeRenderer, ITreeContextMenuEvent } from '../../../../base/browser/ui/tree/tree.js'; +import { FuzzyScore } from '../../../../base/common/filters.js'; +import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; +import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { ISessionsWorkbenchService } from '../../sessions/browser/sessionsWorkbenchService.js'; + +//#region Context Keys + +/** + * Context key indicating whether the AI Customization view has no items. + */ +export const AICustomizationIsEmptyContextKey = new RawContextKey('aiCustomization.isEmpty', true); + +/** + * Context key for the current item's prompt type in context menus. + */ +export const AICustomizationItemTypeContextKey = new RawContextKey('aiCustomizationItemType', ''); + +//#endregion + +//#region Tree Item Types + +/** + * Root element marker for the tree. + */ +const ROOT_ELEMENT = Symbol('root'); +type RootElement = typeof ROOT_ELEMENT; + +/** + * Represents a type category in the tree (e.g., "Custom Agents", "Skills"). + */ +interface IAICustomizationTypeItem { + readonly type: 'category'; + readonly id: string; + readonly label: string; + readonly promptType: PromptsType; + readonly icon: ThemeIcon; +} + +/** + * Represents a storage group header in the tree (e.g., "Workspace", "User", "Extensions"). + */ +interface IAICustomizationGroupItem { + readonly type: 'group'; + readonly id: string; + readonly label: string; + readonly storage: PromptsStorage; + readonly promptType: PromptsType; + readonly icon: ThemeIcon; +} + +/** + * Represents an individual AI customization item (agent, skill, instruction, or prompt). + */ +interface IAICustomizationFileItem { + readonly type: 'file'; + readonly id: string; + readonly uri: URI; + readonly name: string; + readonly description?: string; + readonly storage: PromptsStorage; + readonly promptType: PromptsType; +} + +type AICustomizationTreeItem = IAICustomizationTypeItem | IAICustomizationGroupItem | IAICustomizationFileItem; + +//#endregion + +//#region Tree Infrastructure + +class AICustomizationTreeDelegate implements IListVirtualDelegate { + getHeight(_element: AICustomizationTreeItem): number { + return 22; + } + + getTemplateId(element: AICustomizationTreeItem): string { + switch (element.type) { + case 'category': + return 'category'; + case 'group': + return 'group'; + case 'file': + return 'file'; + } + } +} + +interface ICategoryTemplateData { + readonly container: HTMLElement; + readonly icon: HTMLElement; + readonly label: HTMLElement; +} + +interface IGroupTemplateData { + readonly container: HTMLElement; + readonly label: HTMLElement; +} + +interface IFileTemplateData { + readonly container: HTMLElement; + readonly icon: HTMLElement; + readonly name: HTMLElement; +} + +class AICustomizationCategoryRenderer implements ITreeRenderer { + readonly templateId = 'category'; + + renderTemplate(container: HTMLElement): ICategoryTemplateData { + const element = dom.append(container, dom.$('.ai-customization-category')); + const icon = dom.append(element, dom.$('.icon')); + const label = dom.append(element, dom.$('.label')); + return { container: element, icon, label }; + } + + renderElement(node: ITreeNode, _index: number, templateData: ICategoryTemplateData): void { + templateData.icon.className = 'icon'; + templateData.icon.classList.add(...ThemeIcon.asClassNameArray(node.element.icon)); + templateData.label.textContent = node.element.label; + } + + disposeTemplate(_templateData: ICategoryTemplateData): void { } +} + +class AICustomizationGroupRenderer implements ITreeRenderer { + readonly templateId = 'group'; + + renderTemplate(container: HTMLElement): IGroupTemplateData { + const element = dom.append(container, dom.$('.ai-customization-group-header')); + const label = dom.append(element, dom.$('.label')); + return { container: element, label }; + } + + renderElement(node: ITreeNode, _index: number, templateData: IGroupTemplateData): void { + templateData.label.textContent = node.element.label; + } + + disposeTemplate(_templateData: IGroupTemplateData): void { } +} + +class AICustomizationFileRenderer implements ITreeRenderer { + readonly templateId = 'file'; + + renderTemplate(container: HTMLElement): IFileTemplateData { + const element = dom.append(container, dom.$('.ai-customization-tree-item')); + const icon = dom.append(element, dom.$('.icon')); + const name = dom.append(element, dom.$('.name')); + return { container: element, icon, name }; + } + + renderElement(node: ITreeNode, _index: number, templateData: IFileTemplateData): void { + const item = node.element; + + // Set icon based on prompt type + let icon: ThemeIcon; + switch (item.promptType) { + case PromptsType.agent: + icon = agentIcon; + break; + case PromptsType.skill: + icon = skillIcon; + break; + case PromptsType.instructions: + icon = instructionsIcon; + break; + case PromptsType.prompt: + default: + icon = promptIcon; + break; + } + + templateData.icon.className = 'icon'; + templateData.icon.classList.add(...ThemeIcon.asClassNameArray(icon)); + + templateData.name.textContent = item.name; + + // Set tooltip with name and description + const tooltip = item.description ? `${item.name} - ${item.description}` : item.name; + templateData.container.title = tooltip; + } + + disposeTemplate(_templateData: IFileTemplateData): void { } +} + +/** + * Cached data for a specific prompt type. + */ +interface ICachedTypeData { + skills?: IAgentSkill[]; + files?: Map; +} + +/** + * Data source for the AI Customization tree with efficient caching. + * Caches data per-type to avoid redundant fetches when expanding groups. + */ +class UnifiedAICustomizationDataSource implements IAsyncDataSource { + private cache = new Map(); + private totalItemCount = 0; + + constructor( + private readonly promptsService: IPromptsService, + private readonly logService: ILogService, + private readonly onItemCountChanged: (count: number) => void, + ) { } + + /** + * Clears the cache. Should be called when the view refreshes. + */ + clearCache(): void { + this.cache.clear(); + this.totalItemCount = 0; + } + + hasChildren(element: RootElement | AICustomizationTreeItem): boolean { + if (element === ROOT_ELEMENT) { + return true; + } + return element.type === 'category' || element.type === 'group'; + } + + async getChildren(element: RootElement | AICustomizationTreeItem): Promise { + try { + if (element === ROOT_ELEMENT) { + return this.getTypeCategories(); + } + + if (element.type === 'category') { + return this.getStorageGroups(element.promptType); + } + + if (element.type === 'group') { + return this.getFilesForStorageAndType(element.storage, element.promptType); + } + + return []; + } catch (error) { + this.logService.error('[AICustomization] Error fetching tree children:', error); + return []; + } + } + + private getTypeCategories(): IAICustomizationTypeItem[] { + return [ + { + type: 'category', + id: 'category-agents', + label: localize('customAgents', "Custom Agents"), + promptType: PromptsType.agent, + icon: agentIcon, + }, + { + type: 'category', + id: 'category-skills', + label: localize('skills', "Skills"), + promptType: PromptsType.skill, + icon: skillIcon, + }, + { + type: 'category', + id: 'category-instructions', + label: localize('instructions', "Instructions"), + promptType: PromptsType.instructions, + icon: instructionsIcon, + }, + { + type: 'category', + id: 'category-prompts', + label: localize('prompts', "Prompts"), + promptType: PromptsType.prompt, + icon: promptIcon, + }, + ]; + } + + /** + * Fetches and caches data for a prompt type, returning storage groups with items. + */ + private async getStorageGroups(promptType: PromptsType): Promise { + const groups: IAICustomizationGroupItem[] = []; + + // Check cache first + let cached = this.cache.get(promptType); + if (!cached) { + cached = {}; + this.cache.set(promptType, cached); + } + + // For skills, use findAgentSkills which has the proper names from frontmatter + if (promptType === PromptsType.skill) { + if (!cached.skills) { + const skills = await this.promptsService.findAgentSkills(CancellationToken.None); + cached.skills = skills || []; + this.totalItemCount += cached.skills.length; + this.onItemCountChanged(this.totalItemCount); + } + + const workspaceSkills = cached.skills.filter(s => s.storage === PromptsStorage.local); + const userSkills = cached.skills.filter(s => s.storage === PromptsStorage.user); + const extensionSkills = cached.skills.filter(s => s.storage === PromptsStorage.extension); + + if (workspaceSkills.length > 0) { + groups.push(this.createGroupItem(promptType, PromptsStorage.local, workspaceSkills.length)); + } + if (userSkills.length > 0) { + groups.push(this.createGroupItem(promptType, PromptsStorage.user, userSkills.length)); + } + if (extensionSkills.length > 0) { + groups.push(this.createGroupItem(promptType, PromptsStorage.extension, extensionSkills.length)); + } + + return groups; + } + + // For other types, fetch once and cache grouped by storage + if (!cached.files) { + const allItems = await this.promptsService.listPromptFiles(promptType, CancellationToken.None); + const workspaceItems = allItems.filter(item => item.storage === PromptsStorage.local); + const userItems = allItems.filter(item => item.storage === PromptsStorage.user); + const extensionItems = allItems.filter(item => item.storage === PromptsStorage.extension); + + cached.files = new Map([ + [PromptsStorage.local, workspaceItems], + [PromptsStorage.user, userItems], + [PromptsStorage.extension, extensionItems], + ]); + + const itemCount = allItems.length; + this.totalItemCount += itemCount; + this.onItemCountChanged(this.totalItemCount); + } + + const workspaceItems = cached.files!.get(PromptsStorage.local) || []; + const userItems = cached.files!.get(PromptsStorage.user) || []; + const extensionItems = cached.files!.get(PromptsStorage.extension) || []; + + if (workspaceItems.length > 0) { + groups.push(this.createGroupItem(promptType, PromptsStorage.local, workspaceItems.length)); + } + if (userItems.length > 0) { + groups.push(this.createGroupItem(promptType, PromptsStorage.user, userItems.length)); + } + if (extensionItems.length > 0) { + groups.push(this.createGroupItem(promptType, PromptsStorage.extension, extensionItems.length)); + } + + return groups; + } + + /** + * Creates a group item with consistent structure. + */ + private createGroupItem(promptType: PromptsType, storage: PromptsStorage, count: number): IAICustomizationGroupItem { + const storageLabels: Record = { + [PromptsStorage.local]: localize('workspaceWithCount', "Workspace ({0})", count), + [PromptsStorage.user]: localize('userWithCount', "User ({0})", count), + [PromptsStorage.extension]: localize('extensionsWithCount', "Extensions ({0})", count), + }; + + const storageIcons: Record = { + [PromptsStorage.local]: workspaceIcon, + [PromptsStorage.user]: userIcon, + [PromptsStorage.extension]: extensionIcon, + }; + + const storageSuffixes: Record = { + [PromptsStorage.local]: 'workspace', + [PromptsStorage.user]: 'user', + [PromptsStorage.extension]: 'extensions', + }; + + return { + type: 'group', + id: `group-${promptType}-${storageSuffixes[storage]}`, + label: storageLabels[storage], + storage, + promptType, + icon: storageIcons[storage], + }; + } + + /** + * Returns files for a specific storage/type combination from cache. + * getStorageGroups must be called first to populate the cache. + */ + private async getFilesForStorageAndType(storage: PromptsStorage, promptType: PromptsType): Promise { + const cached = this.cache.get(promptType); + + // For skills, use the cached skills data + if (promptType === PromptsType.skill) { + const skills = cached?.skills || []; + const filtered = skills.filter(skill => skill.storage === storage); + return filtered + .map(skill => { + // Use skill name from frontmatter, or fallback to parent folder name + const skillName = skill.name || basename(dirname(skill.uri)) || basename(skill.uri); + return { + type: 'file' as const, + id: skill.uri.toString(), + uri: skill.uri, + name: skillName, + description: skill.description, + storage: skill.storage, + promptType, + }; + }); + } + + // Use cached files data (already fetched in getStorageGroups) + const items = [...(cached?.files?.get(storage) || [])]; + return items.map(item => ({ + type: 'file' as const, + id: item.uri.toString(), + uri: item.uri, + name: item.name || basename(item.uri), + description: item.description, + storage: item.storage, + promptType, + })); + } +} + +//#endregion + +//#region Unified View Pane + +/** + * Unified view pane for all AI Customization items (agents, skills, instructions, prompts). + */ +export class AICustomizationViewPane extends ViewPane { + static readonly ID = 'aiCustomization.view'; + + private tree: WorkbenchAsyncDataTree | undefined; + private dataSource: UnifiedAICustomizationDataSource | undefined; + private treeContainer: HTMLElement | undefined; + private readonly treeDisposables = this._register(new DisposableStore()); + + // Context keys for controlling menu visibility and welcome content + private readonly isEmptyContextKey: IContextKey; + private readonly itemTypeContextKey: IContextKey; + + constructor( + options: IViewPaneOptions, + @IKeybindingService keybindingService: IKeybindingService, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IInstantiationService instantiationService: IInstantiationService, + @IOpenerService openerService: IOpenerService, + @IThemeService themeService: IThemeService, + @IHoverService hoverService: IHoverService, + @IPromptsService private readonly promptsService: IPromptsService, + @IEditorService private readonly editorService: IEditorService, + @IMenuService private readonly menuService: IMenuService, + @ILogService private readonly logService: ILogService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @ISessionsWorkbenchService private readonly activeSessionService: ISessionsWorkbenchService, + ) { + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); + + // Initialize context keys + this.isEmptyContextKey = AICustomizationIsEmptyContextKey.bindTo(contextKeyService); + this.itemTypeContextKey = AICustomizationItemTypeContextKey.bindTo(contextKeyService); + + // Subscribe to prompt service events to refresh tree + this._register(this.promptsService.onDidChangeCustomAgents(() => this.refresh())); + this._register(this.promptsService.onDidChangeSlashCommands(() => this.refresh())); + + // Listen to workspace folder changes to refresh tree + this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => this.refresh())); + this._register(autorun(reader => { + this.activeSessionService.activeSession.read(reader); + this.refresh(); + })); + + } + + protected override renderBody(container: HTMLElement): void { + super.renderBody(container); + + container.classList.add('ai-customization-view'); + this.treeContainer = dom.append(container, dom.$('.tree-container')); + + this.createTree(); + } + + private createTree(): void { + if (!this.treeContainer) { + return; + } + + // Create data source with callback for tracking item count + this.dataSource = new UnifiedAICustomizationDataSource( + this.promptsService, + this.logService, + (count) => this.isEmptyContextKey.set(count === 0), + ); + + this.tree = this.treeDisposables.add(this.instantiationService.createInstance( + WorkbenchAsyncDataTree, + 'AICustomization', + this.treeContainer, + new AICustomizationTreeDelegate(), + [ + new AICustomizationCategoryRenderer(), + new AICustomizationGroupRenderer(), + new AICustomizationFileRenderer(), + ], + this.dataSource, + { + identityProvider: { + getId: (element: AICustomizationTreeItem) => element.id, + }, + accessibilityProvider: { + getAriaLabel: (element: AICustomizationTreeItem) => { + if (element.type === 'category') { + return element.label; + } + if (element.type === 'group') { + return element.label; + } + // For files, include description if available + return element.description + ? localize('fileAriaLabel', "{0}, {1}", element.name, element.description) + : element.name; + }, + getWidgetAriaLabel: () => localize('aiCustomizationTree', "AI Customization Items"), + }, + keyboardNavigationLabelProvider: { + getKeyboardNavigationLabel: (element: AICustomizationTreeItem) => { + if (element.type === 'file') { + return element.name; + } + return element.label; + }, + }, + } + )); + + // Handle double-click to open file + this.treeDisposables.add(this.tree.onDidOpen(e => { + if (e.element && e.element.type === 'file') { + this.editorService.openEditor({ + resource: e.element.uri + }); + } + })); + + // Handle context menu + this.treeDisposables.add(this.tree.onContextMenu(e => this.onContextMenu(e))); + + // Initial load and auto-expand category nodes + void this.tree.setInput(ROOT_ELEMENT).then(() => this.autoExpandCategories()); + } + + private async autoExpandCategories(): Promise { + if (!this.tree) { + return; + } + // Auto-expand all category nodes to show storage groups + const rootNode = this.tree.getNode(ROOT_ELEMENT); + for (const child of rootNode.children) { + if (child.element !== ROOT_ELEMENT) { + await this.tree.expand(child.element); + } + } + } + + protected override layoutBody(height: number, width: number): void { + super.layoutBody(height, width); + this.tree?.layout(height, width); + } + + public refresh(): void { + // Clear the cache before refreshing + this.dataSource?.clearCache(); + this.isEmptyContextKey.set(true); // Reset until we know the count + void this.tree?.setInput(ROOT_ELEMENT).then(() => this.autoExpandCategories()); + } + + public collapseAll(): void { + this.tree?.collapseAll(); + } + + public expandAll(): void { + this.tree?.expandAll(); + } + + private onContextMenu(e: ITreeContextMenuEvent): void { + // Only show context menu for file items + if (!e.element || e.element.type !== 'file') { + return; + } + + const element = e.element; + + // Set context key for the item type so menu items can use `when` clauses + this.itemTypeContextKey.set(element.promptType); + + // Get menu actions from the menu service + const context = { + uri: element.uri.toString(), + name: element.name, + promptType: element.promptType, + }; + const menu = this.menuService.getMenuActions(AICustomizationItemMenuId, this.contextKeyService, { arg: context, shouldForwardArgs: true }); + const { secondary } = getContextMenuActions(menu, 'inline'); + + // Show the context menu + if (secondary.length > 0) { + this.contextMenuService.showContextMenu({ + getAnchor: () => e.anchor, + getActions: () => secondary, + getActionsContext: () => context, + onHide: () => { + // Clear the context key when menu closes + this.itemTypeContextKey.reset(); + }, + }); + } + } +} + +//#endregion diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/media/aiCustomizationTreeView.css b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/media/aiCustomizationTreeView.css new file mode 100644 index 0000000000000..0756725fc2b39 --- /dev/null +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/media/aiCustomizationTreeView.css @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.ai-customization-view { + height: 100%; +} + +.ai-customization-view .tree-container { + height: 100%; +} + +/* Tree item styling */ +.ai-customization-view .ai-customization-tree-item { + display: flex; + align-items: center; + height: 22px; + line-height: 22px; + padding-right: 8px; +} + +.ai-customization-view .ai-customization-tree-item .icon { + flex-shrink: 0; + width: 16px; + height: 16px; + margin-right: 6px; + display: flex; + align-items: center; + justify-content: center; +} + +.ai-customization-view .ai-customization-tree-item .name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ai-customization-view .ai-customization-tree-item .description { + flex-shrink: 1; + color: var(--vscode-descriptionForeground); + font-size: 0.9em; + margin-left: 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.8; +} + +/* Group headers */ +.ai-customization-view .ai-customization-group-header { + display: flex; + align-items: center; + height: 22px; + font-weight: 600; + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.5px; + color: var(--vscode-descriptionForeground); +} + +.ai-customization-view .ai-customization-group-header .label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Category headers (top-level types like "Custom Agents", "Skills") */ +.ai-customization-view .ai-customization-category { + display: flex; + align-items: center; + height: 22px; + line-height: 22px; + font-weight: 600; +} + +.ai-customization-view .ai-customization-category .icon { + flex-shrink: 0; + width: 16px; + height: 16px; + margin-right: 6px; + display: flex; + align-items: center; + justify-content: center; +} + +.ai-customization-view .ai-customization-category .label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Empty state */ +.ai-customization-view .empty-message { + padding: 10px; + color: var(--vscode-descriptionForeground); + text-align: center; +} diff --git a/src/vs/sessions/contrib/changesView/browser/changesView.contribution.ts b/src/vs/sessions/contrib/changesView/browser/changesView.contribution.ts new file mode 100644 index 0000000000000..3e69bba8dfa4f --- /dev/null +++ b/src/vs/sessions/contrib/changesView/browser/changesView.contribution.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { localize2 } from '../../../../nls.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; +import { IViewContainersRegistry, ViewContainerLocation, IViewsRegistry, Extensions as ViewContainerExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; +import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, ChangesViewPane, ChangesViewPaneContainer } from './changesView.js'; + +const changesViewIcon = registerIcon('changes-view-icon', Codicon.gitCompare, localize2('changesViewIcon', 'View icon for the Changes view.').value); + +const viewContainersRegistry = Registry.as(ViewContainerExtensions.ViewContainersRegistry); + +const changesViewContainer = viewContainersRegistry.registerViewContainer({ + id: CHANGES_VIEW_CONTAINER_ID, + title: localize2('changes', 'Changes'), + ctorDescriptor: new SyncDescriptor(ChangesViewPaneContainer), + icon: changesViewIcon, + order: 10, + hideIfEmpty: true, + windowVisibility: WindowVisibility.Sessions +}, ViewContainerLocation.AuxiliaryBar, { doNotRegisterOpenCommand: true, isDefault: true }); + +const viewsRegistry = Registry.as(ViewContainerExtensions.ViewsRegistry); + +viewsRegistry.registerViews([{ + id: CHANGES_VIEW_ID, + name: localize2('changes', 'Changes'), + containerIcon: changesViewIcon, + ctorDescriptor: new SyncDescriptor(ChangesViewPane), + canToggleVisibility: true, + canMoveView: true, + weight: 100, + order: 1, + windowVisibility: WindowVisibility.Sessions +}], changesViewContainer); diff --git a/src/vs/sessions/contrib/changesView/browser/changesView.ts b/src/vs/sessions/contrib/changesView/browser/changesView.ts new file mode 100644 index 0000000000000..d1d035676a929 --- /dev/null +++ b/src/vs/sessions/contrib/changesView/browser/changesView.ts @@ -0,0 +1,1031 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/changesView.css'; +import * as dom from '../../../../base/browser/dom.js'; +import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; +import { ICompressedTreeNode } from '../../../../base/browser/ui/tree/compressedObjectTreeModel.js'; +import { ICompressibleTreeRenderer } from '../../../../base/browser/ui/tree/objectTree.js'; +import { IObjectTreeElement, ITreeNode } from '../../../../base/browser/ui/tree/tree.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { Iterable } from '../../../../base/common/iterator.js'; +import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derived, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { basename, dirname } from '../../../../base/common/path.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { MenuWorkbenchButtonBar } from '../../../../platform/actions/browser/buttonbar.js'; +import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { FileKind } from '../../../../platform/files/common/files.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { WorkbenchCompressibleObjectTree } from '../../../../platform/list/browser/listService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { fillEditorsDragData } from '../../../../workbench/browser/dnd.js'; +import { IResourceLabel, ResourceLabels } from '../../../../workbench/browser/labels.js'; +import { ViewPane, IViewPaneOptions, ViewAction } from '../../../../workbench/browser/parts/views/viewPane.js'; +import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; +import { IViewDescriptorService } from '../../../../workbench/common/views.js'; +import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { chatEditingWidgetFileStateContextKey, hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingService, ModifiedFileEntryState } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; +import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; +import { createFileIconThemableTreeContainerScope } from '../../../../workbench/contrib/files/browser/views/explorerView.js'; +import { IActivityService, NumberBadge } from '../../../../workbench/services/activity/common/activity.js'; +import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../../workbench/services/editor/common/editorService.js'; +import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; +import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; + +const $ = dom.$; + +// --- Constants + +export const CHANGES_VIEW_CONTAINER_ID = 'workbench.view.agentSessions.changesContainer'; +export const CHANGES_VIEW_ID = 'workbench.view.agentSessions.changes'; + +// --- View Mode + +export const enum ChangesViewMode { + List = 'list', + Tree = 'tree' +} + +const changesViewModeContextKey = new RawContextKey('changesViewMode', ChangesViewMode.List); + +// --- List Item + +type ChangeType = 'added' | 'modified' | 'deleted'; + +interface IChangesFileItem { + readonly type: 'file'; + readonly uri: URI; + readonly originalUri?: URI; + readonly state: ModifiedFileEntryState; + readonly isDeletion: boolean; + readonly changeType: ChangeType; + readonly linesAdded: number; + readonly linesRemoved: number; +} + +interface IChangesFolderItem { + readonly type: 'folder'; + readonly uri: URI; + readonly name: string; +} + +type ChangesTreeElement = IChangesFileItem | IChangesFolderItem; + +function isChangesFileItem(element: ChangesTreeElement): element is IChangesFileItem { + return element.type === 'file'; +} + +/** + * Builds a tree of `IObjectTreeElement` from a flat list of file items. + * Groups files by their directory path segments to create a hierarchical tree structure. + */ +function buildTreeChildren(items: IChangesFileItem[]): IObjectTreeElement[] { + if (items.length === 0) { + return []; + } + + interface FolderNode { + name: string; + uri: URI; + children: Map; + files: IChangesFileItem[]; + } + + const root: FolderNode = { name: '', uri: URI.file('/'), children: new Map(), files: [] }; + + for (const item of items) { + const dirPath = dirname(item.uri.path); + const segments = dirPath.split('/').filter(Boolean); + + let current = root; + let currentPath = ''; + for (const segment of segments) { + currentPath += '/' + segment; + if (!current.children.has(segment)) { + current.children.set(segment, { + name: segment, + uri: item.uri.with({ path: currentPath }), + children: new Map(), + files: [] + }); + } + current = current.children.get(segment)!; + } + current.files.push(item); + } + + function convert(node: FolderNode): IObjectTreeElement[] { + const result: IObjectTreeElement[] = []; + + for (const [, child] of node.children) { + const folderElement: IChangesFolderItem = { type: 'folder', uri: child.uri, name: child.name }; + const folderChildren = convert(child); + result.push({ + element: folderElement, + children: folderChildren, + collapsible: true, + collapsed: false, + }); + } + + for (const file of node.files) { + result.push({ + element: file, + collapsible: false, + }); + } + + return result; + } + + return convert(root); +} + +// --- View Pane + +export class ChangesViewPane extends ViewPane { + + private bodyContainer: HTMLElement | undefined; + private welcomeContainer: HTMLElement | undefined; + private contentContainer: HTMLElement | undefined; + private overviewContainer: HTMLElement | undefined; + private summaryContainer: HTMLElement | undefined; + private listContainer: HTMLElement | undefined; + // Actions container is positioned outside the card for this layout experiment + private actionsContainer: HTMLElement | undefined; + + private tree: WorkbenchCompressibleObjectTree | undefined; + + private readonly renderDisposables = this._register(new DisposableStore()); + + // Track current body dimensions for list layout + private currentBodyHeight = 0; + private currentBodyWidth = 0; + + // View mode (list vs tree) + private readonly viewModeObs: ReturnType>; + private readonly viewModeContextKey: IContextKey; + + get viewMode(): ChangesViewMode { return this.viewModeObs.get(); } + set viewMode(mode: ChangesViewMode) { + if (this.viewModeObs.get() === mode) { + return; + } + this.viewModeObs.set(mode, undefined); + this.viewModeContextKey.set(mode); + this.storageService.store('changesView.viewMode', mode, StorageScope.WORKSPACE, StorageTarget.USER); + } + + // Track the active session's editing session resource + private readonly activeSessionResource = observableValue(this, undefined); + + // Badge for file count + private readonly badgeDisposable = this._register(new MutableDisposable()); + + constructor( + options: IViewPaneOptions, + @IKeybindingService keybindingService: IKeybindingService, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IInstantiationService instantiationService: IInstantiationService, + @IOpenerService openerService: IOpenerService, + @IThemeService themeService: IThemeService, + @IHoverService hoverService: IHoverService, + @IChatEditingService private readonly chatEditingService: IChatEditingService, + @IEditorService private readonly editorService: IEditorService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IActivityService private readonly activityService: IActivityService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @ILabelService private readonly labelService: ILabelService, + @IStorageService private readonly storageService: IStorageService, + ) { + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); + + // View mode + const storedMode = this.storageService.get('changesView.viewMode', StorageScope.WORKSPACE); + const initialMode = storedMode === ChangesViewMode.Tree ? ChangesViewMode.Tree : ChangesViewMode.List; + this.viewModeObs = observableValue(this, initialMode); + this.viewModeContextKey = changesViewModeContextKey.bindTo(contextKeyService); + this.viewModeContextKey.set(initialMode); + + // Setup badge tracking + this.registerBadgeTracking(); + + // Track active session from focused chat widgets + this.registerActiveSessionTracking(); + + // Set chatSessionType on the view's context key service so ViewTitle + // menu items can use it in their `when` clauses. Update reactively + // when the active session changes. + const viewSessionTypeKey = this.scopedContextKeyService.createKey(ChatContextKeys.agentSessionType.key, ''); + this._register(autorun(reader => { + const sessionResource = this.activeSessionResource.read(reader); + viewSessionTypeKey.set(sessionResource ? getChatSessionType(sessionResource) : ''); + })); + } + + private registerActiveSessionTracking(): void { + // Initialize with the last focused widget's session if available + const lastFocused = this.chatWidgetService.lastFocusedWidget; + if (lastFocused?.viewModel?.sessionResource) { + this.activeSessionResource.set(lastFocused.viewModel.sessionResource, undefined); + } + + // Listen for new widgets and track their focus + this._register(this.chatWidgetService.onDidAddWidget(widget => { + this._register(widget.onDidFocus(() => { + if (widget.viewModel?.sessionResource) { + this.activeSessionResource.set(widget.viewModel.sessionResource, undefined); + } + })); + + // Also track view model changes (when a widget loads a different session) + this._register(widget.onDidChangeViewModel(({ currentSessionResource }) => { + // Only update if this widget is focused + if (this.chatWidgetService.lastFocusedWidget === widget && currentSessionResource) { + this.activeSessionResource.set(currentSessionResource, undefined); + } + })); + })); + + // Track focus changes on existing widgets + for (const widget of this.chatWidgetService.getAllWidgets()) { + this._register(widget.onDidFocus(() => { + if (widget.viewModel?.sessionResource) { + this.activeSessionResource.set(widget.viewModel.sessionResource, undefined); + } + })); + + this._register(widget.onDidChangeViewModel(({ currentSessionResource }) => { + if (this.chatWidgetService.lastFocusedWidget === widget && currentSessionResource) { + this.activeSessionResource.set(currentSessionResource, undefined); + } + })); + } + } + + private registerBadgeTracking(): void { + // Signal observable that triggers when sessions data changes + const sessionsChangedSignal = observableFromEvent( + this, + this.agentSessionsService.model.onDidChangeSessions, + () => ({}), + ); + + // Observable for session file changes from agentSessionsService (cloud/background sessions) + // Reactive to both activeSessionResource changes AND session data changes + const sessionFileChangesObs = derived(reader => { + const sessionResource = this.activeSessionResource.read(reader); + sessionsChangedSignal.read(reader); + if (!sessionResource) { + return Iterable.empty(); + } + const model = this.agentSessionsService.getSession(sessionResource); + return model?.changes instanceof Array ? model.changes : Iterable.empty(); + }); + + // Create observable for the number of files changed in the active session + // Combines both editing session entries and session file changes (for cloud/background sessions) + const fileCountObs = derived(reader => { + const sessionResource = this.activeSessionResource.read(reader); + if (!sessionResource) { + return 0; + } + + // Background chat sessions render the working set based on the session files, not the editing session + const isBackgroundSession = getChatSessionType(sessionResource) === AgentSessionProviders.Background; + + // Count from editing session entries (skip for background sessions) + let editingSessionCount = 0; + if (!isBackgroundSession) { + const sessions = this.chatEditingService.editingSessionsObs.read(reader); + const session = sessions.find(candidate => isEqual(candidate.chatSessionResource, sessionResource)); + editingSessionCount = session ? session.entries.read(reader).length : 0; + } + + // Count from session file changes (cloud/background sessions) + const sessionFiles = [...sessionFileChangesObs.read(reader)]; + const sessionFilesCount = sessionFiles.length; + + return editingSessionCount + sessionFilesCount; + }); + + // Update badge when file count changes + this._register(autorun(reader => { + const fileCount = fileCountObs.read(reader); + this.updateBadge(fileCount); + })); + } + + private updateBadge(fileCount: number): void { + if (fileCount > 0) { + const message = fileCount === 1 + ? localize('changesView.oneFileChanged', '1 file changed') + : localize('changesView.filesChanged', '{0} files changed', fileCount); + this.badgeDisposable.value = this.activityService.showViewActivity(CHANGES_VIEW_ID, { badge: new NumberBadge(fileCount, () => message) }); + } else { + this.badgeDisposable.clear(); + } + } + + protected override renderBody(container: HTMLElement): void { + super.renderBody(container); + + this.bodyContainer = dom.append(container, $('.changes-view-body')); + + // Welcome message for empty state + this.welcomeContainer = dom.append(this.bodyContainer, $('.changes-welcome')); + const welcomeIcon = dom.append(this.welcomeContainer, $('.changes-welcome-icon')); + welcomeIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.diffMultiple)); + const welcomeMessage = dom.append(this.welcomeContainer, $('.changes-welcome-message')); + welcomeMessage.textContent = localize('changesView.noChanges', "No files have been changed."); + + // Actions container - positioned outside and above the card + this.actionsContainer = dom.append(this.bodyContainer, $('.chat-editing-session-actions.outside-card')); + + // Main container with file icons support (the "card") + this.contentContainer = dom.append(this.bodyContainer, $('.chat-editing-session-container.show-file-icons')); + this._register(createFileIconThemableTreeContainerScope(this.contentContainer, this.themeService)); + + // Toggle class based on whether the file icon theme has file icons + const updateHasFileIcons = () => { + this.contentContainer!.classList.toggle('has-file-icons', this.themeService.getFileIconTheme().hasFileIcons); + }; + updateHasFileIcons(); + this._register(this.themeService.onDidFileIconThemeChange(updateHasFileIcons)); + + // Overview section (header with summary only - actions moved outside card) + this.overviewContainer = dom.append(this.contentContainer, $('.chat-editing-session-overview')); + this.summaryContainer = dom.append(this.overviewContainer, $('.changes-summary')); + + // List container + this.listContainer = dom.append(this.contentContainer, $('.chat-editing-session-list')); + + this._register(this.onDidChangeBodyVisibility(visible => { + if (visible) { + this.onVisible(); + } else { + this.renderDisposables.clear(); + } + })); + + // Trigger initial render if already visible + if (this.isBodyVisible()) { + this.onVisible(); + } + } + + private onVisible(): void { + this.renderDisposables.clear(); + + // Create observable for the active editing session + // Note: We must read editingSessionsObs to establish a reactive dependency, + // so that the view updates when a new editing session is added (e.g., cloud sessions) + const activeEditingSessionObs = derived(reader => { + const sessionResource = this.activeSessionResource.read(reader); + if (!sessionResource) { + return undefined; + } + const sessions = this.chatEditingService.editingSessionsObs.read(reader); + return sessions.find(candidate => isEqual(candidate.chatSessionResource, sessionResource)); + }); + + // Create observable for edit session entries from the ACTIVE session only (local editing sessions) + const editSessionEntriesObs = derived(reader => { + const sessionResource = this.activeSessionResource.read(reader); + + // Background chat sessions render the working set based on the session files, not the editing session + if (sessionResource && getChatSessionType(sessionResource) === AgentSessionProviders.Background) { + return []; + } + + const session = activeEditingSessionObs.read(reader); + if (!session) { + return []; + } + + const entries = session.entries.read(reader); + const items: IChangesFileItem[] = []; + + for (const entry of entries) { + const isDeletion = entry.isDeletion ?? false; + const linesAdded = entry.linesAdded?.read(reader) ?? 0; + const linesRemoved = entry.linesRemoved?.read(reader) ?? 0; + + items.push({ + type: 'file', + uri: entry.modifiedURI, + originalUri: entry.originalURI, + state: entry.state.read(reader), + isDeletion, + changeType: isDeletion ? 'deleted' : 'modified', + linesAdded, + linesRemoved, + }); + } + + return items; + }); + + // Signal observable that triggers when sessions data changes + const sessionsChangedSignal = observableFromEvent( + this.renderDisposables, + this.agentSessionsService.model.onDidChangeSessions, + () => ({}), + ); + + // Observable for session file changes from agentSessionsService (cloud/background sessions) + // Reactive to both activeSessionResource changes AND session data changes + const sessionFileChangesObs = derived(reader => { + const sessionResource = this.activeSessionResource.read(reader); + sessionsChangedSignal.read(reader); + if (!sessionResource) { + return Iterable.empty(); + } + const model = this.agentSessionsService.getSession(sessionResource); + return model?.changes instanceof Array ? model.changes : Iterable.empty(); + }); + + // Convert session file changes to list items (cloud/background sessions) + const sessionFilesObs = derived(reader => + [...sessionFileChangesObs.read(reader)].map((entry): IChangesFileItem => { + const isDeletion = entry.modifiedUri === undefined; + const isAddition = entry.originalUri === undefined; + return { + type: 'file', + uri: isIChatSessionFileChange2(entry) + ? entry.modifiedUri ?? entry.uri + : entry.modifiedUri, + originalUri: entry.originalUri, + state: ModifiedFileEntryState.Accepted, + isDeletion, + changeType: isDeletion ? 'deleted' : isAddition ? 'added' : 'modified', + linesAdded: entry.insertions, + linesRemoved: entry.deletions, + }; + }) + ); + + // Combine both entry sources for display + const combinedEntriesObs = derived(reader => { + const editEntries = editSessionEntriesObs.read(reader); + const sessionFiles = sessionFilesObs.read(reader); + return [...editEntries, ...sessionFiles]; + }); + + // Calculate stats from combined entries + const topLevelStats = derived(reader => { + const editEntries = editSessionEntriesObs.read(reader); + const sessionFiles = sessionFilesObs.read(reader); + const entries = combinedEntriesObs.read(reader); + + let added = 0, removed = 0; + + for (const entry of entries) { + added += entry.linesAdded; + removed += entry.linesRemoved; + } + + const files = entries.length; + const isSessionMenu = editEntries.length === 0 && sessionFiles.length > 0; + + return { files, added, removed, isSessionMenu }; + }); + + // Setup context keys and actions toolbar + if (this.actionsContainer) { + dom.clearNode(this.actionsContainer); + + const scopedContextKeyService = this.renderDisposables.add(this.contextKeyService.createScoped(this.actionsContainer)); + const scopedInstantiationService = this.renderDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, scopedContextKeyService]))); + + // Set the chat session type context key reactively so that menu items with + // `chatSessionType == copilotcli` (e.g. Create Pull Request) are shown + const chatSessionTypeKey = scopedContextKeyService.createKey(ChatContextKeys.agentSessionType.key, ''); + this.renderDisposables.add(autorun(reader => { + const sessionResource = this.activeSessionResource.read(reader); + chatSessionTypeKey.set(sessionResource ? getChatSessionType(sessionResource) : ''); + })); + + // Bind required context keys for the menu buttons + this.renderDisposables.add(bindContextKey(hasUndecidedChatEditingResourceContextKey, scopedContextKeyService, r => { + const session = activeEditingSessionObs.read(r); + if (!session) { + return false; + } + const entries = session.entries.read(r); + return entries.some(entry => entry.state.read(r) === ModifiedFileEntryState.Modified); + })); + + this.renderDisposables.add(bindContextKey(hasAppliedChatEditsContextKey, scopedContextKeyService, r => { + const session = activeEditingSessionObs.read(r); + if (!session) { + return false; + } + const entries = session.entries.read(r); + return entries.length > 0; + })); + + this.renderDisposables.add(bindContextKey(ChatContextKeys.hasAgentSessionChanges, scopedContextKeyService, r => { + const { files } = topLevelStats.read(r); + return files > 0; + })); + + this.renderDisposables.add(autorun(reader => { + const { isSessionMenu, added, removed } = topLevelStats.read(reader); + const sessionResource = this.activeSessionResource.read(reader); + reader.store.add(scopedInstantiationService.createInstance( + MenuWorkbenchButtonBar, + this.actionsContainer!, + isSessionMenu ? MenuId.ChatEditingSessionChangesToolbar : MenuId.ChatEditingWidgetToolbar, + { + telemetrySource: 'changesView', + menuOptions: isSessionMenu && sessionResource + ? { args: [sessionResource, this.agentSessionsService.getSession(sessionResource)?.metadata] } + : { shouldForwardArgs: true }, + buttonConfigProvider: (action) => { + if (action.id === 'chatEditing.viewChanges' || action.id === 'chatEditing.viewPreviousEdits' || action.id === 'chatEditing.viewAllSessionChanges' || action.id === 'chat.openSessionWorktreeInVSCode') { + const diffStatsLabel = new MarkdownString( + `+${added} -${removed}`, + { supportHtml: true } + ); + return { showIcon: true, showLabel: true, isSecondary: true, customClass: 'working-set-diff-stats', customLabel: diffStatsLabel }; + } + if (action.id === 'github.createPullRequest') { + return { showIcon: true, showLabel: true, isSecondary: true, customClass: 'flex-grow' }; + } + return undefined; + } + } + )); + })); + } + + // Update visibility based on entries + this.renderDisposables.add(autorun(reader => { + const { files } = topLevelStats.read(reader); + const hasEntries = files > 0; + + dom.setVisibility(hasEntries, this.contentContainer!); + dom.setVisibility(hasEntries, this.actionsContainer!); + dom.setVisibility(!hasEntries, this.welcomeContainer!); + })); + + // Update summary text (line counts only, file count is shown in badge) + if (this.summaryContainer) { + dom.clearNode(this.summaryContainer); + + const linesAddedSpan = dom.$('.working-set-lines-added'); + const linesRemovedSpan = dom.$('.working-set-lines-removed'); + + this.summaryContainer.appendChild(linesAddedSpan); + this.summaryContainer.appendChild(linesRemovedSpan); + + this.renderDisposables.add(autorun(reader => { + const { added, removed } = topLevelStats.read(reader); + + linesAddedSpan.textContent = `+${added}`; + linesRemovedSpan.textContent = `-${removed}`; + })); + } + + // Create the tree + if (!this.tree && this.listContainer) { + const resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this.onDidChangeBodyVisibility })); + this.tree = this.instantiationService.createInstance( + WorkbenchCompressibleObjectTree, + 'ChangesViewTree', + this.listContainer, + new ChangesTreeDelegate(), + [this.instantiationService.createInstance(ChangesTreeRenderer, resourceLabels, MenuId.ChatEditingWidgetModifiedFilesToolbar)], + { + alwaysConsumeMouseWheel: false, + accessibilityProvider: { + getAriaLabel: (element: ChangesTreeElement) => isChangesFileItem(element) ? basename(element.uri.path) : element.name, + getWidgetAriaLabel: () => localize('changesViewTree', "Changes Tree") + }, + dnd: { + getDragURI: (element: ChangesTreeElement) => element.uri.toString(), + getDragLabel: (elements) => { + const uris = elements.map(e => e.uri); + if (uris.length === 1) { + return this.labelService.getUriLabel(uris[0], { relative: true }); + } + return `${uris.length}`; + }, + dispose: () => { }, + onDragOver: () => false, + drop: () => { }, + onDragStart: (data, originalEvent) => { + try { + const elements = data.getData() as ChangesTreeElement[]; + const uris = elements.filter(isChangesFileItem).map(e => e.uri); + this.instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, uris, originalEvent)); + } catch { + // noop + } + }, + }, + identityProvider: { + getId: (element: ChangesTreeElement) => element.uri.toString() + }, + compressionEnabled: true, + twistieAdditionalCssClass: (e: unknown) => { + if (this.viewMode === ChangesViewMode.List) { + return 'force-no-twistie'; + } + // In tree mode, hide twistie for file items (they are never collapsible) + return isChangesFileItem(e as ChangesTreeElement) ? 'force-no-twistie' : undefined; + }, + } + ); + } + + // Register tree event handlers + if (this.tree) { + const tree = this.tree; + + this.renderDisposables.add(tree.onDidOpen(async (e) => { + if (!e.element) { + return; + } + + // Ignore folder elements - only open files + if (!isChangesFileItem(e.element)) { + return; + } + + const { uri: modifiedFileUri, originalUri, isDeletion } = e.element; + + if (isDeletion && originalUri) { + await this.editorService.openEditor({ + resource: originalUri, + options: e.editorOptions + }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + return; + } + + if (originalUri) { + await this.editorService.openEditor({ + original: { resource: originalUri }, + modified: { resource: modifiedFileUri }, + options: e.editorOptions + }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + return; + } + + await this.editorService.openEditor({ + resource: modifiedFileUri, + options: e.editorOptions + }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + })); + } + + // Update tree data with combined entries + this.renderDisposables.add(autorun(reader => { + const entries = combinedEntriesObs.read(reader); + const viewMode = this.viewModeObs.read(reader); + + if (!this.tree) { + return; + } + + // Toggle list-mode class to remove tree indentation in list mode + this.listContainer?.classList.toggle('list-mode', viewMode === ChangesViewMode.List); + + if (viewMode === ChangesViewMode.Tree) { + // Tree mode: build hierarchical tree from file entries + const treeChildren = buildTreeChildren(entries); + this.tree.setChildren(null, treeChildren); + } else { + // List mode: flat list of file items + const listChildren: IObjectTreeElement[] = entries.map(item => ({ + element: item, + collapsible: false, + })); + this.tree.setChildren(null, listChildren); + } + + this.layoutTree(); + })); + } + + private layoutTree(): void { + if (!this.tree || !this.listContainer) { + return; + } + + // Calculate remaining height for the tree by subtracting other elements + const bodyHeight = this.currentBodyHeight; + if (bodyHeight <= 0) { + return; + } + + // Measure non-list elements height (padding, actions, overview) + const bodyPadding = 16; // 8px top + 8px bottom from .changes-view-body + const actionsHeight = this.actionsContainer?.offsetHeight ?? 0; + const actionsMargin = actionsHeight > 0 ? 8 : 0; // margin-bottom on actions container + const overviewHeight = this.overviewContainer?.offsetHeight ?? 0; + const containerPadding = 8; // 4px top + 4px bottom from .chat-editing-session-container + const containerBorder = 2; // 1px top + 1px bottom border + + const usedHeight = bodyPadding + actionsHeight + actionsMargin + overviewHeight + containerPadding + containerBorder; + const availableHeight = Math.max(0, bodyHeight - usedHeight); + + // Limit height to the content so the tree doesn't exceed its items + const contentHeight = this.tree.contentHeight; + const treeHeight = Math.min(availableHeight, contentHeight); + + this.tree.layout(treeHeight, this.currentBodyWidth); + this.tree.getHTMLElement().style.height = `${treeHeight}px`; + } + + protected override layoutBody(height: number, width: number): void { + super.layoutBody(height, width); + this.currentBodyHeight = height; + this.currentBodyWidth = width; + this.layoutTree(); + } + + override focus(): void { + super.focus(); + this.tree?.domFocus(); + } + + override dispose(): void { + this.tree?.dispose(); + this.tree = undefined; + super.dispose(); + } +} + +export class ChangesViewPaneContainer extends ViewPaneContainer { + constructor( + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @ITelemetryService telemetryService: ITelemetryService, + @IInstantiationService instantiationService: IInstantiationService, + @IContextMenuService contextMenuService: IContextMenuService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IConfigurationService configurationService: IConfigurationService, + @IExtensionService extensionService: IExtensionService, + @IWorkspaceContextService contextService: IWorkspaceContextService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @ILogService logService: ILogService, + ) { + super(CHANGES_VIEW_CONTAINER_ID, { mergeViewWithContainerWhenSingleView: true }, instantiationService, configurationService, layoutService, contextMenuService, telemetryService, extensionService, themeService, storageService, contextService, viewDescriptorService, logService); + } + + override create(parent: HTMLElement): void { + super.create(parent); + parent.classList.add('changes-viewlet'); + } +} + +// --- Tree Delegate & Renderer + +class ChangesTreeDelegate implements IListVirtualDelegate { + getHeight(_element: ChangesTreeElement): number { + return 22; + } + + getTemplateId(_element: ChangesTreeElement): string { + return ChangesTreeRenderer.TEMPLATE_ID; + } +} + +interface IChangesTreeTemplate { + readonly label: IResourceLabel; + readonly templateDisposables: DisposableStore; + readonly toolbar: MenuWorkbenchToolBar | undefined; + readonly contextKeyService: IContextKeyService | undefined; + readonly decorationBadge: HTMLElement; + readonly addedSpan: HTMLElement; + readonly removedSpan: HTMLElement; + readonly lineCountsContainer: HTMLElement; +} + +class ChangesTreeRenderer implements ICompressibleTreeRenderer { + static TEMPLATE_ID = 'changesTreeRenderer'; + readonly templateId: string = ChangesTreeRenderer.TEMPLATE_ID; + + constructor( + private labels: ResourceLabels, + private menuId: MenuId | undefined, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @ILabelService private readonly labelService: ILabelService, + ) { } + + renderTemplate(container: HTMLElement): IChangesTreeTemplate { + const templateDisposables = new DisposableStore(); + const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true, supportIcons: true })); + + const lineCountsContainer = $('.working-set-line-counts'); + const addedSpan = dom.$('.working-set-lines-added'); + const removedSpan = dom.$('.working-set-lines-removed'); + lineCountsContainer.appendChild(addedSpan); + lineCountsContainer.appendChild(removedSpan); + label.element.appendChild(lineCountsContainer); + + const decorationBadge = dom.$('.changes-decoration-badge'); + label.element.appendChild(decorationBadge); + + let toolbar: MenuWorkbenchToolBar | undefined; + let contextKeyService: IContextKeyService | undefined; + if (this.menuId) { + const actionBarContainer = $('.chat-collapsible-list-action-bar'); + contextKeyService = templateDisposables.add(this.contextKeyService.createScoped(actionBarContainer)); + const scopedInstantiationService = templateDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, contextKeyService]))); + toolbar = templateDisposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, actionBarContainer, this.menuId, { menuOptions: { shouldForwardArgs: true, arg: undefined } })); + label.element.appendChild(actionBarContainer); + } + + return { templateDisposables, label, toolbar, contextKeyService, decorationBadge, addedSpan, removedSpan, lineCountsContainer }; + } + + renderElement(node: ITreeNode, _index: number, templateData: IChangesTreeTemplate): void { + const element = node.element; + templateData.label.element.style.display = 'flex'; + + if (isChangesFileItem(element)) { + this.renderFileElement(element, templateData); + } else { + this.renderFolderElement(element, templateData); + } + } + + renderCompressedElements(node: ITreeNode, void>, _index: number, templateData: IChangesTreeTemplate): void { + const compressed = node.element; + const lastElement = compressed.elements[compressed.elements.length - 1]; + + templateData.label.element.style.display = 'flex'; + + if (isChangesFileItem(lastElement)) { + // Shouldn't happen in practice - files don't get compressed + this.renderFileElement(lastElement, templateData); + } else { + // Compressed folder chain - show joined folder names + const label = compressed.elements.map(e => isChangesFileItem(e) ? basename(e.uri.path) : e.name); + templateData.label.setResource({ resource: lastElement.uri, name: label }, { + fileKind: FileKind.FOLDER, + separator: this.labelService.getSeparator(lastElement.uri.scheme), + }); + + // Hide file-specific decorations for folders + templateData.decorationBadge.style.display = 'none'; + templateData.lineCountsContainer.style.display = 'none'; + + if (templateData.toolbar) { + templateData.toolbar.context = undefined; + } + if (templateData.contextKeyService) { + chatEditingWidgetFileStateContextKey.bindTo(templateData.contextKeyService).set(undefined!); + } + } + } + + private renderFileElement(data: IChangesFileItem, templateData: IChangesTreeTemplate): void { + templateData.label.setFile(data.uri, { + fileKind: FileKind.FILE, + fileDecorations: undefined, + strikethrough: data.changeType === 'deleted', + hidePath: true, + }); + + // Show file-specific decorations + templateData.lineCountsContainer.style.display = ''; + templateData.decorationBadge.style.display = ''; + + // Update decoration badge (A/M/D) + const badge = templateData.decorationBadge; + badge.className = 'changes-decoration-badge'; + switch (data.changeType) { + case 'added': + badge.textContent = 'A'; + badge.classList.add('added'); + break; + case 'deleted': + badge.textContent = 'D'; + badge.classList.add('deleted'); + break; + case 'modified': + default: + badge.textContent = 'M'; + badge.classList.add('modified'); + break; + } + + templateData.addedSpan.textContent = `+${data.linesAdded}`; + templateData.removedSpan.textContent = `-${data.linesRemoved}`; + + // eslint-disable-next-line no-restricted-syntax + templateData.label.element.querySelector('.monaco-icon-name-container')?.classList.add('modified'); + + if (templateData.toolbar) { + templateData.toolbar.context = data.uri; + } + if (templateData.contextKeyService) { + chatEditingWidgetFileStateContextKey.bindTo(templateData.contextKeyService).set(data.state); + } + } + + private renderFolderElement(data: IChangesFolderItem, templateData: IChangesTreeTemplate): void { + templateData.label.setFile(data.uri, { + fileKind: FileKind.FOLDER, + }); + + // Hide file-specific decorations for folders + templateData.decorationBadge.style.display = 'none'; + templateData.lineCountsContainer.style.display = 'none'; + + if (templateData.toolbar) { + templateData.toolbar.context = undefined; + } + if (templateData.contextKeyService) { + chatEditingWidgetFileStateContextKey.bindTo(templateData.contextKeyService).set(undefined!); + } + } + + disposeTemplate(templateData: IChangesTreeTemplate): void { + templateData.templateDisposables.dispose(); + } +} + +// --- View Mode Actions + +class SetChangesListViewModeAction extends ViewAction { + constructor() { + super({ + id: 'workbench.changesView.action.setListViewMode', + title: localize('setListViewMode', "View as List"), + viewId: CHANGES_VIEW_ID, + f1: false, + icon: Codicon.listTree, + toggled: changesViewModeContextKey.isEqualTo(ChangesViewMode.List), + menu: { + id: MenuId.ViewTitle, + when: ContextKeyExpr.equals('view', CHANGES_VIEW_ID), + group: '1_viewmode', + order: 1 + } + }); + } + + async runInView(_: ServicesAccessor, view: ChangesViewPane): Promise { + view.viewMode = ChangesViewMode.List; + } +} + +class SetChangesTreeViewModeAction extends ViewAction { + constructor() { + super({ + id: 'workbench.changesView.action.setTreeViewMode', + title: localize('setTreeViewMode', "View as Tree"), + viewId: CHANGES_VIEW_ID, + f1: false, + icon: Codicon.listFlat, + toggled: changesViewModeContextKey.isEqualTo(ChangesViewMode.Tree), + menu: { + id: MenuId.ViewTitle, + when: ContextKeyExpr.equals('view', CHANGES_VIEW_ID), + group: '1_viewmode', + order: 2 + } + }); + } + + async runInView(_: ServicesAccessor, view: ChangesViewPane): Promise { + view.viewMode = ChangesViewMode.Tree; + } +} + +registerAction2(SetChangesListViewModeAction); +registerAction2(SetChangesTreeViewModeAction); diff --git a/src/vs/sessions/contrib/changesView/browser/media/changesView.css b/src/vs/sessions/contrib/changesView/browser/media/changesView.css new file mode 100644 index 0000000000000..1300b886cbcd8 --- /dev/null +++ b/src/vs/sessions/contrib/changesView/browser/media/changesView.css @@ -0,0 +1,237 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.flex-grow { + flex-grow: 1; +} + +.changes-view-body { + display: flex; + flex-direction: column; + height: 100%; + padding: 8px; + box-sizing: border-box; +} + +/* Welcome/Empty state */ +.changes-view-body .changes-welcome { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + padding: 20px; + text-align: center; + gap: 8px; +} + +.changes-view-body .changes-welcome-icon.codicon { + font-size: 48px !important; + color: var(--vscode-descriptionForeground); + opacity: 0.6; +} + +.changes-view-body .changes-welcome-message { + color: var(--vscode-descriptionForeground); + font-size: 12px; +} + +/* Main container - matches chat editing session styling */ +.changes-view-body .chat-editing-session-container { + padding: 4px 3px; + box-sizing: border-box; + background-color: var(--vscode-editor-background); + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 4px; + display: flex; + flex-direction: column; + gap: 2px; + overflow: hidden; +} + +/* Overview section (header) - hidden since actions moved outside card */ +.changes-view-body .chat-editing-session-overview { + display: none; +} + +/* Summary container */ +.changes-view-body .changes-summary { + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; + padding: 0 6px; + color: var(--vscode-descriptionForeground); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border: 1px solid var(--vscode-input-border); + border-radius: 4px; +} + +/* Line counts in header */ +.changes-view-body .changes-summary .working-set-lines-added { + color: var(--vscode-chat-linesAddedForeground); + font-size: 11px; + font-weight: 500; +} + +.changes-view-body .changes-summary .working-set-lines-removed { + color: var(--vscode-chat-linesRemovedForeground); + font-size: 11px; + font-weight: 500; +} + +/* Actions container */ +.changes-view-body .chat-editing-session-actions { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + gap: 6px; + align-items: center; +} + +/* Actions container outside the card - new layout experiment */ +.changes-view-body .chat-editing-session-actions.outside-card { + margin-bottom: 8px; + justify-content: flex-end; +} + +/* Larger action buttons matching SCM ActionButton style */ +.changes-view-body .chat-editing-session-actions.outside-card .monaco-button { + height: 26px; + padding: 4px 14px; + border-radius: 4px; + font-size: 12px; + line-height: 18px; +} + +/* Primary button grows to fill available space */ +.changes-view-body .chat-editing-session-actions.outside-card .monaco-button:not(.secondary) { + flex: 1; +} + +.changes-view-body .chat-editing-session-actions.outside-card .monaco-button.secondary.monaco-text-button.codicon { + padding: 4px 8px; + font-size: 16px !important; +} +.changes-view-body .chat-editing-session-actions .monaco-button { + width: fit-content; + overflow: hidden; + text-wrap: nowrap; +} + +.changes-view-body .chat-editing-session-actions .monaco-button.secondary.monaco-text-button.codicon { + cursor: pointer; + padding: 2px; + border-radius: 4px; + display: inline-flex; +} + +.changes-view-body .chat-editing-session-actions .monaco-button.secondary.monaco-text-button { + background-color: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); +} + +.changes-view-body .chat-editing-session-actions .monaco-button.secondary:hover { + background-color: var(--vscode-button-secondaryHoverBackground); + color: var(--vscode-button-secondaryForeground); +} + +/* List container */ +.changes-view-body .chat-editing-session-list { + overflow: hidden; +} + +/* Make the vertical scrollbar overlay on top of content instead of shifting it */ +.changes-view-body .chat-editing-session-list .monaco-scrollable-element > .scrollbar.vertical { + z-index: 1; +} + +.changes-view-body .chat-editing-session-list .monaco-scrollable-element > .monaco-list-rows { + width: 100% !important; +} + +/* Remove tree indentation padding for hidden twisties (both list and tree mode) */ +.changes-view-body .chat-editing-session-list .monaco-tl-twistie.force-no-twistie { + padding-left: 0 !important; +} +/* List rows */ +.changes-view-body .chat-editing-session-container:not(.has-file-icons) .monaco-list-row .monaco-icon-label { + margin-left: 6px; +} + +.changes-view-body .chat-editing-session-container.show-file-icons .monaco-scrollable-element .monaco-list-rows .monaco-list-row { + border-radius: 2px; +} + +/* Action bar in list rows */ +.changes-view-body .monaco-list-row .chat-collapsible-list-action-bar { + padding-left: 5px; + display: none; +} + +.changes-view-body .monaco-list-row:hover .chat-collapsible-list-action-bar:not(.has-no-actions), +.changes-view-body .monaco-list-row.focused .chat-collapsible-list-action-bar:not(.has-no-actions), +.changes-view-body .monaco-list-row.selected .chat-collapsible-list-action-bar:not(.has-no-actions) { + display: inherit; +} + +/* Decoration badges (A/M/D) */ +.changes-view-body .chat-editing-session-list .changes-decoration-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + min-width: 16px; + font-size: 11px; + font-weight: 600; + line-height: 1; + margin-right: 2px; + opacity: 0.9; +} + +.changes-view-body .chat-editing-session-list .changes-decoration-badge.added { + color: var(--vscode-gitDecoration-addedResourceForeground); +} + +.changes-view-body .chat-editing-session-list .changes-decoration-badge.modified { + color: var(--vscode-gitDecoration-modifiedResourceForeground); +} + +.changes-view-body .chat-editing-session-list .changes-decoration-badge.deleted { + color: var(--vscode-gitDecoration-deletedResourceForeground); +} +/* Line counts in list items */ +.changes-view-body .chat-editing-session-list .working-set-line-counts { + margin: 0 6px; + display: inline-flex; + gap: 4px; + font-size: 11px; +} + +.changes-view-body .chat-editing-session-list .working-set-lines-added { + color: var(--vscode-chat-linesAddedForeground); +} + +.changes-view-body .chat-editing-session-list .working-set-lines-removed { + color: var(--vscode-chat-linesRemovedForeground); +} + +/* Line counts in buttons */ +.changes-view-body .chat-editing-session-actions .monaco-button.working-set-diff-stats { + flex-shrink: 0; + padding-left: 4px; + padding-right: 8px; +} + +.changes-view-body .chat-editing-session-actions .monaco-button .working-set-lines-added { + color: var(--vscode-chat-linesAddedForeground); +} + +.changes-view-body .chat-editing-session-actions .monaco-button .working-set-lines-removed { + color: var(--vscode-chat-linesRemovedForeground); +} diff --git a/src/vs/sessions/contrib/chat/browser/branchChatSessionAction.ts b/src/vs/sessions/contrib/chat/browser/branchChatSessionAction.ts new file mode 100644 index 0000000000000..f5575ce5a9bb8 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/branchChatSessionAction.ts @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize2 } from '../../../../nls.js'; +import { Action2, MenuId } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { ChatTreeItem, ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { ChatModel, ISerializableChatData } from '../../../../workbench/contrib/chat/common/model/chatModel.js'; +import { isRequestVM, isResponseVM } from '../../../../workbench/contrib/chat/common/model/chatViewModel.js'; +import { revive } from '../../../../base/common/marshalling.js'; +import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; + + +/** + * Action ID for branching chat session to a new local session. + */ +export const ACTION_ID_BRANCH_CHAT_SESSION = 'workbench.action.chat.branchChatSession'; + +/** + * Action that allows users to branch the current chat session from a specific checkpoint. + * This creates a copy of the conversation up to the selected checkpoint, allowing users + * to explore alternative paths from any point in the conversation. + */ +export class BranchChatSessionAction extends Action2 { + + static readonly ID = ACTION_ID_BRANCH_CHAT_SESSION; + + constructor() { + super({ + id: BranchChatSessionAction.ID, + title: localize2('branchChatSession', "Branch Chat"), + tooltip: localize2('branchChatSessionTooltip', "Branch to new session"), + icon: Codicon.reply, + f1: false, + precondition: ContextKeyExpr.and( + ChatContextKeys.enabled, + ChatContextKeys.requestInProgress.negate(), + ), + menu: [{ + id: MenuId.ChatMessageCheckpoint, + group: 'navigation', + order: 3, + when: ContextKeyExpr.and( + ChatContextKeys.isRequest, + ChatContextKeys.lockedToCodingAgent.negate(), + ), + }] + }); + } + + override async run(accessor: ServicesAccessor, ...args: unknown[]): Promise { + const item = args[0] as ChatTreeItem | undefined; + const widgetService = accessor.get(IChatWidgetService); + const chatService = accessor.get(IChatService); + + // Item must be a valid request or response from the checkpoint toolbar context + if (!item || (!isRequestVM(item) && !isResponseVM(item))) { + return; + } + + const widget = widgetService.getWidgetBySessionResource(item.sessionResource); + if (!widget || !widget.viewModel) { + return; + } + + // Get the current chat model + const chatModel = widget.viewModel.model as ChatModel; + if (!chatModel) { + return; + } + + const checkpointRequestId = isRequestVM(item) ? item.id : item.requestId; + const serializedData = revive(structuredClone(chatModel.toJSON())) as ISerializableChatData; + serializedData.sessionId = generateUuid(); + + delete serializedData.customTitle; + + const checkpointIndex = serializedData.requests.findIndex(r => r.requestId === checkpointRequestId); + if (checkpointIndex === -1) { + return; + } + + serializedData.requests = serializedData.requests.slice(0, checkpointIndex); + + // Clear shouldBeRemovedOnSend for all requests in the branched session + // This ensures all requests are visible in the new session + for (const request of serializedData.requests) { + delete request.shouldBeRemovedOnSend; + delete (request as { isHidden?: boolean }).isHidden; + } + + // If there's no conversation history to branch, don't proceed + if (serializedData.requests.length === 0) { + return; + } + + // Load the branched data into a new session model + const modelRef = chatService.loadSessionFromContent(serializedData); + if (!modelRef) { + return; + } + + // Open the branched session in the chat view pane + await widgetService.openSession(modelRef.object.sessionResource, ChatViewPaneTarget); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts new file mode 100644 index 0000000000000..8d3344be48df0 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -0,0 +1,183 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IHostService } from '../../../../workbench/services/host/browser/host.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IViewContainersRegistry, IViewsRegistry, ViewContainerLocation, Extensions as ViewExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { isAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { ISessionsWorkbenchService, IsNewChatSessionContext } from '../../sessions/browser/sessionsWorkbenchService.js'; +import { ITerminalService, ITerminalGroupService } from '../../../../workbench/contrib/terminal/browser/terminal.js'; +import { Menus } from '../../../browser/menus.js'; +import { BranchChatSessionAction } from './branchChatSessionAction.js'; +import { RunScriptContribution } from './runScriptAction.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { AgenticPromptsService } from './promptsService.js'; +import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { ChatViewContainerId, ChatViewId } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { NewChatViewPane, SessionsViewId } from './newChatViewPane.js'; +import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; +import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; +import { ChatViewPane } from '../../../../workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.js'; + +export class OpenSessionWorktreeInVSCodeAction extends Action2 { + static readonly ID = 'chat.openSessionWorktreeInVSCode'; + + constructor() { + super({ + id: OpenSessionWorktreeInVSCodeAction.ID, + title: localize2('openInVSCode', 'Open in VS Code'), + icon: Codicon.vscodeInsiders, + menu: [{ + id: Menus.OpenSubMenu, + group: 'navigation', + order: 2, + }] + }); + } + + override async run(accessor: ServicesAccessor,): Promise { + const hostService = accessor.get(IHostService); + const agentSessionsService = accessor.get(ISessionsWorkbenchService); + + const activeSession = agentSessionsService.activeSession.get(); + if (!activeSession) { + return; + } + + const folderUri = isAgentSession(activeSession) && activeSession.providerType !== AgentSessionProviders.Cloud ? activeSession.worktree : undefined; + + if (!folderUri) { + return; + } + + await hostService.openWindow([{ folderUri }], { forceNewWindow: true }); + } +} +registerAction2(OpenSessionWorktreeInVSCodeAction); + +export class OpenSessionInTerminalAction extends Action2 { + + constructor() { + super({ + id: 'agentSession.openInTerminal', + title: localize2('openInTerminal', "Open Terminal"), + icon: Codicon.terminal, + menu: [{ + id: Menus.OpenSubMenu, + group: 'navigation', + order: 1, + }] + }); + } + + override async run(accessor: ServicesAccessor,): Promise { + const terminalService = accessor.get(ITerminalService); + const terminalGroupService = accessor.get(ITerminalGroupService); + const agentSessionsService = accessor.get(ISessionsWorkbenchService); + + const activeSession = agentSessionsService.activeSession.get(); + const repository = isAgentSession(activeSession) && activeSession.providerType !== AgentSessionProviders.Cloud + ? activeSession.worktree + : undefined; + if (repository) { + const instance = await terminalService.createTerminal({ config: { cwd: repository } }); + if (instance) { + terminalService.setActiveInstance(instance); + } + } + terminalGroupService.showPanel(true); + } +} + +registerAction2(OpenSessionInTerminalAction); + +// Register the split button menu item that combines Open in VS Code and Open in Terminal +MenuRegistry.appendMenuItem(Menus.TitleBarRight, { + submenu: Menus.OpenSubMenu, + isSplitButton: { togglePrimaryAction: true }, + title: localize2('open', "Open..."), + icon: Codicon.folderOpened, + group: 'navigation', + order: 9, +}); + + + +// --- Sessions New Chat View Registration --- +// Registers in the same ChatBar container as the existing ChatViewPane. +// The `when` clause ensures only the new-session pane shows when no active session exists. + +const chatViewIcon = registerIcon('chat-view-icon', Codicon.chatSparkle, localize('chatViewIcon', 'View icon of the chat view.')); + +class RegisterChatViewContainerContribution implements IWorkbenchContribution { + + static ID = 'sessions.registerChatViewContainer'; + + constructor() { + const viewContainerRegistry = Registry.as(ViewExtensions.ViewContainersRegistry); + const viewsRegistry = Registry.as(ViewExtensions.ViewsRegistry); + let chatViewContainer = viewContainerRegistry.get(ChatViewContainerId); + if (chatViewContainer) { + viewContainerRegistry.deregisterViewContainer(chatViewContainer); + const view = viewsRegistry.getView(ChatViewId); + if (view) { + viewsRegistry.deregisterViews([view], chatViewContainer); + } + } + + chatViewContainer = viewContainerRegistry.registerViewContainer({ + id: ChatViewContainerId, + title: localize2('chat.viewContainer.label', "Chat"), + icon: chatViewIcon, + ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [ChatViewContainerId, { mergeViewWithContainerWhenSingleView: true }]), + storageId: ChatViewContainerId, + hideIfEmpty: true, + order: 1, + windowVisibility: WindowVisibility.Sessions, + }, ViewContainerLocation.ChatBar, { isDefault: true, doNotRegisterOpenCommand: true }); + + viewsRegistry.registerViews([{ + id: ChatViewId, + containerIcon: chatViewContainer.icon, + containerTitle: chatViewContainer.title.value, + singleViewPaneContainerTitle: chatViewContainer.title.value, + name: localize2('chat.viewContainer.label', "Chat"), + canToggleVisibility: false, + canMoveView: false, + ctorDescriptor: new SyncDescriptor(ChatViewPane), + when: IsNewChatSessionContext.negate(), + windowVisibility: WindowVisibility.Sessions + }, { + id: SessionsViewId, + containerIcon: chatViewContainer.icon, + containerTitle: chatViewContainer.title.value, + singleViewPaneContainerTitle: chatViewContainer.title.value, + name: localize2('sessions.newChat.view', "New Session"), + canToggleVisibility: false, + canMoveView: false, + ctorDescriptor: new SyncDescriptor(NewChatViewPane), + when: IsNewChatSessionContext, + windowVisibility: WindowVisibility.Sessions, + }], chatViewContainer); + } +} + + +// register actions +registerAction2(BranchChatSessionAction); + +// register workbench contributions +registerWorkbenchContribution2(RegisterChatViewContainerContribution.ID, RegisterChatViewContainerContribution, WorkbenchPhase.BlockStartup); +registerWorkbenchContribution2(RunScriptContribution.ID, RunScriptContribution, WorkbenchPhase.AfterRestored); + +// register services +registerSingleton(IPromptsService, AgenticPromptsService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css new file mode 100644 index 0000000000000..6316384afb338 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css @@ -0,0 +1,268 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.chat-full-welcome { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + box-sizing: border-box; + overflow-x: hidden; + transition: justify-content 0.4s ease; +} + +.chat-full-welcome.revealed { + justify-content: center; +} + +/* Header */ +.chat-full-welcome-header { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + max-width: 800px; + overflow: visible; +} + +/* Mascot */ +.chat-full-welcome-mascot { + width: 80px; + height: 80px; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + margin-top: 8px; + margin-bottom: 12px; + animation: chat-full-welcome-mascot-bounce 1s ease-in-out infinite; + transition: animation-duration 0.3s ease; + background-image: url('../../../../../workbench/browser/media/code-icon.svg'); +} + +.chat-full-welcome.revealed .chat-full-welcome-mascot { + animation-duration: 2s; +} + +@keyframes chat-full-welcome-mascot-bounce { + 0%, 100% { + transform: translateY(0); + } + + 50% { + transform: translateY(-6px); + } +} + +/* Input slot */ +.chat-full-welcome-inputSlot { + width: 100%; + max-width: 800px; + margin-top: 12px; + box-sizing: border-box; + display: none; +} + +.chat-full-welcome.revealed .chat-full-welcome-inputSlot { + display: block; + animation: chat-full-welcome-fade-in 0.35s ease 0.15s both; +} + +/* Option group pickers container (below the input) */ +.chat-full-welcome-pickers-container { + display: none; + justify-content: center; + width: 100%; + max-width: 800px; + margin: 12px; + box-sizing: border-box; +} + +.chat-full-welcome.revealed .chat-full-welcome-pickers-container { + display: flex; + animation: chat-full-welcome-fade-in 0.35s ease 0.1s both; +} + +@keyframes chat-full-welcome-fade-in { + from { + opacity: 0; + transform: translateY(12px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.chat-full-welcome-pickers-container:empty { + display: none; + margin-bottom: 0; +} + +/* Ensure the input editor fits properly */ +.chat-full-welcome-inputSlot .interactive-input-part { + margin: 0; + padding: 0; + max-width: 100%; + box-sizing: border-box; +} + +.chat-full-welcome-inputSlot .interactive-input-part .monaco-editor { + min-height: 0; +} + +.chat-full-welcome-inputSlot .interactive-input-part .monaco-editor .view-lines { + min-height: 0; +} + +.chat-full-welcome-inputSlot .chat-input-container { + overflow: hidden; + border-color: var(--vscode-contrastBorder, var(--vscode-editorWidget-border)); +} + +.chat-controls-container .monaco-editor-background { + background-color: var(--vscode-input-background) !important; +} + +/* Pickers row - flat horizontal bar below the input */ +.chat-full-welcome-pickers { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + width: 100%; + box-sizing: border-box; + padding: 0 2px; +} + +.chat-full-welcome-pickers:empty { + display: none; +} + +/* Left group (target dropdown + left-side extension pickers) */ +.sessions-chat-pickers-left { + display: flex; + align-items: center; + gap: 2px; + min-width: 0; +} + +/* Spacer between left and right groups */ +.sessions-chat-pickers-spacer { + flex: 1; +} + +/* Right group (repo/folder pickers) */ +.sessions-chat-extension-pickers-right { + display: flex; + align-items: center; + gap: 2px; + min-width: 0; +} + +.sessions-chat-extension-pickers-right:empty { + display: none; +} + +/* Left extension pickers container */ +.sessions-chat-extension-pickers-left { + display: flex; + align-items: center; + gap: 2px; + min-width: 0; +} + +.sessions-chat-extension-pickers-left:empty { + display: none; +} + +/* Target dropdown button */ +.sessions-chat-dropdown-button { + display: flex; + align-items: center; + height: 16px; + padding: 3px 0 3px 6px; + cursor: pointer; + font-size: 13px; + color: var(--vscode-descriptionForeground); + background-color: transparent; + border: none; + white-space: nowrap; + border-radius: 4px; +} + +.sessions-chat-dropdown-button:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.sessions-chat-dropdown-button:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.sessions-chat-dropdown-button .codicon { + font-size: 14px; + flex-shrink: 0; +} + +.sessions-chat-dropdown-button .codicon.codicon-chevron-down { + font-size: 12px; + margin-left: 2px; +} + +.sessions-chat-dropdown-label { + margin-left: 4px; +} + +/* Extension picker slots (rendered inline in the row) */ +.sessions-chat-picker-slot { + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; +} + +.sessions-chat-picker-slot .action-label { + display: flex; + align-items: center; + height: 16px; + padding: 3px 0 3px 6px; + background-color: transparent; + border: none; + color: var(--vscode-descriptionForeground); + font-size: 13px; + cursor: pointer; + white-space: nowrap; + border-radius: 4px; + min-width: 0; + overflow: hidden; +} + +.sessions-chat-picker-slot .action-label:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.sessions-chat-picker-slot .action-label .codicon { + font-size: 14px; + flex-shrink: 0; +} + +.sessions-chat-picker-slot .action-label .codicon-chevron-down { + font-size: 12px; + margin-left: 2px; +} + +.sessions-chat-picker-slot .action-label .chat-session-option-label { + overflow: hidden; + text-overflow: ellipsis; +} + +.sessions-chat-picker-slot .action-label span + .chat-session-option-label { + margin-left: 2px; +} diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css new file mode 100644 index 0000000000000..83bce416db037 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -0,0 +1,117 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.sessions-chat-widget { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; +} + +/* Welcome container fills available space and centers content */ +.sessions-chat-widget .agent-chat-welcome-container { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +/* Input area */ +.sessions-chat-input-area { + width: 100%; + max-width: 800px; + box-sizing: border-box; + border: 1px solid var(--vscode-input-border, var(--vscode-contrastBorder, transparent)); + border-radius: 8px; + background-color: var(--vscode-input-background); + overflow: hidden; +} + +.sessions-chat-input-area:focus-within { + border-color: var(--vscode-focusBorder); +} + +/* Editor */ +.sessions-chat-editor { + padding: 0 10px; + height: 72px; + min-height: 72px; + max-height: 200px; +} + +.sessions-chat-editor .monaco-editor, +.sessions-chat-editor .monaco-editor .overflow-guard, +.sessions-chat-editor .monaco-editor-background { + background-color: var(--vscode-input-background) !important; + border-radius: 8px 8px 0 0; +} + +/* Toolbar */ +.sessions-chat-toolbar { + display: flex; + align-items: center; + padding: 4px 8px; + gap: 4px; +} + +.sessions-chat-toolbar-spacer { + flex: 1; +} + +/* Model picker - uses workbench ModelPickerActionItem */ +.sessions-chat-model-picker { + display: flex; + align-items: center; +} + +.sessions-chat-model-picker .action-label { + display: flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + color: var(--vscode-descriptionForeground); +} + +.sessions-chat-model-picker .action-label:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.sessions-chat-model-picker .action-label .codicon { + font-size: 12px; +} + +/* Send button */ +.sessions-chat-send-button { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 4px; + cursor: pointer; + color: var(--vscode-descriptionForeground); + background: transparent; + border: none; + outline: none; +} + +.sessions-chat-send-button:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.sessions-chat-send-button:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.sessions-chat-send-button .codicon { + font-size: 16px; +} diff --git a/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-exploration.svg b/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-exploration.svg new file mode 100644 index 0000000000000..81991ee80fa80 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-exploration.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-insider.svg b/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-insider.svg new file mode 100644 index 0000000000000..55db4d45e46fb --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-insider.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-stable.svg b/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-stable.svg new file mode 100644 index 0000000000000..e26c10e038aa0 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-stable.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions.svg b/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions.svg new file mode 100644 index 0000000000000..e26c10e038aa0 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts new file mode 100644 index 0000000000000..54a452f8c3d7e --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -0,0 +1,819 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/chatWidget.css'; +import './media/chatWelcomePart.css'; +import * as dom from '../../../../base/browser/dom.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { toAction } from '../../../../base/common/actions.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { IObservable, observableValue } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; + +import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js'; +import { IEditorConstructionOptions } from '../../../../editor/browser/config/editorConfiguration.js'; +import { IModelService } from '../../../../editor/common/services/model.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService, IContextKey, RawContextKey, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { asCSSUrl } from '../../../../base/browser/cssValue.js'; +import { FileAccess } from '../../../../base/common/network.js'; +import { localize } from '../../../../nls.js'; +import { AgentSessionProviders, getAgentSessionProviderIcon } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { ISessionsWorkbenchService } from '../../sessions/browser/sessionsWorkbenchService.js'; +import { ChatSessionPosition, getResourceForNewChatSession } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.js'; +import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.js'; +import { SearchableOptionPickerActionItem } from '../../../../workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.js'; +import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; +import { IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; +import { IModelPickerDelegate, ModelPickerActionItem } from '../../../../workbench/contrib/chat/browser/widget/input/modelPickerActionItem.js'; +import { IChatInputPickerOptions } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js'; +import { WorkspaceFolderCountContext } from '../../../../workbench/common/contextkeys.js'; +import { IViewDescriptorService } from '../../../../workbench/common/views.js'; +import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; +import { ContextMenuController } from '../../../../editor/contrib/contextmenu/browser/contextmenu.js'; +import { getSimpleEditorOptions } from '../../../../workbench/contrib/codeEditor/browser/simpleEditorOptions.js'; + +// #region --- Target Config --- + +/** + * Tracks which agent session targets are available and which is selected. + * Targets are fixed at construction time; only the selection changes. + */ +export interface ITargetConfig { + readonly allowedTargets: IObservable>; + readonly selectedTarget: IObservable; + readonly onDidChangeSelectedTarget: Event; + readonly onDidChangeAllowedTargets: Event>; + setSelectedTarget(target: AgentSessionProviders): void; +} + +export interface ITargetConfigOptions { + allowedTargets: AgentSessionProviders[]; + defaultTarget?: AgentSessionProviders; +} + +class TargetConfig extends Disposable implements ITargetConfig { + + private readonly _allowedTargets = observableValue>('allowedTargets', new Set()); + readonly allowedTargets: IObservable> = this._allowedTargets; + + private readonly _selectedTarget = observableValue('selectedTarget', undefined); + readonly selectedTarget: IObservable = this._selectedTarget; + + private readonly _onDidChangeSelectedTarget = this._register(new Emitter()); + readonly onDidChangeSelectedTarget = this._onDidChangeSelectedTarget.event; + + private readonly _onDidChangeAllowedTargets = this._register(new Emitter>()); + readonly onDidChangeAllowedTargets = this._onDidChangeAllowedTargets.event; + + constructor(options: ITargetConfigOptions) { + super(); + const initialSet = new Set(options.allowedTargets); + this._allowedTargets.set(initialSet, undefined); + const defaultTarget = options.defaultTarget && initialSet.has(options.defaultTarget) + ? options.defaultTarget + : initialSet.values().next().value; + this._selectedTarget.set(defaultTarget, undefined); + } + + setSelectedTarget(target: AgentSessionProviders): void { + const allowed = this._allowedTargets.get(); + if (!allowed.has(target)) { + throw new Error(`Target "${target}" is not in the allowed set`); + } + if (this._selectedTarget.get() !== target) { + this._selectedTarget.set(target, undefined); + this._onDidChangeSelectedTarget.fire(target); + } + } +} + +// #endregion + +// #region --- Chat Welcome Widget --- + +/** + * Data passed to the `onSendRequest` callback when the user submits a query. + */ +export interface INewChatSendRequestData { + readonly resource: URI; + readonly target: AgentSessionProviders; + readonly query: string; + readonly sendOptions: IChatSendRequestOptions; + readonly selectedOptions: ReadonlyMap; +} + +/** + * Options for creating a `NewChatWidget`. + */ +export interface INewChatWidgetOptions { + readonly targetConfig: ITargetConfigOptions; + readonly onSendRequest?: (data: INewChatSendRequestData) => void; + readonly sessionPosition?: ChatSessionPosition; +} + +/** + * A self-contained new-session chat widget with a welcome view (mascot, target + * buttons, option pickers), an input editor, model picker, and send button. + * + * This widget is shown only in the empty/welcome state. Once the user sends + * a message, a session is created and the workbench ChatViewPane takes over. + */ +class NewChatWidget extends Disposable { + + private readonly _targetConfig: ITargetConfig; + private readonly _options: INewChatWidgetOptions; + + // Input + private _editor!: CodeEditorWidget; + private readonly _currentLanguageModel = observableValue('currentLanguageModel', undefined); + private readonly _modelPickerDisposable = this._register(new MutableDisposable()); + private _pendingSessionResource: URI | undefined; + + // Welcome part + private readonly _welcomeContentDisposables = this._register(new DisposableStore()); + private _pickersContainer: HTMLElement | undefined; + private _targetDropdownContainer: HTMLElement | undefined; + private _extensionPickersLeftContainer: HTMLElement | undefined; + private _extensionPickersRightContainer: HTMLElement | undefined; + private _inputSlot: HTMLElement | undefined; + private readonly _pickerWidgets = new Map(); + private readonly _pickerWidgetDisposables = this._register(new DisposableStore()); + private readonly _optionEmitters = new Map>(); + private readonly _selectedOptions = new Map(); + private readonly _optionContextKeys = new Map>(); + private readonly _whenClauseKeys = new Set(); + + constructor( + options: INewChatWidgetOptions, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @IModelService private readonly modelService: IModelService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IProductService private readonly productService: IProductService, + @ILogService private readonly logService: ILogService, + @IHoverService _hoverService: IHoverService, + ) { + super(); + this._targetConfig = this._register(new TargetConfig(options.targetConfig)); + this._options = options; + + // When target changes, regenerate pending resource + this._register(this._targetConfig.onDidChangeSelectedTarget(() => { + this._generatePendingSessionResource(); + this._updateTargetDropdown(); + this._renderExtensionPickers(true); + })); + + this._register(this._targetConfig.onDidChangeAllowedTargets(() => { + if (this._targetDropdownContainer) { + dom.clearNode(this._targetDropdownContainer); + this._renderTargetDropdown(this._targetDropdownContainer); + } + this._renderExtensionPickers(true); + })); + + // Listen for option group changes to re-render pickers + this._register(this.chatSessionsService.onDidChangeOptionGroups(() => { + this._renderExtensionPickers(); + })); + + // React to chat session option changes + this._register(this.chatSessionsService.onDidChangeSessionOptions((e: URI | undefined) => { + if (this._pendingSessionResource && isEqual(this._pendingSessionResource, e)) { + this._syncOptionsFromSession(this._pendingSessionResource); + this._renderExtensionPickers(); + } + })); + + const workspaceFolderCountKey = new Set([WorkspaceFolderCountContext.key]); + this._register(this.contextKeyService.onDidChangeContext(e => { + if (e.affectsSome(workspaceFolderCountKey)) { + this._renderExtensionPickers(true); + } + if (this._whenClauseKeys.size > 0 && e.affectsSome(this._whenClauseKeys)) { + this._renderExtensionPickers(true); + } + })); + } + + // --- Rendering --- + + render(container: HTMLElement): void { + const wrapper = dom.append(container, dom.$('.sessions-chat-widget')); + const welcomeElement = dom.append(wrapper, dom.$('.chat-full-welcome')); + + // Mascot + const header = dom.append(welcomeElement, dom.$('.chat-full-welcome-header')); + const quality = this.productService.quality ?? 'stable'; + const mascot = dom.append(header, dom.$('.chat-full-welcome-mascot')); + mascot.style.backgroundImage = asCSSUrl(FileAccess.asBrowserUri(`vs/sessions/contrib/chat/browser/media/code-icon-agent-sessions-${quality}.svg`)); + + // Input slot + this._inputSlot = dom.append(welcomeElement, dom.$('.chat-full-welcome-inputSlot')); + + // Input area inside the input slot + const inputArea = dom.$('.sessions-chat-input-area'); + this._createEditor(inputArea); + this._createToolbar(inputArea); + this._inputSlot.appendChild(inputArea); + + // Option group pickers (below the input) + this._pickersContainer = dom.append(welcomeElement, dom.$('.chat-full-welcome-pickers-container')); + + // Render target buttons & extension pickers + this._renderOptionGroupPickers(); + + // Initialize model picker + this._initDefaultModel(); + + // Generate pending resource for option changes + this._generatePendingSessionResource(); + + // Reveal + welcomeElement.classList.add('revealed'); + } + + private _generatePendingSessionResource(): void { + const target = this._targetConfig.selectedTarget.get(); + if (!target || target === AgentSessionProviders.Local) { + this._pendingSessionResource = undefined; + return; + } + this._pendingSessionResource = getResourceForNewChatSession({ + type: target, + position: this._options.sessionPosition ?? ChatSessionPosition.Sidebar, + displayName: '', + }); + } + + // --- Editor --- + + private _createEditor(container: HTMLElement): void { + const editorContainer = dom.append(container, dom.$('.sessions-chat-editor')); + + const uri = URI.from({ scheme: 'sessions-chat', path: `input-${Date.now()}` }); + const textModel = this._register(this.modelService.createModel('', null, uri, true)); + + const editorOptions: IEditorConstructionOptions = { + ...getSimpleEditorOptions(this.configurationService), + readOnly: false, + ariaLabel: localize('chatInput', "Chat input"), + placeholder: localize('chatPlaceholder', "Run tasks in the background, type '#' for adding context"), + fontFamily: 'system-ui, -apple-system, sans-serif', + fontSize: 13, + lineHeight: 20, + padding: { top: 8, bottom: 2 }, + wrappingStrategy: 'advanced', + stickyScroll: { enabled: false }, + renderWhitespace: 'none', + }; + + const widgetOptions: ICodeEditorWidgetOptions = { + isSimpleWidget: true, + contributions: EditorExtensionsRegistry.getSomeEditorContributions([ + ContextMenuController.ID, + ]), + }; + + this._editor = this._register(this.instantiationService.createInstance( + CodeEditorWidget, editorContainer, editorOptions, widgetOptions, + )); + this._editor.setModel(textModel); + + this._register(this._editor.onKeyDown(e => { + if (e.keyCode === KeyCode.Enter && !e.shiftKey && !e.ctrlKey && !e.altKey) { + e.preventDefault(); + e.stopPropagation(); + this._send(); + } + })); + + this._register(this._editor.onDidContentSizeChange(() => { + const contentHeight = this._editor.getContentHeight(); + const clampedHeight = Math.min(Math.max(contentHeight, 36), 200); + editorContainer.style.height = `${clampedHeight}px`; + this._editor.layout(); + })); + } + + private _createToolbar(container: HTMLElement): void { + const toolbar = dom.append(container, dom.$('.sessions-chat-toolbar')); + + const modelPickerContainer = dom.append(toolbar, dom.$('.sessions-chat-model-picker')); + this._createModelPicker(modelPickerContainer); + + dom.append(toolbar, dom.$('.sessions-chat-toolbar-spacer')); + + const sendButton = dom.append(toolbar, dom.$('.sessions-chat-send-button')); + sendButton.tabIndex = 0; + sendButton.role = 'button'; + sendButton.title = localize('send', "Send"); + dom.append(sendButton, renderIcon(Codicon.send)); + this._register(dom.addDisposableListener(sendButton, dom.EventType.CLICK, () => this._send())); + } + + // --- Model picker --- + + private _createModelPicker(container: HTMLElement): void { + const delegate: IModelPickerDelegate = { + currentModel: this._currentLanguageModel, + setModel: (model: ILanguageModelChatMetadataAndIdentifier) => { + this._currentLanguageModel.set(model, undefined); + }, + getModels: () => this._getAvailableModels(), + canManageModels: () => true, + }; + + const pickerOptions: IChatInputPickerOptions = { + onlyShowIconsForDefaultActions: observableValue('onlyShowIcons', false), + hoverPosition: { hoverPosition: HoverPosition.ABOVE }, + }; + + const action = { id: 'sessions.modelPicker', label: '', enabled: true, class: undefined, tooltip: '', run: () => { } }; + + const modelPicker = this.instantiationService.createInstance( + ModelPickerActionItem, action, undefined, delegate, pickerOptions, + ); + this._modelPickerDisposable.value = modelPicker; + modelPicker.render(container); + } + + private _initDefaultModel(): void { + const models = this._getAvailableModels(); + if (models.length > 0) { + this._currentLanguageModel.set(models[0], undefined); + } + + this._register(this.languageModelsService.onDidChangeLanguageModels(() => { + if (!this._currentLanguageModel.get()) { + const models = this._getAvailableModels(); + if (models.length > 0) { + this._currentLanguageModel.set(models[0], undefined); + } + } + })); + } + + private _getAvailableModels(): ILanguageModelChatMetadataAndIdentifier[] { + return this.languageModelsService.getLanguageModelIds() + .map(id => { + const metadata = this.languageModelsService.lookupLanguageModel(id); + return metadata ? { metadata, identifier: id } : undefined; + }) + .filter((m): m is ILanguageModelChatMetadataAndIdentifier => !!m && !!m.metadata.isUserSelectable); + } + + // --- Welcome: Target & option pickers (dropdown row below input) --- + + private _renderOptionGroupPickers(): void { + if (!this._pickersContainer) { + return; + } + + this._disposePickerWidgets(); + dom.clearNode(this._pickersContainer); + + const pickersRow = dom.append(this._pickersContainer, dom.$('.chat-full-welcome-pickers')); + + // Left group: target dropdown + non-repo extension pickers + const leftGroup = dom.append(pickersRow, dom.$('.sessions-chat-pickers-left')); + this._targetDropdownContainer = dom.append(leftGroup, dom.$('.sessions-chat-dropdown-wrapper')); + this._renderTargetDropdown(this._targetDropdownContainer); + this._extensionPickersLeftContainer = dom.append(leftGroup, dom.$('.sessions-chat-extension-pickers-left')); + + // Spacer + dom.append(pickersRow, dom.$('.sessions-chat-pickers-spacer')); + + // Right group: repo/folder pickers + this._extensionPickersRightContainer = dom.append(pickersRow, dom.$('.sessions-chat-extension-pickers-right')); + + this._renderExtensionPickers(); + } + + private _renderTargetDropdown(container: HTMLElement): void { + const allowed = this._targetConfig.allowedTargets.get(); + if (allowed.size === 0) { + return; + } + + const activeType = this._targetConfig.selectedTarget.get() ?? AgentSessionProviders.Background; + const icon = getAgentSessionProviderIcon(activeType); + const name = getAgentSessionProviderName(activeType); + + const button = dom.append(container, dom.$('.sessions-chat-dropdown-button')); + button.tabIndex = 0; + button.role = 'button'; + button.ariaHasPopup = 'true'; + dom.append(button, renderIcon(icon)); + dom.append(button, dom.$('span.sessions-chat-dropdown-label', undefined, name)); + dom.append(button, renderIcon(Codicon.chevronDown)); + + this._welcomeContentDisposables.add(dom.addDisposableListener(button, dom.EventType.CLICK, () => { + const currentAllowed = this._targetConfig.allowedTargets.get(); + const currentActive = this._targetConfig.selectedTarget.get(); + const actions = [...currentAllowed] + .filter(t => t !== AgentSessionProviders.Local) + .map(sessionType => { + const label = getAgentSessionProviderName(sessionType); + return toAction({ + id: `target.${sessionType}`, + label, + checked: sessionType === currentActive, + run: () => this._targetConfig.setSelectedTarget(sessionType), + }); + }); + this.contextMenuService.showContextMenu({ + getAnchor: () => button, + getActions: () => actions, + }); + })); + } + + private _updateTargetDropdown(): void { + if (!this._targetDropdownContainer) { + return; + } + dom.clearNode(this._targetDropdownContainer); + this._renderTargetDropdown(this._targetDropdownContainer); + } + + // --- Welcome: Extension option pickers --- + + private _renderExtensionPickers(force?: boolean): void { + if (!this._extensionPickersLeftContainer || !this._extensionPickersRightContainer) { + return; + } + + const activeSessionType = this._targetConfig.selectedTarget.get(); + if (!activeSessionType) { + this._clearExtensionPickers(); + return; + } + + const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(activeSessionType); + if (!optionGroups || optionGroups.length === 0) { + return; + } + + const visibleGroups: IChatSessionProviderOptionGroup[] = []; + this._whenClauseKeys.clear(); + for (const group of optionGroups) { + if (isModelOptionGroup(group)) { + continue; + } + if (group.when) { + const expr = ContextKeyExpr.deserialize(group.when); + if (expr) { + for (const key of expr.keys()) { + this._whenClauseKeys.add(key); + } + } + } + const hasItems = group.items.length > 0 || (group.commands || []).length > 0 || !!group.searchable; + const passesWhenClause = this._evaluateOptionGroupVisibility(group); + if (hasItems && passesWhenClause) { + visibleGroups.push(group); + } + } + + if (visibleGroups.length === 0) { + this._clearExtensionPickers(); + return; + } + + visibleGroups.sort((a, b) => (a.when ? 1 : 0) - (b.when ? 1 : 0)); + + if (!force && this._pickerWidgets.size === visibleGroups.length) { + const allMatch = visibleGroups.every(g => this._pickerWidgets.has(g.id)); + if (allMatch) { + return; + } + } + + this._clearExtensionPickers(); + + for (const optionGroup of visibleGroups) { + const initialItem = this._getDefaultOptionForGroup(optionGroup); + const initialState = { group: optionGroup, item: initialItem }; + + if (initialItem) { + this._updateOptionContextKey(optionGroup.id, initialItem.id); + } + + const emitter = this._getOrCreateOptionEmitter(optionGroup.id); + const itemDelegate: IChatSessionPickerDelegate = { + getCurrentOption: () => this._selectedOptions.get(optionGroup.id) ?? this._getDefaultOptionForGroup(optionGroup), + onDidChangeOption: emitter.event, + setOption: (option: IChatSessionProviderOptionItem) => { + this._selectedOptions.set(optionGroup.id, option); + this._updateOptionContextKey(optionGroup.id, option.id); + emitter.fire(option); + + if (this._pendingSessionResource) { + this.chatSessionsService.notifySessionOptionsChange( + this._pendingSessionResource, + [{ optionId: optionGroup.id, value: option }] + ).catch((err) => this.logService.error(`Failed to notify extension of ${optionGroup.id} change:`, err)); + } + + this._renderExtensionPickers(true); + }, + getOptionGroup: () => { + const groups = this.chatSessionsService.getOptionGroupsForSessionType(activeSessionType); + return groups?.find((g: { id: string }) => g.id === optionGroup.id); + }, + getSessionResource: () => this._pendingSessionResource, + }; + + const action = toAction({ id: optionGroup.id, label: optionGroup.name, run: () => { } }); + const widget = this.instantiationService.createInstance( + optionGroup.searchable ? SearchableOptionPickerActionItem : ChatSessionPickerActionItem, + action, initialState, itemDelegate + ); + + this._pickerWidgetDisposables.add(widget); + this._pickerWidgets.set(optionGroup.id, widget); + + // Repo/folder pickers go to the right; others go to the left + const isRightAligned = isRepoOrFolderGroup(optionGroup); + const targetContainer = isRightAligned ? this._extensionPickersRightContainer! : this._extensionPickersLeftContainer!; + + const slot = dom.append(targetContainer, dom.$('.sessions-chat-picker-slot')); + widget.render(slot); + } + } + + private _evaluateOptionGroupVisibility(optionGroup: { id: string; when?: string }): boolean { + if (!optionGroup.when) { + return true; + } + const expr = ContextKeyExpr.deserialize(optionGroup.when); + return !expr || this.contextKeyService.contextMatchesRules(expr); + } + + private _getDefaultOptionForGroup(optionGroup: IChatSessionProviderOptionGroup): IChatSessionProviderOptionItem | undefined { + return this._selectedOptions.get(optionGroup.id) ?? optionGroup.items.find((item) => item.default === true); + } + + private _syncOptionsFromSession(sessionResource: URI): void { + const activeSessionType = this._targetConfig.selectedTarget.get(); + if (!activeSessionType) { + return; + } + const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(activeSessionType); + if (!optionGroups) { + return; + } + for (const optionGroup of optionGroups) { + if (isModelOptionGroup(optionGroup)) { + continue; + } + const currentOption = this.chatSessionsService.getSessionOption(sessionResource, optionGroup.id); + if (!currentOption) { + continue; + } + let item: IChatSessionProviderOptionItem | undefined; + if (typeof currentOption === 'string') { + item = optionGroup.items.find((m: { id: string }) => m.id === currentOption.trim()); + } else { + item = currentOption; + } + if (item) { + const { locked: _locked, ...unlocked } = item; + this._selectedOptions.set(optionGroup.id, unlocked as IChatSessionProviderOptionItem); + this._updateOptionContextKey(optionGroup.id, item.id); + this._optionEmitters.get(optionGroup.id)?.fire(item); + } + } + } + + private _updateOptionContextKey(optionGroupId: string, optionItemId: string): void { + let contextKey = this._optionContextKeys.get(optionGroupId); + if (!contextKey) { + const rawKey = new RawContextKey(`chatSessionOption.${optionGroupId}`, ''); + contextKey = rawKey.bindTo(this.contextKeyService); + this._optionContextKeys.set(optionGroupId, contextKey); + } + contextKey.set(optionItemId.trim()); + } + + private _getOrCreateOptionEmitter(optionGroupId: string): Emitter { + let emitter = this._optionEmitters.get(optionGroupId); + if (!emitter) { + emitter = new Emitter(); + this._optionEmitters.set(optionGroupId, emitter); + this._pickerWidgetDisposables.add(emitter); + } + return emitter; + } + + private _disposePickerWidgets(): void { + this._pickerWidgetDisposables.clear(); + this._pickerWidgets.clear(); + this._optionEmitters.clear(); + } + + private _clearExtensionPickers(): void { + this._pickerWidgetDisposables.clear(); + this._pickerWidgets.clear(); + this._optionEmitters.clear(); + if (this._extensionPickersLeftContainer) { + dom.clearNode(this._extensionPickersLeftContainer); + } + if (this._extensionPickersRightContainer) { + dom.clearNode(this._extensionPickersRightContainer); + } + } + + // --- Send --- + + private _send(): void { + const query = this._editor.getModel()?.getValue().trim(); + if (!query) { + return; + } + + const target = this._targetConfig.selectedTarget.get(); + if (!target) { + this.logService.warn('ChatWelcomeWidget: No target selected, cannot create session'); + return; + } + + const position = this._options.sessionPosition ?? ChatSessionPosition.Sidebar; + const resource = this._pendingSessionResource + ?? getResourceForNewChatSession({ type: target, position, displayName: '' }); + + const contribution = target !== AgentSessionProviders.Local + ? this.chatSessionsService.getChatSessionContribution(target) + : undefined; + + const sendOptions: IChatSendRequestOptions = { + location: ChatAgentLocation.Chat, + userSelectedModelId: this._currentLanguageModel.get()?.identifier, + modeInfo: { + kind: ChatModeKind.Agent, + isBuiltin: true, + modeInstructions: undefined, + modeId: 'agent', + applyCodeBlockSuggestionId: undefined, + }, + agentIdSilent: contribution?.type, + }; + + this._options.onSendRequest?.({ + resource, + target, + query, + sendOptions, + selectedOptions: new Map(this._selectedOptions), + }); + } + + // --- Layout --- + + layout(_height: number, _width: number): void { + this._editor?.layout(); + } + + setVisible(_visible: boolean): void { + // no-op + } + + focusInput(): void { + this._editor?.focus(); + } +} + +// #endregion + +// #region --- New Chat View Pane --- + +export const SessionsViewId = 'workbench.view.sessions.chat'; + +/** + * A view pane that hosts the new-session welcome widget. + */ +export class NewChatViewPane extends ViewPane { + + private _widget: NewChatWidget | undefined; + + constructor( + options: IViewPaneOptions, + @IKeybindingService keybindingService: IKeybindingService, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IInstantiationService instantiationService: IInstantiationService, + @IOpenerService openerService: IOpenerService, + @IThemeService themeService: IThemeService, + @IHoverService hoverService: IHoverService, + @ISessionsWorkbenchService private readonly activeSessionService: ISessionsWorkbenchService, + @ILogService private readonly logService: ILogService, + ) { + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); + } + + protected override renderBody(container: HTMLElement): void { + super.renderBody(container); + + this._widget = this._register(this.instantiationService.createInstance( + NewChatWidget, + { + targetConfig: { + allowedTargets: [AgentSessionProviders.Background, AgentSessionProviders.Cloud], + defaultTarget: AgentSessionProviders.Background, + }, + onSendRequest: (data) => { + this.activeSessionService.openSessionAndSend( + data.resource, data.query, data.sendOptions, data.selectedOptions + ).catch(e => this.logService.error('NewChatViewPane: Failed to open session and send request', e)); + }, + } satisfies INewChatWidgetOptions, + )); + + this._widget.render(container); + this._widget.focusInput(); + } + + protected override layoutBody(height: number, width: number): void { + super.layoutBody(height, width); + this._widget?.layout(height, width); + } + + override focus(): void { + super.focus(); + this._widget?.focusInput(); + } + + override setVisible(visible: boolean): void { + super.setVisible(visible); + this._widget?.setVisible(visible); + } +} + +// #endregion + +/** + * Check whether an option group represents the model picker. + * The convention is `id: 'models'` but extensions may use different IDs + * per session type, so we also fall back to name matching. + */ +function isModelOptionGroup(group: IChatSessionProviderOptionGroup): boolean { + if (group.id === 'models') { + return true; + } + const nameLower = group.name.toLowerCase(); + return nameLower === 'model' || nameLower === 'models'; +} + +/** + * Check whether an option group represents a repository or folder picker. + * These are placed on the right side of the pickers row. + */ +function isRepoOrFolderGroup(group: IChatSessionProviderOptionGroup): boolean { + const idLower = group.id.toLowerCase(); + const nameLower = group.name.toLowerCase(); + return idLower === 'repositories' || idLower === 'folders' || + nameLower === 'repository' || nameLower === 'repositories' || + nameLower === 'folder' || nameLower === 'folders'; +} + +function getAgentSessionProviderName(provider: AgentSessionProviders): string { + switch (provider) { + case AgentSessionProviders.Local: + return localize('chat.session.providerLabel.local', "Local"); + case AgentSessionProviders.Background: + return localize('chat.session.providerLabel.background', "Worktree"); + case AgentSessionProviders.Cloud: + return localize('chat.session.providerLabel.cloud', "Cloud"); + case AgentSessionProviders.Claude: + return 'Claude'; + case AgentSessionProviders.Codex: + return 'Codex'; + case AgentSessionProviders.Growth: + return 'Growth'; + } +} diff --git a/src/vs/sessions/contrib/chat/browser/promptsService.ts b/src/vs/sessions/contrib/chat/browser/promptsService.ts new file mode 100644 index 0000000000000..754a3f995ee2e --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/promptsService.ts @@ -0,0 +1,93 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { PromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.js'; +import { PromptFilesLocator } from '../../../../workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.js'; +import { Event } from '../../../../base/common/event.js'; +import { basename, isEqualOrParent, joinPath } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../platform/workspace/common/workspace.js'; +import { HOOKS_SOURCE_FOLDER } from '../../../../workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.js'; +import { IWorkbenchEnvironmentService } from '../../../../workbench/services/environment/common/environmentService.js'; +import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; +import { ISearchService } from '../../../../workbench/services/search/common/search.js'; +import { IUserDataProfileService } from '../../../../workbench/services/userDataProfile/common/userDataProfile.js'; +import { ISessionsWorkbenchService } from '../../sessions/browser/sessionsWorkbenchService.js'; + +export class AgenticPromptsService extends PromptsService { + protected override createPromptFilesLocator(): PromptFilesLocator { + return this.instantiationService.createInstance(AgenticPromptFilesLocator); + } +} + +class AgenticPromptFilesLocator extends PromptFilesLocator { + + constructor( + @IFileService fileService: IFileService, + @IConfigurationService configService: IConfigurationService, + @IWorkspaceContextService workspaceService: IWorkspaceContextService, + @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, + @ISearchService searchService: ISearchService, + @IUserDataProfileService userDataService: IUserDataProfileService, + @ILogService logService: ILogService, + @IPathService pathService: IPathService, + @ISessionsWorkbenchService private readonly activeSessionService: ISessionsWorkbenchService, + ) { + super( + fileService, + configService, + workspaceService, + environmentService, + searchService, + userDataService, + logService, + pathService + ); + } + + protected override getWorkspaceFolders(): readonly IWorkspaceFolder[] { + const folder = this.getActiveWorkspaceFolder(); + return folder ? [folder] : []; + } + + protected override getWorkspaceFolder(resource: URI): IWorkspaceFolder | undefined { + const folder = this.getActiveWorkspaceFolder(); + if (!folder) { + return undefined; + } + return isEqualOrParent(resource, folder.uri) ? folder : undefined; + } + + protected override onDidChangeWorkspaceFolders(): Event { + return Event.fromObservableLight(this.activeSessionService.activeSession); + } + + public override async getHookSourceFolders(): Promise { + const configured = await super.getHookSourceFolders(); + if (configured.length > 0) { + return configured; + } + const folder = this.getActiveWorkspaceFolder(); + return folder ? [joinPath(folder.uri, HOOKS_SOURCE_FOLDER)] : []; + } + + private getActiveWorkspaceFolder(): IWorkspaceFolder | undefined { + const session = this.activeSessionService.getActiveSession(); + const root = session?.worktree ?? session?.repository; + if (!root) { + return undefined; + } + return { + uri: root, + name: basename(root), + index: 0, + toResource: relativePath => joinPath(root, relativePath), + }; + } +} + diff --git a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts new file mode 100644 index 0000000000000..e9368f846a56c --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts @@ -0,0 +1,207 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derived, IObservable, observableSignal } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { MenuId, registerAction2, Action2, MenuRegistry } from '../../../../platform/actions/common/actions.js'; +import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { TerminalLocation } from '../../../../platform/terminal/common/terminal.js'; +import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; +import { ISessionsWorkbenchService } from '../../sessions/browser/sessionsWorkbenchService.js'; +import { ITerminalService } from '../../../../workbench/contrib/terminal/browser/terminal.js'; +import { Menus } from '../../../browser/menus.js'; + + +// Storage keys +const STORAGE_KEY_DEFAULT_RUN_ACTION = 'workbench.agentSessions.defaultRunAction'; + +// Menu IDs - exported for use in auxiliary bar part +export const RunScriptDropdownMenuId = MenuId.for('AgentSessionsRunScriptDropdown'); + +// Action IDs +const RUN_SCRIPT_ACTION_ID = 'workbench.action.agentSessions.runScript'; +const CONFIGURE_DEFAULT_RUN_ACTION_ID = 'workbench.action.agentSessions.configureDefaultRunAction'; + +// Types for stored default action +interface IStoredRunAction { + readonly name: string; + readonly command: string; +} + +interface IRunScriptActionContext { + readonly storageKey: string; + readonly action: IStoredRunAction | undefined; + readonly cwd: URI; +} + +/** + * Workbench contribution that adds a split dropdown action to the auxiliary bar title + * for running a custom command. + */ +export class RunScriptContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.agentSessions.runScript'; + + private readonly _activeRunState: IObservable; + private readonly _updateSignal = observableSignal(this); + + constructor( + @IStorageService private readonly _storageService: IStorageService, + @ITerminalService private readonly _terminalService: ITerminalService, + @ISessionsWorkbenchService activeSessionService: ISessionsWorkbenchService, + @IQuickInputService private readonly _quickInputService: IQuickInputService, + ) { + super(); + + this._activeRunState = derived(this, reader => { + const activeSession = activeSessionService.activeSession.read(reader); + if (!activeSession || !activeSession.repository) { + return undefined; + } + + this._updateSignal.read(reader); + const storageKey = `${STORAGE_KEY_DEFAULT_RUN_ACTION}.${activeSession.repository.toString()}`; + const action = this._getStoredDefaultAction(storageKey); + + return { + storageKey, + action, + cwd: activeSession.worktree ?? activeSession.repository + }; + }); + + this._registerActions(); + } + + private _getStoredDefaultAction(storageKey: string): IStoredRunAction | undefined { + const stored = this._storageService.get(storageKey, StorageScope.WORKSPACE); + if (stored) { + try { + const parsed = JSON.parse(stored); + if (typeof parsed?.name === 'string' && typeof parsed?.command === 'string') { + return parsed; + } + } catch { + return undefined; + } + } + return undefined; + } + + private _setStoredDefaultAction(storageKey: string, action: IStoredRunAction): void { + this._storageService.store(storageKey, JSON.stringify(action), StorageScope.WORKSPACE, StorageTarget.MACHINE); + this._updateSignal.trigger(undefined); + } + + private _registerActions(): void { + const that = this; + + // Main play action + this._register(autorun(reader => { + const activeSession = this._activeRunState.read(reader); + if (!activeSession) { + return; + } + + const title = activeSession.action ? activeSession.action.name : localize('runScriptNoAction', "Run Script"); + const tooltip = activeSession.action ? + localize('runScriptTooltip', "Run '{0}' in terminal", activeSession.action.name) + : localize('runScriptTooltipNoAction', "Configure run action"); + + reader.store.add(registerAction2(class extends Action2 { + constructor() { + super({ + id: RUN_SCRIPT_ACTION_ID, + title: title, + tooltip: tooltip, + icon: Codicon.play, + category: localize2('agentSessions', 'Agent Sessions'), + menu: [{ + id: RunScriptDropdownMenuId, + group: 'navigation', + order: 0, + }] + }); + } + + async run(): Promise { + if (activeSession.action) { + await that._runScript(activeSession.cwd, activeSession.action); + } else { + // Open quick pick to configure run action + await that._showConfigureQuickPick(activeSession); + } + } + })); + + // Configure run action (shown in dropdown) + reader.store.add(registerAction2(class extends Action2 { + constructor() { + super({ + id: CONFIGURE_DEFAULT_RUN_ACTION_ID, + title: localize2('configureDefaultRunAction', "Configure Run Action..."), + category: localize2('agentSessions', 'Agent Sessions'), + icon: Codicon.play, + menu: [{ + id: RunScriptDropdownMenuId, + group: '0_configure', + order: 0 + }] + }); + } + + async run(): Promise { + await that._showConfigureQuickPick(activeSession); + } + })); + })); + } + + private async _showConfigureQuickPick(activeSession: IRunScriptActionContext): Promise { + + // Show input box for command + const command = await this._quickInputService.input({ + placeHolder: localize('enterCommandPlaceholder', "Enter command (e.g., npm run dev)"), + prompt: localize('enterCommandPrompt', "This command will be run in the integrated terminal") + }); + + if (command) { + const storedAction: IStoredRunAction = { + name: command, + command + }; + this._setStoredDefaultAction(activeSession.storageKey, storedAction); + await this._runScript(activeSession.cwd, storedAction); + } + } + + private async _runScript(cwd: URI, action: IStoredRunAction): Promise { + // Create a new terminal and run the command + const terminal = await this._terminalService.createTerminal({ + location: TerminalLocation.Panel, + config: { + name: action.name + }, + cwd + }); + + terminal.sendText(action.command, true); + await this._terminalService.revealTerminal(terminal); + } +} + +// Register the Run split button submenu on the workbench title bar +MenuRegistry.appendMenuItem(Menus.TitleBarRight, { + submenu: RunScriptDropdownMenuId, + isSplitButton: true, + title: localize2('run', "Run"), + icon: Codicon.play, + group: 'navigation', + order: 8, +}); diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts new file mode 100644 index 0000000000000..40dbca7917b74 --- /dev/null +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Extensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; + +Registry.as(Extensions.Configuration).registerDefaultConfigurations([{ + overrides: { + 'chat.agentsControl.clickBehavior': 'focus', + 'chat.agentsControl.enabled': true, + 'chat.agent.maxRequests': 1000, + 'chat.restoreLastPanelSession': true, + 'chat.unifiedAgentsBar.enabled': true, + 'chat.viewSessions.enabled': false, + + 'diffEditor.renderSideBySide': false, + 'diffEditor.hideUnchangedRegions.enabled': true, + + 'files.autoSave': 'afterDelay', + + 'git.showProgress': false, + + 'github.copilot.chat.claudeCode.enabled': true, + 'github.copilot.chat.languageContext.typescript.enabled': true, + + 'inlineChat.affordance': 'editor', + 'inlineChat.renderMode': 'hover', + + 'workbench.editor.restoreEditors': false, + 'workbench.editor.showTabs': 'single', + 'workbench.startupEditor': 'none', + 'workbench.tips.enabled': false, + 'workbench.layoutControl.type': 'toggles', + 'workbench.editor.allowOpenInModalEditor': false + }, + donotCache: true +}]); diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css new file mode 100644 index 0000000000000..f8b2715466dbc --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Container - button style hover */ +.command-center .agent-sessions-titlebar-container { + display: flex; + width: 38vw; + max-width: 600px; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: center; + padding: 0 10px; + height: 22px; + border-radius: 4px; + cursor: pointer; + -webkit-app-region: no-drag; + overflow: hidden; + color: var(--vscode-commandCenter-foreground); + gap: 6px; +} + +.command-center .agent-sessions-titlebar-container:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.command-center .agent-sessions-titlebar-container:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +/* Center group: icon + label + folder + changes */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-center { + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; + justify-content: center; +} + +/* Kind icon */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-icon { + display: flex; + align-items: center; + flex-shrink: 0; + font-size: 14px; +} + +/* Label (title) */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Repository/folder label */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-repo { + display: flex; + align-items: center; + flex-shrink: 0; + opacity: 0.7; +} + +/* Dot separator */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-separator { + opacity: 0.5; + flex-shrink: 0; +} + +/* Changes container */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-changes { + display: flex; + align-items: center; + gap: 3px; + cursor: pointer; + padding: 0 4px; + border-radius: 3px; +} + +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-changes:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +/* Changes icon */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-changes-icon { + display: flex; + align-items: center; + flex-shrink: 0; + font-size: 14px; +} + +/* Insertions */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-added { + color: var(--vscode-gitDecoration-addedResourceForeground); +} + +/* Deletions */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-removed { + color: var(--vscode-gitDecoration-deletedResourceForeground); +} diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css new file mode 100644 index 0000000000000..4b37cbc4323ab --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css @@ -0,0 +1,215 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.agent-sessions-viewpane { + + .agent-sessions-container { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + } + + /* Section headers - more prominent than time-based groupings */ + .ai-customization-header, + .agent-sessions-header { + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + color: var(--vscode-sideBarSectionHeader-foreground, var(--vscode-foreground)); + padding: 6px 20px 6px 12px; + letter-spacing: 0.05em; + } + + /* Customization header - clickable for collapse */ + .ai-customization-header { + display: flex; + align-items: center; + cursor: pointer; + user-select: none; + margin: 0 6px; + border-radius: 6px; + } + + .ai-customization-header:hover { + background-color: var(--vscode-list-hoverBackground); + } + + .ai-customization-header:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + } + + .ai-customization-chevron, + .agent-sessions-chevron { + flex-shrink: 0; + margin-left: auto; + padding-right: 4px; + opacity: 0; + transition: opacity 0.1s ease-in-out; + } + + .ai-customization-header:hover .ai-customization-chevron, + .ai-customization-header:focus .ai-customization-chevron, + .agent-sessions-header:hover .agent-sessions-chevron, + .agent-sessions-header:focus .agent-sessions-chevron { + opacity: 0.7; + } + + /* AI Customization section - pinned to bottom */ + .ai-customization-shortcuts { + display: flex; + flex-direction: column; + flex-shrink: 0; + border-top: 1px solid var(--vscode-widget-border); + margin-top: 8px; + padding-top: 4px; + padding-bottom: 8px; + } + + .ai-customization-shortcuts .ai-customization-links { + display: flex; + flex-direction: column; + max-height: 500px; + overflow: hidden; + transition: max-height 0.2s ease-out; + } + + .ai-customization-shortcuts .ai-customization-links.collapsed { + max-height: 0; + } + + .ai-customization-shortcuts .ai-customization-link { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: var(--vscode-foreground); + cursor: pointer; + text-decoration: none; + padding: 6px 14px; + margin: 0 6px; + line-height: 22px; + border-radius: 6px; + } + + .ai-customization-shortcuts .ai-customization-link:hover { + background-color: var(--vscode-list-hoverBackground); + } + + .ai-customization-shortcuts .ai-customization-link:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + } + + .ai-customization-shortcuts .ai-customization-link .link-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.85; + } + + .ai-customization-shortcuts .ai-customization-link .link-label { + flex: 1; + } + + .ai-customization-shortcuts .ai-customization-link .link-counts { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 6px; + margin-left: auto; + } + + .ai-customization-shortcuts .ai-customization-link .link-counts.hidden { + display: none; + } + + .ai-customization-shortcuts .ai-customization-link .source-count-badge { + display: flex; + align-items: center; + gap: 2px; + } + + .ai-customization-shortcuts .ai-customization-link .source-count-icon { + font-size: 12px; + opacity: 0.6; + } + + .ai-customization-shortcuts .ai-customization-link .source-count-num { + font-size: 11px; + color: var(--vscode-descriptionForeground); + opacity: 0.8; + } + + /* Sessions section - fills remaining space above customizations */ + .agent-sessions-section { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + min-height: 0; + } + + .agent-sessions-header { + display: flex; + align-items: center; + gap: 4px; + padding-top: 10px; + padding-right: 12px; + user-select: none; + } + + .agent-sessions-header .agent-sessions-header-toolbar { + margin-left: auto; + } + + .agent-sessions-header:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + } + + .agent-sessions-content { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + } + + .agent-sessions-content.collapsed { + display: none; + } + + .agent-sessions-new-button-container { + padding: 6px 12px 8px 12px; + } + + .agent-sessions-new-button-container .monaco-button { + position: relative; + display: flex; + align-items: center; + justify-content: center; + } + + .agent-sessions-new-button-container .monaco-button .new-session-keybinding-hint { + position: absolute; + right: 10px; + font-size: 11px; + opacity: 0.5; + } + + .agent-sessions-control-container { + flex: 1; + overflow: hidden; + + /* Override section header padding to align with dot indicators */ + .agent-session-section { + padding-left: 12px; + } + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts new file mode 100644 index 0000000000000..c5211ee8e27a6 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { IViewDescriptor, IViewsRegistry, Extensions as ViewContainerExtensions, WindowVisibility, ViewContainer, IViewContainersRegistry, ViewContainerLocation } from '../../../../workbench/common/views.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; +import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; +import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { SessionsTitleBarContribution } from './sessionsTitleBarWidget.js'; +import { AgenticSessionsViewPane, SessionsViewId } from './sessionsViewPane.js'; +import { SessionsWorkbenchService, ISessionsWorkbenchService } from './sessionsWorkbenchService.js'; + +const agentSessionsViewIcon = registerIcon('chat-sessions-icon', Codicon.commentDiscussionSparkle, localize('agentSessionsViewIcon', 'Icon for Agent Sessions View')); +const AGENT_SESSIONS_VIEW_TITLE = localize2('agentSessions.view.label', "Sessions"); +const SessionsContainerId = 'agentic.workbench.view.sessionsContainer'; + +const agentSessionsViewContainer: ViewContainer = Registry.as(ViewContainerExtensions.ViewContainersRegistry).registerViewContainer({ + id: SessionsContainerId, + title: AGENT_SESSIONS_VIEW_TITLE, + icon: agentSessionsViewIcon, + ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [SessionsContainerId, { mergeViewWithContainerWhenSingleView: true, }]), + storageId: SessionsContainerId, + hideIfEmpty: true, + order: 6, + windowVisibility: WindowVisibility.Sessions +}, ViewContainerLocation.Sidebar, { isDefault: true }); + +const agentSessionsViewDescriptor: IViewDescriptor = { + id: SessionsViewId, + containerIcon: agentSessionsViewIcon, + containerTitle: AGENT_SESSIONS_VIEW_TITLE.value, + singleViewPaneContainerTitle: AGENT_SESSIONS_VIEW_TITLE.value, + name: AGENT_SESSIONS_VIEW_TITLE, + canToggleVisibility: false, + canMoveView: false, + ctorDescriptor: new SyncDescriptor(AgenticSessionsViewPane), + windowVisibility: WindowVisibility.Sessions +}; + +Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews([agentSessionsViewDescriptor], agentSessionsViewContainer); + +registerWorkbenchContribution2(SessionsTitleBarContribution.ID, SessionsTitleBarContribution, WorkbenchPhase.AfterRestored); + +registerSingleton(ISessionsWorkbenchService, SessionsWorkbenchService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts new file mode 100644 index 0000000000000..5d271a457ed33 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -0,0 +1,400 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/sessionsTitleBarWidget.css'; +import { $, addDisposableListener, EventType, reset } from '../../../../base/browser/dom.js'; + +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { MenuRegistry, SubmenuItemAction } from '../../../../platform/actions/common/actions.js'; +import { Menus } from '../../../browser/menus.js'; +import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { ISessionsWorkbenchService } from './sessionsWorkbenchService.js'; +import { FocusAgentSessionsAction } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsActions.js'; +import { AgentSessionsPicker } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { getAgentChangesSummary, hasValidDiff, IAgentSession, isAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { getAgentSessionProvider, getAgentSessionProviderIcon } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { basename } from '../../../../base/common/resources.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ViewAllSessionChangesAction } from '../../../../workbench/contrib/chat/browser/chatEditing/chatEditingActions.js'; + +/** + * Sessions Title Bar Widget - renders the active chat session title + * in the command center of the agent sessions workbench. + * + * Shows the current chat session label as a clickable pill with: + * - Kind icon at the beginning (provider type icon) + * - Session title + * - Repository folder name + * - Changes summary (+insertions -deletions) + * + * On click, opens the sessions picker. + */ +export class SessionsTitleBarWidget extends BaseActionViewItem { + + private _container: HTMLElement | undefined; + private readonly _dynamicDisposables = this._register(new DisposableStore()); + private readonly _modelChangeListener = this._register(new MutableDisposable()); + + /** Cached render state to avoid unnecessary DOM rebuilds */ + private _lastRenderState: string | undefined; + + /** Guard to prevent re-entrant rendering */ + private _isRendering = false; + + constructor( + action: SubmenuItemAction, + options: IBaseActionViewItemOptions | undefined, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IHoverService private readonly hoverService: IHoverService, + @ISessionsWorkbenchService private readonly activeSessionService: ISessionsWorkbenchService, + @IChatService private readonly chatService: IChatService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @ICommandService private readonly commandService: ICommandService, + ) { + super(undefined, action, options); + + // Re-render when the active session changes + this._register(autorun(reader => { + const activeSession = this.activeSessionService.activeSession.read(reader); + this._trackModelTitleChanges(activeSession?.resource); + this._lastRenderState = undefined; + this._render(); + })); + + // Re-render when sessions data changes (e.g., changes info updated) + this._register(this.agentSessionsService.model.onDidChangeSessions(() => { + this._lastRenderState = undefined; + this._render(); + })); + } + + override render(container: HTMLElement): void { + super.render(container); + + this._container = container; + container.classList.add('agent-sessions-titlebar-container'); + + // Initial render + this._render(); + } + + override setFocusable(_focusable: boolean): void { + // Don't set focusable on the container + } + + // Override onClick to prevent the base class from running the underlying + // submenu action when the widget handles clicks itself. + override onClick(): void { + // No-op: click handling is done by the pill handler + } + + private _render(): void { + if (!this._container) { + return; + } + + if (this._isRendering) { + return; + } + this._isRendering = true; + + try { + const label = this._getActiveSessionLabel(); + const icon = this._getActiveSessionIcon(); + const repoLabel = this._getRepositoryLabel(); + const changes = this._getChanges(); + + // Build a render-state key from all displayed data + const renderState = `${icon?.id ?? ''}|${label}|${repoLabel ?? ''}|${changes?.insertions ?? ''}|${changes?.deletions ?? ''}`; + + // Skip re-render if state hasn't changed + if (this._lastRenderState === renderState) { + return; + } + this._lastRenderState = renderState; + + // Clear existing content + reset(this._container); + this._dynamicDisposables.clear(); + + // Set up container as the button directly + this._container.setAttribute('role', 'button'); + this._container.setAttribute('aria-label', localize('agentSessionsShowSessions', "Show Sessions")); + this._container.tabIndex = 0; + + // Center group: icon + label + folder + changes together + const centerGroup = $('span.agent-sessions-titlebar-center'); + + // Kind icon at the beginning + if (icon) { + const iconEl = $('span.agent-sessions-titlebar-icon' + ThemeIcon.asCSSSelector(icon)); + centerGroup.appendChild(iconEl); + } + + // Label + const labelEl = $('span.agent-sessions-titlebar-label'); + labelEl.textContent = label; + centerGroup.appendChild(labelEl); + + // Folder and changes shown next to the title + if (repoLabel || changes) { + if (repoLabel) { + const separator1 = $('span.agent-sessions-titlebar-separator'); + separator1.textContent = '\u00B7'; + centerGroup.appendChild(separator1); + + const repoEl = $('span.agent-sessions-titlebar-repo'); + repoEl.textContent = repoLabel; + centerGroup.appendChild(repoEl); + } + + if (changes) { + const separator2 = $('span.agent-sessions-titlebar-separator'); + separator2.textContent = '\u00B7'; + centerGroup.appendChild(separator2); + + const changesEl = $('span.agent-sessions-titlebar-changes'); + + // Diff icon + const changesIconEl = $('span.agent-sessions-titlebar-changes-icon' + ThemeIcon.asCSSSelector(Codicon.diffMultiple)); + changesEl.appendChild(changesIconEl); + + const addedEl = $('span.agent-sessions-titlebar-added'); + addedEl.textContent = `+${changes.insertions}`; + changesEl.appendChild(addedEl); + + const removedEl = $('span.agent-sessions-titlebar-removed'); + removedEl.textContent = `-${changes.deletions}`; + changesEl.appendChild(removedEl); + + centerGroup.appendChild(changesEl); + + // Separate hover for changes + this._dynamicDisposables.add(this.hoverService.setupManagedHover( + getDefaultHoverDelegate('mouse'), + changesEl, + localize('agentSessions.viewChanges', "View All Changes") + )); + + // Click on changes opens multi-diff editor + this._dynamicDisposables.add(addDisposableListener(changesEl, EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this._openChanges(); + })); + } + } + + this._container.appendChild(centerGroup); + + // Hover + this._dynamicDisposables.add(this.hoverService.setupManagedHover( + getDefaultHoverDelegate('mouse'), + this._container, + label + )); + + // Click handler - show sessions picker + this._dynamicDisposables.add(addDisposableListener(this._container, EventType.MOUSE_DOWN, (e) => { + e.preventDefault(); + e.stopPropagation(); + })); + this._dynamicDisposables.add(addDisposableListener(this._container, EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this._showSessionsPicker(); + })); + + // Keyboard handler + this._dynamicDisposables.add(addDisposableListener(this._container, EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this._showSessionsPicker(); + } + })); + } finally { + this._isRendering = false; + } + } + + /** + * Track title changes on the chat model for the given session resource. + * When the model title changes, re-render the widget. + */ + private _trackModelTitleChanges(sessionResource: URI | undefined): void { + this._modelChangeListener.clear(); + + if (!sessionResource) { + return; + } + + const model = this.chatService.getSession(sessionResource); + if (!model) { + return; + } + + this._modelChangeListener.value = model.onDidChange(e => { + if (e.kind === 'setCustomTitle' || e.kind === 'addRequest') { + this._lastRenderState = undefined; + this._render(); + } + }); + } + + /** + * Get the label of the active chat session. + * Prefers the live model title over the snapshot label from the active session service. + * Falls back to a generic label if no active session is found. + */ + private _getActiveSessionLabel(): string { + const activeSession = this.activeSessionService.getActiveSession(); + if (activeSession?.resource) { + const model = this.chatService.getSession(activeSession.resource); + if (model?.title) { + return model.title; + } + } + + if (activeSession?.label) { + return activeSession.label; + } + + return localize('agentSessions.newSession', "New Session"); + } + + /** + * Get the icon for the active session's kind/provider. + */ + private _getActiveSessionIcon(): ThemeIcon | undefined { + const activeSession = this.activeSessionService.getActiveSession(); + if (!activeSession) { + return undefined; + } + + // Try to get icon from the agent session model (has provider-resolved icon) + const agentSession = this.agentSessionsService.getSession(activeSession.resource); + if (agentSession) { + return agentSession.icon; + } + + // Fall back to provider icon from the resource + const provider = getAgentSessionProvider(activeSession.resource); + if (provider !== undefined) { + return getAgentSessionProviderIcon(provider); + } + + return undefined; + } + + /** + * Get the repository label for the active session. + */ + private _getRepositoryLabel(): string | undefined { + const activeSession = this.activeSessionService.getActiveSession(); + if (!activeSession) { + return undefined; + } + + const uri = activeSession.repository; + if (!uri) { + return undefined; + } + + return basename(uri); + } + + /** + * Get the changes summary (insertions/deletions) for the active session. + */ + private _getChanges(): { insertions: number; deletions: number } | undefined { + const activeSession = this.activeSessionService.getActiveSession(); + if (!activeSession) { + return undefined; + } + + let changes: IAgentSession['changes'] | undefined; + + if (isAgentSession(activeSession)) { + changes = activeSession.changes; + } else { + const agentSession = this.agentSessionsService.getSession(activeSession.resource); + changes = agentSession?.changes; + } + + if (!changes || !hasValidDiff(changes)) { + return undefined; + } + + return getAgentChangesSummary(changes) ?? undefined; + } + + private _showSessionsPicker(): void { + const picker = this.instantiationService.createInstance(AgentSessionsPicker, undefined); + picker.pickAgentSession(); + } + + private _openChanges(): void { + const activeSession = this.activeSessionService.getActiveSession(); + if (!activeSession) { + return; + } + + this.commandService.executeCommand(ViewAllSessionChangesAction.ID, activeSession.resource); + } +} + +/** + * Provides custom rendering for the sessions title bar widget + * in the command center. Uses IActionViewItemService to render a custom widget + * for the TitleBarControlMenu submenu. + */ +export class SessionsTitleBarContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.agentSessionsTitleBar'; + + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + // Register the submenu item in the Agent Sessions command center + this._register(MenuRegistry.appendMenuItem(Menus.CommandCenter, { + submenu: Menus.TitleBarControlMenu, + title: localize('agentSessionsControl', "Agent Sessions"), + order: 101, + })); + + // Register a placeholder action so the submenu appears + this._register(MenuRegistry.appendMenuItem(Menus.TitleBarControlMenu, { + command: { + id: FocusAgentSessionsAction.id, + title: localize('showSessions', "Show Sessions"), + }, + group: 'a_sessions', + order: 1 + })); + + this._register(actionViewItemService.register(Menus.CommandCenter, Menus.TitleBarControlMenu, (action, options) => { + if (!(action instanceof SubmenuItemAction)) { + return undefined; + } + return instantiationService.createInstance(SessionsTitleBarWidget, action, options); + }, undefined)); + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts new file mode 100644 index 0000000000000..63d62b4fa9d3a --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -0,0 +1,488 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/sessionsViewPane.css'; +import * as DOM from '../../../../base/browser/dom.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; +import { IViewDescriptorService, ViewContainerLocation } from '../../../../workbench/common/views.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { AgentSessionsControl } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsControl.js'; +import { AgentSessionsFilter, AgentSessionsGrouping } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.js'; +import { ISessionsWorkbenchService } from './sessionsWorkbenchService.js'; +import { Action2, ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; +import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { ACTION_ID_NEW_CHAT } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; +import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { AICustomizationManagementEditor } from '../../aiCustomizationManagement/browser/aiCustomizationManagementEditor.js'; +import { AICustomizationManagementSection } from '../../aiCustomizationManagement/browser/aiCustomizationManagement.js'; +import { AICustomizationManagementEditorInput } from '../../aiCustomizationManagement/browser/aiCustomizationManagementEditorInput.js'; +import { IPromptsService, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; +import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, workspaceIcon, userIcon, extensionIcon } from '../../aiCustomizationTreeView/browser/aiCustomizationTreeViewIcons.js'; + +const $ = DOM.$; +export const SessionsViewId = 'agentic.workbench.view.sessionsView'; +const SessionsViewFilterSubMenu = new MenuId('AgentSessionsViewFilterSubMenu'); + +/** + * Per-source breakdown of item counts. + */ +interface ISourceCounts { + readonly workspace: number; + readonly user: number; + readonly extension: number; +} + +interface IShortcutItem { + readonly label: string; + readonly icon: ThemeIcon; + readonly action: () => Promise; + readonly getSourceCounts?: () => Promise; + /** For items without per-source breakdown (MCP, Models). */ + readonly getCount?: () => Promise; + countContainer?: HTMLElement; +} + +const CUSTOMIZATIONS_COLLAPSED_KEY = 'agentSessions.customizationsCollapsed'; + +export class AgenticSessionsViewPane extends ViewPane { + + private viewPaneContainer: HTMLElement | undefined; + private newSessionButtonContainer: HTMLElement | undefined; + private sessionsControlContainer: HTMLElement | undefined; + sessionsControl: AgentSessionsControl | undefined; + private aiCustomizationContainer: HTMLElement | undefined; + private readonly shortcuts: IShortcutItem[] = []; + + constructor( + options: IViewPaneOptions, + @IKeybindingService keybindingService: IKeybindingService, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IInstantiationService instantiationService: IInstantiationService, + @IOpenerService openerService: IOpenerService, + @IThemeService themeService: IThemeService, + @IHoverService hoverService: IHoverService, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @ICommandService commandService: ICommandService, + @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + @IPromptsService private readonly promptsService: IPromptsService, + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + @IMcpService private readonly mcpService: IMcpService, + @IStorageService private readonly storageService: IStorageService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @ISessionsWorkbenchService private readonly activeSessionService: ISessionsWorkbenchService, + ) { + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); + + // Initialize shortcuts + this.shortcuts = [ + { label: localize('agents', "Agents"), icon: agentIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Agents), getSourceCounts: () => this.getPromptSourceCounts(PromptsType.agent) }, + { label: localize('skills', "Skills"), icon: skillIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Skills), getSourceCounts: () => this.getSkillSourceCounts() }, + { label: localize('instructions', "Instructions"), icon: instructionsIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Instructions), getSourceCounts: () => this.getPromptSourceCounts(PromptsType.instructions) }, + { label: localize('prompts', "Prompts"), icon: promptIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Prompts), getSourceCounts: () => this.getPromptSourceCounts(PromptsType.prompt) }, + { label: localize('hooks', "Hooks"), icon: hookIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Hooks), getSourceCounts: () => this.getPromptSourceCounts(PromptsType.hook) }, + { label: localize('mcpServers', "MCP Servers"), icon: Codicon.server, action: () => this.openAICustomizationSection(AICustomizationManagementSection.McpServers), getCount: () => Promise.resolve(this.mcpService.servers.get().length) }, + { label: localize('models', "Models"), icon: Codicon.vm, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Models), getCount: () => Promise.resolve(this.languageModelsService.getLanguageModelIds().length) }, + ]; + + // Listen to changes to update counts + this._register(this.promptsService.onDidChangeCustomAgents(() => this.updateCounts())); + this._register(this.promptsService.onDidChangeSlashCommands(() => this.updateCounts())); + this._register(this.languageModelsService.onDidChangeLanguageModels(() => this.updateCounts())); + this._register(autorun(reader => { + this.mcpService.servers.read(reader); + this.updateCounts(); + })); + + // Listen to workspace folder changes to update counts + this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => this.updateCounts())); + this._register(autorun(reader => { + this.activeSessionService.activeSession.read(reader); + this.updateCounts(); + })); + + } + + protected override renderBody(parent: HTMLElement): void { + super.renderBody(parent); + + this.viewPaneContainer = parent; + this.viewPaneContainer.classList.add('agent-sessions-viewpane'); + + this.createControls(parent); + } + + private createControls(parent: HTMLElement): void { + const sessionsContainer = DOM.append(parent, $('.agent-sessions-container')); + + // Sessions Filter (actions go to view title bar via menu registration) + const sessionsFilter = this._register(this.instantiationService.createInstance(AgentSessionsFilter, { + filterMenuId: SessionsViewFilterSubMenu, + groupResults: () => AgentSessionsGrouping.Date + })); + + // Sessions section (top, fills available space) + const sessionsSection = DOM.append(sessionsContainer, $('.agent-sessions-section')); + + // Sessions content container + const sessionsContent = DOM.append(sessionsSection, $('.agent-sessions-content')); + + // New Session Button + const newSessionButtonContainer = this.newSessionButtonContainer = DOM.append(sessionsContent, $('.agent-sessions-new-button-container')); + const newSessionButton = this._register(new Button(newSessionButtonContainer, { ...defaultButtonStyles, secondary: true })); + newSessionButton.label = localize('newSession', "New Session"); + this._register(newSessionButton.onDidClick(() => this.activeSessionService.openNewSession())); + + // Keybinding hint inside the button + const keybinding = this.keybindingService.lookupKeybinding(ACTION_ID_NEW_CHAT); + if (keybinding) { + const keybindingHint = DOM.append(newSessionButton.element, $('span.new-session-keybinding-hint')); + keybindingHint.textContent = keybinding.getLabel() ?? ''; + } + + // Sessions Control + this.sessionsControlContainer = DOM.append(sessionsContent, $('.agent-sessions-control-container')); + const sessionsControl = this.sessionsControl = this._register(this.instantiationService.createInstance(AgentSessionsControl, this.sessionsControlContainer, { + source: 'agentSessionsViewPane', + filter: sessionsFilter, + overrideStyles: this.getLocationBasedColors().listOverrideStyles, + getHoverPosition: () => this.getSessionHoverPosition(), + trackActiveEditorSession: () => true, + collapseOlderSections: () => true, + overrideSessionOpen: (resource, openOptions) => this.activeSessionService.openSession(resource, openOptions), + })); + this._register(this.onDidChangeBodyVisibility(visible => sessionsControl.setVisible(visible))); + + // Listen to tree updates and restore selection if nothing is selected + this._register(sessionsControl.onDidUpdate(() => { + if (!sessionsControl.hasFocusOrSelection()) { + this.restoreLastSelectedSession(); + } + })); + + // When the active session changes, select it in the tree + this._register(autorun(reader => { + const activeSession = this.activeSessionService.activeSession.read(reader); + if (activeSession) { + if (!sessionsControl.reveal(activeSession.resource)) { + sessionsControl.clearFocus(); + } + } + })); + + // AI Customization shortcuts (bottom, fixed height) + this.aiCustomizationContainer = DOM.append(sessionsContainer, $('.ai-customization-shortcuts')); + this.createAICustomizationShortcuts(this.aiCustomizationContainer); + } + + private restoreLastSelectedSession(): void { + const activeSession = this.activeSessionService.getActiveSession(); + if (activeSession && this.sessionsControl) { + this.sessionsControl.reveal(activeSession.resource); + } + } + + private createAICustomizationShortcuts(container: HTMLElement): void { + // Get initial collapsed state + const isCollapsed = this.storageService.getBoolean(CUSTOMIZATIONS_COLLAPSED_KEY, StorageScope.PROFILE, false); + + // Header (clickable to toggle) + const header = DOM.append(container, $('.ai-customization-header')); + header.tabIndex = 0; + header.setAttribute('role', 'button'); + header.setAttribute('aria-expanded', String(!isCollapsed)); + + // Header text + const headerText = DOM.append(header, $('span')); + headerText.textContent = localize('customizations', "CUSTOMIZATIONS"); + + // Chevron icon (right-aligned, shown on hover) + const chevron = DOM.append(header, $('.ai-customization-chevron')); + chevron.classList.add(...ThemeIcon.asClassNameArray(isCollapsed ? Codicon.chevronRight : Codicon.chevronDown)); + + // Links container + const linksContainer = DOM.append(container, $('.ai-customization-links')); + if (isCollapsed) { + linksContainer.classList.add('collapsed'); + } + + // Toggle collapse on header click + const toggleCollapse = () => { + const collapsed = linksContainer.classList.toggle('collapsed'); + this.storageService.store(CUSTOMIZATIONS_COLLAPSED_KEY, collapsed, StorageScope.PROFILE, StorageTarget.USER); + header.setAttribute('aria-expanded', String(!collapsed)); + chevron.classList.remove(...ThemeIcon.asClassNameArray(Codicon.chevronRight), ...ThemeIcon.asClassNameArray(Codicon.chevronDown)); + chevron.classList.add(...ThemeIcon.asClassNameArray(collapsed ? Codicon.chevronRight : Codicon.chevronDown)); + + // Re-layout after the transition so sessions control gets the right height + const onTransitionEnd = () => { + linksContainer.removeEventListener('transitionend', onTransitionEnd); + if (this.viewPaneContainer) { + const { offsetHeight, offsetWidth } = this.viewPaneContainer; + this.layoutBody(offsetHeight, offsetWidth); + } + }; + linksContainer.addEventListener('transitionend', onTransitionEnd); + }; + + this._register(DOM.addDisposableListener(header, 'click', toggleCollapse)); + this._register(DOM.addDisposableListener(header, 'keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleCollapse(); + } + })); + + for (const shortcut of this.shortcuts) { + const link = DOM.append(linksContainer, $('a.ai-customization-link')); + link.tabIndex = 0; + link.setAttribute('role', 'button'); + link.setAttribute('aria-label', shortcut.label); + + // Icon + const iconElement = DOM.append(link, $('.link-icon')); + iconElement.classList.add(...ThemeIcon.asClassNameArray(shortcut.icon)); + + // Label + const labelElement = DOM.append(link, $('.link-label')); + labelElement.textContent = shortcut.label; + + // Count container (right-aligned, shows per-source badges) + const countContainer = DOM.append(link, $('.link-counts')); + shortcut.countContainer = countContainer; + + this._register(DOM.addDisposableListener(link, 'click', (e) => { + DOM.EventHelper.stop(e); + shortcut.action(); + })); + + this._register(DOM.addDisposableListener(link, 'keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + shortcut.action(); + } + })); + } + + // Load initial counts + this.updateCounts(); + } + + private async updateCounts(): Promise { + for (const shortcut of this.shortcuts) { + if (!shortcut.countContainer) { + continue; + } + + if (shortcut.getSourceCounts) { + const counts = await shortcut.getSourceCounts(); + this.renderSourceCounts(shortcut.countContainer, counts); + } else if (shortcut.getCount) { + const count = await shortcut.getCount(); + this.renderSimpleCount(shortcut.countContainer, count); + } + } + } + + private renderSourceCounts(container: HTMLElement, counts: ISourceCounts): void { + DOM.clearNode(container); + const total = counts.workspace + counts.user + counts.extension; + container.classList.toggle('hidden', total === 0); + if (total === 0) { + return; + } + + const sources: { count: number; icon: ThemeIcon; title: string }[] = [ + { count: counts.workspace, icon: workspaceIcon, title: localize('workspaceCount', "{0} from workspace", counts.workspace) }, + { count: counts.user, icon: userIcon, title: localize('userCount', "{0} from user", counts.user) }, + { count: counts.extension, icon: extensionIcon, title: localize('extensionCount', "{0} from extensions", counts.extension) }, + ]; + + for (const source of sources) { + if (source.count === 0) { + continue; + } + const badge = DOM.append(container, $('.source-count-badge')); + badge.title = source.title; + const icon = DOM.append(badge, $('.source-count-icon')); + icon.classList.add(...ThemeIcon.asClassNameArray(source.icon)); + const num = DOM.append(badge, $('.source-count-num')); + num.textContent = `${source.count}`; + } + } + + private renderSimpleCount(container: HTMLElement, count: number): void { + DOM.clearNode(container); + container.classList.toggle('hidden', count === 0); + if (count > 0) { + const badge = DOM.append(container, $('.source-count-badge')); + const num = DOM.append(badge, $('.source-count-num')); + num.textContent = `${count}`; + } + } + + private async getPromptSourceCounts(promptType: PromptsType): Promise { + const [workspaceItems, userItems, extensionItems] = await Promise.all([ + this.promptsService.listPromptFilesForStorage(promptType, PromptsStorage.local, CancellationToken.None), + this.promptsService.listPromptFilesForStorage(promptType, PromptsStorage.user, CancellationToken.None), + this.promptsService.listPromptFilesForStorage(promptType, PromptsStorage.extension, CancellationToken.None), + ]); + + return { + workspace: workspaceItems.length, + user: userItems.length, + extension: extensionItems.length, + }; + } + + private async getSkillSourceCounts(): Promise { + const skills = await this.promptsService.findAgentSkills(CancellationToken.None); + if (!skills || skills.length === 0) { + return { workspace: 0, user: 0, extension: 0 }; + } + + const workspaceSkills = skills.filter(s => s.storage === PromptsStorage.local); + + return { + workspace: workspaceSkills.length, + user: skills.filter(s => s.storage === PromptsStorage.user).length, + extension: skills.filter(s => s.storage === PromptsStorage.extension).length, + }; + } + + private async openAICustomizationSection(sectionId: AICustomizationManagementSection): Promise { + const input = AICustomizationManagementEditorInput.getOrCreate(); + const editor = await this.editorGroupsService.activeGroup.openEditor(input, { pinned: true }); + + if (editor instanceof AICustomizationManagementEditor) { + editor.selectSectionById(sectionId); + } + } + + private getSessionHoverPosition(): HoverPosition { + const viewLocation = this.viewDescriptorService.getViewLocationById(this.id); + const sideBarPosition = this.layoutService.getSideBarPosition(); + + return { + [ViewContainerLocation.Sidebar]: sideBarPosition === 0 ? HoverPosition.RIGHT : HoverPosition.LEFT, + [ViewContainerLocation.AuxiliaryBar]: sideBarPosition === 0 ? HoverPosition.LEFT : HoverPosition.RIGHT, + [ViewContainerLocation.ChatBar]: HoverPosition.RIGHT, + [ViewContainerLocation.Panel]: HoverPosition.ABOVE + }[viewLocation ?? ViewContainerLocation.AuxiliaryBar]; + } + + protected override layoutBody(height: number, width: number): void { + super.layoutBody(height, width); + + if (!this.sessionsControl || !this.newSessionButtonContainer) { + return; + } + + const buttonHeight = this.newSessionButtonContainer.offsetHeight; + const customizationHeight = this.aiCustomizationContainer?.offsetHeight || 0; + const availableSessionsHeight = height - buttonHeight - customizationHeight; + this.sessionsControl.layout(availableSessionsHeight, width); + } + + override focus(): void { + super.focus(); + + this.sessionsControl?.focus(); + } + + refresh(): void { + this.sessionsControl?.refresh(); + } + + openFind(): void { + this.sessionsControl?.openFind(); + } +} + +// Register Cmd+N / Ctrl+N keybinding for new session in the agent sessions window +KeybindingsRegistry.registerKeybindingRule({ + id: ACTION_ID_NEW_CHAT, + weight: KeybindingWeight.WorkbenchContrib + 1, + primary: KeyMod.CtrlCmd | KeyCode.KeyN, +}); + +MenuRegistry.appendMenuItem(MenuId.ViewTitle, { + submenu: SessionsViewFilterSubMenu, + title: localize2('filterAgentSessions', "Filter Agent Sessions"), + group: 'navigation', + order: 3, + icon: Codicon.filter, + when: ContextKeyExpr.equals('view', SessionsViewId) +} satisfies ISubmenuItem); + +registerAction2(class RefreshAgentSessionsViewerAction extends Action2 { + constructor() { + super({ + id: 'sessionsView.refresh', + title: localize2('refresh', "Refresh Agent Sessions"), + icon: Codicon.refresh, + menu: [{ + id: MenuId.ViewTitle, + group: 'navigation', + order: 1, + when: ContextKeyExpr.equals('view', SessionsViewId), + }], + }); + } + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + return view?.sessionsControl?.refresh(); + } +}); + +registerAction2(class FindAgentSessionInViewerAction extends Action2 { + + constructor() { + super({ + id: 'sessionsView.find', + title: localize2('find', "Find Agent Session"), + icon: Codicon.search, + menu: [{ + id: MenuId.ViewTitle, + group: 'navigation', + order: 2, + when: ContextKeyExpr.equals('view', SessionsViewId), + }] + }); + } + + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + return view?.sessionsControl?.openFind(); + } +}); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsWorkbenchService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsWorkbenchService.ts new file mode 100644 index 0000000000000..84368fd05f9a7 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/sessionsWorkbenchService.ts @@ -0,0 +1,310 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { IObservable, observableValue } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { ISessionOpenOptions, openSession as openSessionDefault } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.js'; +import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { IChatSessionItem, IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { IChatService, IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { ChatAgentLocation } from '../../../../workbench/contrib/chat/common/constants.js'; +import { IAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; + +export const IsNewChatSessionContext = new RawContextKey('isNewChatSession', true); + +//#region Active Session Service + +const LAST_SELECTED_SESSION_KEY = 'agentSessions.lastSelectedSession'; +const repositoryOptionId = 'repository'; + +/** + * An active session item extends IChatSessionItem with repository information. + * - For agent session items: repository is the workingDirectory from metadata + * - For new sessions: repository comes from the session option with id 'repository' + */ +export type IActiveSessionItem = (IChatSessionItem | IAgentSession) & { + /** + * The repository URI for this session. + */ + readonly repository: URI | undefined; + + /** + * The worktree URI for this session. + */ + readonly worktree: URI | undefined; +}; + +export interface ISessionsWorkbenchService { + readonly _serviceBrand: undefined; + + /** + * Observable for the currently active session. + */ + readonly activeSession: IObservable; + + /** + * Returns the currently active session, if any. + */ + getActiveSession(): IActiveSessionItem | undefined; + + /** + * Select an existing session as the active session. + * Sets `isNewChatSession` context to false and opens the session. + */ + openSession(sessionResource: URI, openOptions?: ISessionOpenOptions): Promise; + + /** + * Open a new session, apply options, and send the initial request. + * This is the main entry point for the new-chat welcome widget. + */ + openSessionAndSend(sessionResource: URI, query: string, sendOptions: IChatSendRequestOptions, selectedOptions?: ReadonlyMap): Promise; + + /** + * Switch to the new-session view. + * No-op if the current session is already a new session. + */ + openNewSession(): void; +} + +export const ISessionsWorkbenchService = createDecorator('sessionsWorkbenchService'); + +export class SessionsWorkbenchService extends Disposable implements ISessionsWorkbenchService { + + declare readonly _serviceBrand: undefined; + + private readonly _activeSession = observableValue(this, undefined); + readonly activeSession: IObservable = this._activeSession; + + private lastSelectedSession: URI | undefined; + private readonly isNewChatSessionContext: IContextKey; + + constructor( + @IStorageService private readonly storageService: IStorageService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IChatService private readonly chatService: IChatService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @ILogService private readonly logService: ILogService, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(); + + // Bind context key to active session state. + // isNewSession is false when there are any established sessions in the model. + this.isNewChatSessionContext = IsNewChatSessionContext.bindTo(contextKeyService); + + // Load last selected session + this.lastSelectedSession = this.loadLastSelectedSession(); + + // Save on shutdown + this._register(this.storageService.onWillSaveState(() => { + this.saveLastSelectedSession(); + })); + + // Update active session when session options change + this._register(this.chatSessionsService.onDidChangeSessionOptions(sessionResource => { + const currentActive = this._activeSession.get(); + if (currentActive && currentActive.resource.toString() === sessionResource.toString()) { + // Re-fetch the repository from session options and update the active session + const repository = this.getRepositoryFromSessionOption(sessionResource); + if (currentActive.repository?.toString() !== repository?.toString()) { + this._activeSession.set({ ...currentActive, repository }, undefined); + } + } + })); + + // Update active session when the agent sessions model changes (e.g., metadata updates with worktree/repository info) + this._register(this.agentSessionsService.model.onDidChangeSessions(() => { + this.refreshActiveSessionFromModel(); + })); + } + + private refreshActiveSessionFromModel(): void { + const currentActive = this._activeSession.get(); + if (!currentActive) { + return; + } + + const agentSession = this.agentSessionsService.model.getSession(currentActive.resource); + if (!agentSession) { + return; + } + + const [repository, worktree] = this.getRepositoryFromMetadata(agentSession.metadata); + const activeSessionItem: IActiveSessionItem = { + ...agentSession, + repository, + worktree, + }; + this._activeSession.set(activeSessionItem, undefined); + } + + private getRepositoryFromMetadata(metadata: { readonly [key: string]: unknown } | undefined): [URI | undefined, URI | undefined] { + if (!metadata) { + return [undefined, undefined]; + } + + const repositoryPath = metadata?.repositoryPath as string | undefined; + const repositoryPathUri = typeof repositoryPath === 'string' ? URI.file(repositoryPath) : undefined; + + const worktreePath = metadata?.worktreePath as string | undefined; + const worktreePathUri = typeof worktreePath === 'string' ? URI.file(worktreePath) : undefined; + + return [ + URI.isUri(repositoryPathUri) ? repositoryPathUri : undefined, + URI.isUri(worktreePathUri) ? worktreePathUri : undefined]; + } + + private getRepositoryFromSessionOption(sessionResource: URI): URI | undefined { + const optionValue = this.chatSessionsService.getSessionOption(sessionResource, repositoryOptionId); + if (!optionValue) { + return undefined; + } + + // Option value can be a string or IChatSessionProviderOptionItem + const optionId = typeof optionValue === 'string' ? optionValue : (optionValue as IChatSessionProviderOptionItem).id; + if (!optionId) { + return undefined; + } + + try { + return URI.parse(optionId); + } catch { + return undefined; + } + } + + getActiveSession(): IActiveSessionItem | undefined { + return this._activeSession.get(); + } + + async openSession(sessionResource: URI, openOptions?: ISessionOpenOptions): Promise { + this.isNewChatSessionContext.set(false); + const session = this.agentSessionsService.model.getSession(sessionResource); + if (session) { + this.setActiveSession(session); + await this.instantiationService.invokeFunction(openSessionDefault, session, openOptions); + } else { + // For new sessions, load via the chat service first so the model + // is ready before the ChatViewPane renders it. + const modelRef = await this.chatService.loadSessionForResource(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); + const chatWidget = await this.chatWidgetService.openSession(sessionResource, ChatViewPaneTarget); + if (!chatWidget?.viewModel) { + this.logService.warn(`[ActiveSessionService] Failed to open session: ${sessionResource.toString()}`); + modelRef?.dispose(); + return; + } + const repository = this.getRepositoryFromSessionOption(sessionResource); + const activeSessionItem: IActiveSessionItem = { + resource: sessionResource, + label: chatWidget.viewModel.model.title || '', + timing: chatWidget.viewModel.model.timing, + repository, + worktree: undefined + }; + this.logService.info(`[ActiveSessionService] Active session changed (new): ${sessionResource.toString()}, repository: ${repository?.toString() ?? 'none'}`); + this._activeSession.set(activeSessionItem, undefined); + } + } + + async openSessionAndSend(sessionResource: URI, query: string, sendOptions: IChatSendRequestOptions, selectedOptions?: ReadonlyMap): Promise { + // 1. Open the session in ChatViewPane - this transitions views, + // loads the model, and connects it to the ChatWidget so + // tool invocations work. + await this.openSession(sessionResource); + + // 2. Apply selected options to the contributed session + if (selectedOptions && selectedOptions.size > 0) { + const modelRef = this.chatService.getActiveSessionReference(sessionResource); + if (modelRef) { + const model = modelRef.object; + const contributedSession = model.contributedChatSession; + if (contributedSession) { + const initialSessionOptions = [...selectedOptions.entries()].map( + ([optionId, value]) => ({ optionId, value }) + ); + model.setContributedChatSession({ + ...contributedSession, + initialSessionOptions, + }); + } + modelRef.dispose(); + } + } + + // 3. Snapshot existing session resources so we can detect the new one + const existingResources = new Set( + this.agentSessionsService.model.sessions.map(s => s.resource.toString()) + ); + + // 4. Send the request through the chat service - the model is now + // connected to the ChatWidget, so tools and rendering work. + const result = await this.chatService.sendRequest(sessionResource, query, sendOptions); + if (result.kind === 'rejected') { + this.logService.error(`[ActiveSessionService] sendRequest rejected: ${result.reason}`); + return; + } + + // 5. After send, the extension creates an agent session. Detect it + // and set it as the active session so the titlebar and sidebar + // reflect the new session. + const newSession = this.agentSessionsService.model.sessions.find( + s => !existingResources.has(s.resource.toString()) + ); + if (newSession) { + this.setActiveSession(newSession); + } + } + + openNewSession(): void { + // No-op if the current session is already a new session + if (this.isNewChatSessionContext.get()) { + return; + } + this.isNewChatSessionContext.set(true); + this._activeSession.set(undefined, undefined); + } + + private setActiveSession(session: IAgentSession): void { + this.lastSelectedSession = session.resource; + const [repository, worktree] = this.getRepositoryFromMetadata(session.metadata); + const activeSessionItem: IActiveSessionItem = { + ...session, + repository, + worktree, + }; + this.logService.info(`[ActiveSessionService] Active session changed: ${session.resource.toString()}, repository: ${repository?.toString() ?? 'none'}`); + this._activeSession.set(activeSessionItem, undefined); + } + + private loadLastSelectedSession(): URI | undefined { + const cached = this.storageService.get(LAST_SELECTED_SESSION_KEY, StorageScope.WORKSPACE); + if (!cached) { + return undefined; + } + + try { + return URI.parse(cached); + } catch { + return undefined; + } + } + + private saveLastSelectedSession(): void { + if (this.lastSelectedSession) { + this.storageService.store(LAST_SELECTED_SESSION_KEY, this.lastSelectedSession.toString(), StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + } +} + +//#endregion diff --git a/src/vs/sessions/electron-browser/sessions-dev.html b/src/vs/sessions/electron-browser/sessions-dev.html new file mode 100644 index 0000000000000..56f1b22575beb --- /dev/null +++ b/src/vs/sessions/electron-browser/sessions-dev.html @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + diff --git a/src/vs/sessions/electron-browser/sessions.html b/src/vs/sessions/electron-browser/sessions.html new file mode 100644 index 0000000000000..afb0a45e67ec7 --- /dev/null +++ b/src/vs/sessions/electron-browser/sessions.html @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + diff --git a/src/vs/sessions/electron-browser/sessions.main.ts b/src/vs/sessions/electron-browser/sessions.main.ts new file mode 100644 index 0000000000000..87669d73043a2 --- /dev/null +++ b/src/vs/sessions/electron-browser/sessions.main.ts @@ -0,0 +1,422 @@ +/*--------------------------------------------------------------------------------------------- + * 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 product from '../../platform/product/common/product.js'; +import { INativeWindowConfiguration, IWindowsConfiguration, hasNativeMenu } from '../../platform/window/common/window.js'; +import { NativeWindow } from '../../workbench/electron-browser/window.js'; +import { setFullscreen } from '../../base/browser/browser.js'; +import { domContentLoaded } from '../../base/browser/dom.js'; +import { onUnexpectedError } from '../../base/common/errors.js'; +import { URI } from '../../base/common/uri.js'; +import { WorkspaceService } from '../../workbench/services/configuration/browser/configurationService.js'; +import { INativeWorkbenchEnvironmentService, NativeWorkbenchEnvironmentService } from '../../workbench/services/environment/electron-browser/environmentService.js'; +import { ServiceCollection } from '../../platform/instantiation/common/serviceCollection.js'; +import { ILoggerService, ILogService, LogLevel } from '../../platform/log/common/log.js'; +import { NativeWorkbenchStorageService } from '../../workbench/services/storage/electron-browser/storageService.js'; +import { IWorkspaceContextService, isSingleFolderWorkspaceIdentifier, isWorkspaceIdentifier, IAnyWorkspaceIdentifier, reviveIdentifier, toWorkspaceIdentifier } from '../../platform/workspace/common/workspace.js'; +import { IWorkbenchConfigurationService } from '../../workbench/services/configuration/common/configuration.js'; +import { IStorageService } from '../../platform/storage/common/storage.js'; +import { Disposable } from '../../base/common/lifecycle.js'; +import { ISharedProcessService } from '../../platform/ipc/electron-browser/services.js'; +import { IMainProcessService } from '../../platform/ipc/common/mainProcessService.js'; +import { SharedProcessService } from '../../workbench/services/sharedProcess/electron-browser/sharedProcessService.js'; +import { RemoteAuthorityResolverService } from '../../platform/remote/electron-browser/remoteAuthorityResolverService.js'; +import { IRemoteAuthorityResolverService, RemoteConnectionType } from '../../platform/remote/common/remoteAuthorityResolver.js'; +import { RemoteAgentService } from '../../workbench/services/remote/electron-browser/remoteAgentService.js'; +import { IRemoteAgentService } from '../../workbench/services/remote/common/remoteAgentService.js'; +import { FileService } from '../../platform/files/common/fileService.js'; +import { IFileService } from '../../platform/files/common/files.js'; +import { RemoteFileSystemProviderClient } from '../../workbench/services/remote/common/remoteFileSystemProviderClient.js'; +import { ConfigurationCache } from '../../workbench/services/configuration/common/configurationCache.js'; +import { ISignService } from '../../platform/sign/common/sign.js'; +import { IProductService } from '../../platform/product/common/productService.js'; +import { IUriIdentityService } from '../../platform/uriIdentity/common/uriIdentity.js'; +import { UriIdentityService } from '../../platform/uriIdentity/common/uriIdentityService.js'; +import { INativeKeyboardLayoutService, NativeKeyboardLayoutService } from '../../workbench/services/keybinding/electron-browser/nativeKeyboardLayoutService.js'; +import { ElectronIPCMainProcessService } from '../../platform/ipc/electron-browser/mainProcessService.js'; +import { LoggerChannelClient } from '../../platform/log/common/logIpc.js'; +import { ProxyChannel } from '../../base/parts/ipc/common/ipc.js'; +import { NativeLogService } from '../../workbench/services/log/electron-browser/logService.js'; +import { WorkspaceTrustEnablementService, WorkspaceTrustManagementService } from '../../workbench/services/workspaces/common/workspaceTrust.js'; +import { IWorkspaceTrustEnablementService, IWorkspaceTrustManagementService } from '../../platform/workspace/common/workspaceTrust.js'; +import { safeStringify } from '../../base/common/objects.js'; +import { IUtilityProcessWorkerWorkbenchService, UtilityProcessWorkerWorkbenchService } from '../../workbench/services/utilityProcess/electron-browser/utilityProcessWorkerWorkbenchService.js'; +import { isCI, isMacintosh, isTahoeOrNewer } from '../../base/common/platform.js'; +import { Schemas } from '../../base/common/network.js'; +import { DiskFileSystemProvider } from '../../workbench/services/files/electron-browser/diskFileSystemProvider.js'; +import { FileUserDataProvider } from '../../platform/userData/common/fileUserDataProvider.js'; +import { IUserDataProfilesService, reviveProfile } from '../../platform/userDataProfile/common/userDataProfile.js'; +import { UserDataProfilesService } from '../../platform/userDataProfile/common/userDataProfileIpc.js'; +import { PolicyChannelClient } from '../../platform/policy/common/policyIpc.js'; +import { IPolicyService } from '../../platform/policy/common/policy.js'; +import { UserDataProfileService } from '../../workbench/services/userDataProfile/common/userDataProfileService.js'; +import { IUserDataProfileService } from '../../workbench/services/userDataProfile/common/userDataProfile.js'; +import { BrowserSocketFactory } from '../../platform/remote/browser/browserSocketFactory.js'; +import { RemoteSocketFactoryService, IRemoteSocketFactoryService } from '../../platform/remote/common/remoteSocketFactoryService.js'; +import { ElectronRemoteResourceLoader } from '../../platform/remote/electron-browser/electronRemoteResourceLoader.js'; +import { IConfigurationService } from '../../platform/configuration/common/configuration.js'; +import { applyZoom } from '../../platform/window/electron-browser/window.js'; +import { mainWindow } from '../../base/browser/window.js'; +import { IDefaultAccountService } from '../../platform/defaultAccount/common/defaultAccount.js'; +import { DefaultAccountService } from '../../workbench/services/accounts/browser/defaultAccount.js'; +import { AccountPolicyService } from '../../workbench/services/policies/common/accountPolicyService.js'; +import { MultiplexPolicyService } from '../../workbench/services/policies/common/multiplexPolicyService.js'; +import { Workbench as AgenticWorkbench } from '../browser/workbench.js'; +import { NativeMenubarControl } from '../../workbench/electron-browser/parts/titlebar/menubarControl.js'; + +export class AgenticMain extends Disposable { + + constructor( + private readonly configuration: INativeWindowConfiguration + ) { + super(); + + this.init(); + } + + private init(): void { + + // Massage configuration file URIs + this.reviveUris(); + + // Apply fullscreen early if configured + setFullscreen(!!this.configuration.fullscreen, mainWindow); + } + + private reviveUris() { + + // Workspace + const workspace = reviveIdentifier(this.configuration.workspace); + if (isWorkspaceIdentifier(workspace) || isSingleFolderWorkspaceIdentifier(workspace)) { + this.configuration.workspace = workspace; + } + + // Files + const filesToWait = this.configuration.filesToWait; + const filesToWaitPaths = filesToWait?.paths; + for (const paths of [filesToWaitPaths, this.configuration.filesToOpenOrCreate, this.configuration.filesToDiff, this.configuration.filesToMerge]) { + if (Array.isArray(paths)) { + for (const path of paths) { + if (path.fileUri) { + path.fileUri = URI.revive(path.fileUri); + } + } + } + } + + if (filesToWait) { + filesToWait.waitMarkerFileUri = URI.revive(filesToWait.waitMarkerFileUri); + } + } + + async open(): Promise { + + // Init services and wait for DOM to be ready in parallel + const [services] = await Promise.all([this.initServices(), domContentLoaded(mainWindow)]); + + // Apply zoom level early + this.applyWindowZoomLevel(services.configurationService); + + // Create Agentic Workbench + const workbench = new AgenticWorkbench(mainWindow.document.body, { + extraClasses: this.getExtraClasses(), + }, services.serviceCollection, services.logService); + + // Listeners + this.registerListeners(workbench, services.storageService); + + // Startup + const instantiationService = workbench.startup(); + + // Window + this._register(instantiationService.createInstance(NativeWindow)); + + // Native menu controller + if (isMacintosh || hasNativeMenu(services.configurationService)) { + this._register(instantiationService.createInstance(NativeMenubarControl)); + } + } + + private applyWindowZoomLevel(configurationService: IConfigurationService) { + let zoomLevel: number | undefined = undefined; + if (this.configuration.isCustomZoomLevel && typeof this.configuration.zoomLevel === 'number') { + zoomLevel = this.configuration.zoomLevel; + } else { + const windowConfig = configurationService.getValue(); + zoomLevel = typeof windowConfig.window?.zoomLevel === 'number' ? windowConfig.window.zoomLevel : 0; + } + + applyZoom(zoomLevel, mainWindow); + } + + private getExtraClasses(): string[] { + if (isMacintosh && isTahoeOrNewer(this.configuration.os.release)) { + return ['macos-tahoe']; + } + + return []; + } + + private registerListeners(workbench: AgenticWorkbench, storageService: NativeWorkbenchStorageService): void { + + // Workbench Lifecycle + this._register(workbench.onWillShutdown(event => event.join(storageService.close(), { id: 'join.closeStorage', label: localize('join.closeStorage', "Saving UI state") }))); + this._register(workbench.onDidShutdown(() => this.dispose())); + } + + private async initServices(): Promise<{ serviceCollection: ServiceCollection; logService: ILogService; storageService: NativeWorkbenchStorageService; configurationService: WorkspaceService }> { + const serviceCollection = new ServiceCollection(); + + + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // + // NOTE: Please do NOT register services here. Use `registerSingleton()` + // from `workbench.common.main.ts` if the service is shared between + // desktop and web or `sessions/sessions.desktop.main.ts` if the service + // is sessions desktop only. + // + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + + + // Main Process + const mainProcessService = this._register(new ElectronIPCMainProcessService(this.configuration.windowId)); + serviceCollection.set(IMainProcessService, mainProcessService); + + // Product + const productService: IProductService = { _serviceBrand: undefined, ...product }; + serviceCollection.set(IProductService, productService); + + // Environment + const environmentService = new NativeWorkbenchEnvironmentService(this.configuration, productService); + serviceCollection.set(INativeWorkbenchEnvironmentService, environmentService); + + // Logger + const loggers = this.configuration.loggers.map(loggerResource => ({ ...loggerResource, resource: URI.revive(loggerResource.resource) })); + const loggerService = new LoggerChannelClient(this.configuration.windowId, this.configuration.logLevel, environmentService.windowLogsPath, loggers, mainProcessService.getChannel('logger')); + serviceCollection.set(ILoggerService, loggerService); + + // Log + const logService = this._register(new NativeLogService(loggerService, environmentService)); + serviceCollection.set(ILogService, logService); + if (isCI) { + logService.info('workbench#open()'); // marking workbench open helps to diagnose flaky integration/smoke tests + } + if (logService.getLevel() === LogLevel.Trace) { + logService.trace('workbench#open(): with configuration', safeStringify({ ...this.configuration, nls: undefined /* exclude large property */ })); + } + + // Default Account + const defaultAccountService = this._register(new DefaultAccountService(productService)); + serviceCollection.set(IDefaultAccountService, defaultAccountService); + + // Policies + let policyService: IPolicyService; + const accountPolicy = new AccountPolicyService(logService, defaultAccountService); + if (this.configuration.policiesData) { + const policyChannel = new PolicyChannelClient(this.configuration.policiesData, mainProcessService.getChannel('policy')); + policyService = new MultiplexPolicyService([policyChannel, accountPolicy], logService); + } else { + policyService = accountPolicy; + } + serviceCollection.set(IPolicyService, policyService); + + // Shared Process + const sharedProcessService = new SharedProcessService(this.configuration.windowId, logService); + serviceCollection.set(ISharedProcessService, sharedProcessService); + + // Utility Process Worker + const utilityProcessWorkerWorkbenchService = new UtilityProcessWorkerWorkbenchService(this.configuration.windowId, logService, mainProcessService); + serviceCollection.set(IUtilityProcessWorkerWorkbenchService, utilityProcessWorkerWorkbenchService); + + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // + // NOTE: Please do NOT register services here. Use `registerSingleton()` + // from `workbench.common.main.ts` if the service is shared between + // desktop and web or `sessions/sessions.desktop.main.ts` if the service + // is sessions desktop only. + // + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + + + // Sign + const signService = ProxyChannel.toService(mainProcessService.getChannel('sign')); + serviceCollection.set(ISignService, signService); + + // Files + const fileService = this._register(new FileService(logService)); + serviceCollection.set(IFileService, fileService); + + // Remote + const remoteAuthorityResolverService = new RemoteAuthorityResolverService(productService, new ElectronRemoteResourceLoader(environmentService.window.id, mainProcessService, fileService)); + serviceCollection.set(IRemoteAuthorityResolverService, remoteAuthorityResolverService); + + // Local Files + const diskFileSystemProvider = this._register(new DiskFileSystemProvider(mainProcessService, utilityProcessWorkerWorkbenchService, logService, loggerService)); + fileService.registerProvider(Schemas.file, diskFileSystemProvider); + + // URI Identity + const uriIdentityService = new UriIdentityService(fileService); + serviceCollection.set(IUriIdentityService, uriIdentityService); + + // User Data Profiles + const userDataProfilesService = new UserDataProfilesService(this.configuration.profiles.all, URI.revive(this.configuration.profiles.home).with({ scheme: environmentService.userRoamingDataHome.scheme }), mainProcessService.getChannel('userDataProfiles')); + serviceCollection.set(IUserDataProfilesService, userDataProfilesService); + const userDataProfileService = new UserDataProfileService(reviveProfile(this.configuration.profiles.profile, userDataProfilesService.profilesHome.scheme)); + serviceCollection.set(IUserDataProfileService, userDataProfileService); + + // Use FileUserDataProvider for user data to + // enable atomic read / write operations. + fileService.registerProvider(Schemas.vscodeUserData, this._register(new FileUserDataProvider(Schemas.file, diskFileSystemProvider, Schemas.vscodeUserData, userDataProfilesService, uriIdentityService, logService))); + + // Remote Agent + const remoteSocketFactoryService = new RemoteSocketFactoryService(); + remoteSocketFactoryService.register(RemoteConnectionType.WebSocket, new BrowserSocketFactory(null)); + serviceCollection.set(IRemoteSocketFactoryService, remoteSocketFactoryService); + const remoteAgentService = this._register(new RemoteAgentService(remoteSocketFactoryService, userDataProfileService, environmentService, productService, remoteAuthorityResolverService, signService, logService)); + serviceCollection.set(IRemoteAgentService, remoteAgentService); + + // Remote Files + this._register(RemoteFileSystemProviderClient.register(remoteAgentService, fileService, logService)); + + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // + // NOTE: Please do NOT register services here. Use `registerSingleton()` + // from `workbench.common.main.ts` if the service is shared between + // desktop and web or `sessions/sessions.desktop.main.ts` if the service + // is sessions desktop only. + // + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + + // Create services that require resolving in parallel + const workspace = this.resolveWorkspaceIdentifier(environmentService); + const [configurationService, storageService] = await Promise.all([ + this.createWorkspaceService(workspace, environmentService, userDataProfileService, userDataProfilesService, fileService, remoteAgentService, uriIdentityService, logService, policyService).then(service => { + + // Workspace + serviceCollection.set(IWorkspaceContextService, service); + + // Configuration + serviceCollection.set(IWorkbenchConfigurationService, service); + + return service; + }), + + this.createStorageService(workspace, environmentService, userDataProfileService, userDataProfilesService, mainProcessService).then(service => { + + // Storage + serviceCollection.set(IStorageService, service); + + return service; + }), + + this.createKeyboardLayoutService(mainProcessService).then(service => { + + // KeyboardLayout + serviceCollection.set(INativeKeyboardLayoutService, service); + + return service; + }) + ]); + + // Workspace Trust Service + const workspaceTrustEnablementService = new WorkspaceTrustEnablementService(configurationService, environmentService); + serviceCollection.set(IWorkspaceTrustEnablementService, workspaceTrustEnablementService); + + const workspaceTrustManagementService = new WorkspaceTrustManagementService(configurationService, remoteAuthorityResolverService, storageService, uriIdentityService, environmentService, configurationService, workspaceTrustEnablementService, fileService); + serviceCollection.set(IWorkspaceTrustManagementService, workspaceTrustManagementService); + + // Update workspace trust so that configuration is updated accordingly + configurationService.updateWorkspaceTrust(workspaceTrustManagementService.isWorkspaceTrusted()); + this._register(workspaceTrustManagementService.onDidChangeTrust(() => configurationService.updateWorkspaceTrust(workspaceTrustManagementService.isWorkspaceTrusted()))); + + + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // + // NOTE: Please do NOT register services here. Use `registerSingleton()` + // from `workbench.common.main.ts` if the service is shared between + // desktop and web or `sessions/sessions.desktop.main.ts` if the service + // is sessions desktop only. + // + // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + + + return { serviceCollection, logService, storageService, configurationService }; + } + + private resolveWorkspaceIdentifier(environmentService: INativeWorkbenchEnvironmentService): IAnyWorkspaceIdentifier { + + // Return early for when a folder or multi-root is opened + if (this.configuration.workspace) { + return this.configuration.workspace; + } + + // Otherwise, workspace is empty, so we derive an identifier + return toWorkspaceIdentifier(this.configuration.backupPath, environmentService.isExtensionDevelopment); + } + + private async createWorkspaceService( + workspace: IAnyWorkspaceIdentifier, + environmentService: INativeWorkbenchEnvironmentService, + userDataProfileService: IUserDataProfileService, + userDataProfilesService: IUserDataProfilesService, + fileService: FileService, + remoteAgentService: IRemoteAgentService, + uriIdentityService: IUriIdentityService, + logService: ILogService, + policyService: IPolicyService + ): Promise { + const configurationCache = new ConfigurationCache([Schemas.file, Schemas.vscodeUserData] /* Cache all non native resources */, environmentService, fileService); + const workspaceService = new WorkspaceService({ remoteAuthority: environmentService.remoteAuthority, configurationCache }, environmentService, userDataProfileService, userDataProfilesService, fileService, remoteAgentService, uriIdentityService, logService, policyService); + + try { + await workspaceService.initialize(workspace); + + return workspaceService; + } catch (error) { + onUnexpectedError(error); + + return workspaceService; + } + } + + private async createStorageService(workspace: IAnyWorkspaceIdentifier, environmentService: INativeWorkbenchEnvironmentService, userDataProfileService: IUserDataProfileService, userDataProfilesService: IUserDataProfilesService, mainProcessService: IMainProcessService): Promise { + const storageService = new NativeWorkbenchStorageService(workspace, userDataProfileService, userDataProfilesService, mainProcessService, environmentService); + + try { + await storageService.initialize(); + + return storageService; + } catch (error) { + onUnexpectedError(error); + + return storageService; + } + } + + private async createKeyboardLayoutService(mainProcessService: IMainProcessService): Promise { + const keyboardLayoutService = new NativeKeyboardLayoutService(mainProcessService); + + try { + await keyboardLayoutService.initialize(); + + return keyboardLayoutService; + } catch (error) { + onUnexpectedError(error); + + return keyboardLayoutService; + } + } +} + +export interface IDesktopMain { + main(configuration: INativeWindowConfiguration): Promise; +} + +export function main(configuration: INativeWindowConfiguration): Promise { + const workbench = new AgenticMain(configuration); + + return workbench.open(); +} diff --git a/src/vs/sessions/electron-browser/sessions.ts b/src/vs/sessions/electron-browser/sessions.ts new file mode 100644 index 0000000000000..b7d33fa75da6a --- /dev/null +++ b/src/vs/sessions/electron-browser/sessions.ts @@ -0,0 +1,323 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* eslint-disable no-restricted-globals */ + +(async function () { + + // Add a perf entry right from the top + performance.mark('code/didStartRenderer'); + + type ISandboxConfiguration = import('../../base/parts/sandbox/common/sandboxTypes.js').ISandboxConfiguration; + type ILoadResult = import('../../platform/window/electron-browser/window.js').ILoadResult; + type ILoadOptions = import('../../platform/window/electron-browser/window.js').ILoadOptions; + type INativeWindowConfiguration = import('../../platform/window/common/window.js').INativeWindowConfiguration; + type IMainWindowSandboxGlobals = import('../../base/parts/sandbox/electron-browser/globals.js').IMainWindowSandboxGlobals; + type IDesktopMain = import('./sessions.main.js').IDesktopMain; + + const preloadGlobals = (window as unknown as { vscode: IMainWindowSandboxGlobals }).vscode; // defined by preload.ts + const safeProcess = preloadGlobals.process; + + //#region Splash Screen + + function showSplash(configuration: INativeWindowConfiguration) { + performance.mark('code/willShowPartsSplash'); + + const baseTheme = 'vs-dark'; + const shellBackground = '#191A1B'; + const shellForeground = '#CCCCCC'; + + // Apply base colors + const style = document.createElement('style'); + style.className = 'initialShellColors'; + window.document.head.appendChild(style); + style.textContent = `body { background-color: ${shellBackground}; color: ${shellForeground}; margin: 0; padding: 0; }`; + + // Set zoom level from splash data if available + if (typeof configuration.partsSplash?.zoomLevel === 'number' && typeof preloadGlobals?.webFrame?.setZoomLevel === 'function') { + preloadGlobals.webFrame.setZoomLevel(configuration.partsSplash.zoomLevel); + } + + const splash = document.createElement('div'); + splash.id = 'monaco-parts-splash'; + splash.className = baseTheme; + + window.document.body.appendChild(splash); + + performance.mark('code/didShowPartsSplash'); + } + + //#endregion + + //#region Window Helpers + + async function load(options: ILoadOptions): Promise> { + + // Window Configuration from Preload Script + const configuration = await resolveWindowConfiguration(); + + // Signal before import() + options?.beforeImport?.(configuration); + + // Developer settings + const { enableDeveloperKeybindings, removeDeveloperKeybindingsAfterLoad, developerDeveloperKeybindingsDisposable, forceDisableShowDevtoolsOnError } = setupDeveloperKeybindings(configuration, options); + + // NLS + setupNLS(configuration); + + // Compute base URL and set as global + const baseUrl = new URL(`${fileUriFromPath(configuration.appRoot, { isWindows: safeProcess.platform === 'win32', scheme: 'vscode-file', fallbackAuthority: 'vscode-app' })}/out/`); + globalThis._VSCODE_FILE_ROOT = baseUrl.toString(); + + // Dev only: CSS import map tricks + setupCSSImportMaps(configuration, baseUrl); + + // ESM Import - load the sessions workbench main module + try { + let workbenchUrl: string; + if (!!safeProcess.env['VSCODE_DEV'] && globalThis._VSCODE_USE_RELATIVE_IMPORTS) { + workbenchUrl = './workbench.desktop.main.js'; // for dev purposes only + } else { + workbenchUrl = new URL(`vs/sessions/sessions.desktop.main.js`, baseUrl).href; + } + + const result = await import(workbenchUrl); + if (developerDeveloperKeybindingsDisposable && removeDeveloperKeybindingsAfterLoad) { + developerDeveloperKeybindingsDisposable(); + } + + return { result, configuration }; + } catch (error) { + onUnexpectedError(error, enableDeveloperKeybindings && !forceDisableShowDevtoolsOnError); + + throw error; + } + } + + async function resolveWindowConfiguration() { + const timeout = setTimeout(() => { console.error(`[resolve window config] Could not resolve window configuration within 10 seconds, but will continue to wait...`); }, 10000); + performance.mark('code/willWaitForWindowConfig'); + + const configuration = await preloadGlobals.context.resolveConfiguration() as T; + performance.mark('code/didWaitForWindowConfig'); + + clearTimeout(timeout); + + return configuration; + } + + function setupDeveloperKeybindings(configuration: T, options: ILoadOptions) { + const { + forceEnableDeveloperKeybindings, + disallowReloadKeybinding, + removeDeveloperKeybindingsAfterLoad, + forceDisableShowDevtoolsOnError + } = typeof options?.configureDeveloperSettings === 'function' ? options.configureDeveloperSettings(configuration) : { + forceEnableDeveloperKeybindings: false, + disallowReloadKeybinding: false, + removeDeveloperKeybindingsAfterLoad: false, + forceDisableShowDevtoolsOnError: false + }; + + const isDev = !!safeProcess.env['VSCODE_DEV']; + const enableDeveloperKeybindings = Boolean(isDev || forceEnableDeveloperKeybindings); + let developerDeveloperKeybindingsDisposable: Function | undefined = undefined; + if (enableDeveloperKeybindings) { + developerDeveloperKeybindingsDisposable = registerDeveloperKeybindings(disallowReloadKeybinding); + } + + return { + enableDeveloperKeybindings, + removeDeveloperKeybindingsAfterLoad, + developerDeveloperKeybindingsDisposable, + forceDisableShowDevtoolsOnError + }; + } + + function registerDeveloperKeybindings(disallowReloadKeybinding: boolean | undefined): Function { + const ipcRenderer = preloadGlobals.ipcRenderer; + + const extractKey = + function (e: KeyboardEvent) { + return [ + e.ctrlKey ? 'ctrl-' : '', + e.metaKey ? 'meta-' : '', + e.altKey ? 'alt-' : '', + e.shiftKey ? 'shift-' : '', + e.keyCode + ].join(''); + }; + + // Devtools & reload support + const TOGGLE_DEV_TOOLS_KB = (safeProcess.platform === 'darwin' ? 'meta-alt-73' : 'ctrl-shift-73'); // mac: Cmd-Alt-I, rest: Ctrl-Shift-I + const TOGGLE_DEV_TOOLS_KB_ALT = '123'; // F12 + const RELOAD_KB = (safeProcess.platform === 'darwin' ? 'meta-82' : 'ctrl-82'); // mac: Cmd-R, rest: Ctrl-R + + let listener: ((e: KeyboardEvent) => void) | undefined = function (e) { + const key = extractKey(e); + if (key === TOGGLE_DEV_TOOLS_KB || key === TOGGLE_DEV_TOOLS_KB_ALT) { + ipcRenderer.send('vscode:toggleDevTools'); + } else if (key === RELOAD_KB && !disallowReloadKeybinding) { + ipcRenderer.send('vscode:reloadWindow'); + } + }; + + window.addEventListener('keydown', listener); + + return function () { + if (listener) { + window.removeEventListener('keydown', listener); + listener = undefined; + } + }; + } + + function setupNLS(configuration: T): void { + globalThis._VSCODE_NLS_MESSAGES = configuration.nls.messages; + globalThis._VSCODE_NLS_LANGUAGE = configuration.nls.language; + + let language = configuration.nls.language || 'en'; + if (language === 'zh-tw') { + language = 'zh-Hant'; + } else if (language === 'zh-cn') { + language = 'zh-Hans'; + } + + window.document.documentElement.setAttribute('lang', language); + } + + function onUnexpectedError(error: string | Error, showDevtoolsOnError: boolean): void { + if (showDevtoolsOnError) { + const ipcRenderer = preloadGlobals.ipcRenderer; + ipcRenderer.send('vscode:openDevTools'); + } + + console.error(`[uncaught exception]: ${error}`); + + if (error && typeof error !== 'string' && error.stack) { + console.error(error.stack); + } + } + + function fileUriFromPath(path: string, config: { isWindows?: boolean; scheme?: string; fallbackAuthority?: string }): string { + + // Since we are building a URI, we normalize any backslash + // to slashes and we ensure that the path begins with a '/'. + let pathName = path.replace(/\\/g, '/'); + if (pathName.length > 0 && pathName.charAt(0) !== '/') { + pathName = `/${pathName}`; + } + + let uri: string; + + // Windows: in order to support UNC paths (which start with '//') + // that have their own authority, we do not use the provided authority + // but rather preserve it. + if (config.isWindows && pathName.startsWith('//')) { + uri = encodeURI(`${config.scheme || 'file'}:${pathName}`); + } + + // Otherwise we optionally add the provided authority if specified + else { + uri = encodeURI(`${config.scheme || 'file'}://${config.fallbackAuthority || ''}${pathName}`); + } + + return uri.replace(/#/g, '%23'); + } + + function setupCSSImportMaps(configuration: T, baseUrl: URL) { + + // DEV --------------------------------------------------------------------------------------- + // DEV: This is for development and enables loading CSS via import-statements via import-maps. + // DEV: For each CSS modules that we have we defined an entry in the import map that maps to + // DEV: a blob URL that loads the CSS via a dynamic @import-rule. + // DEV --------------------------------------------------------------------------------------- + + if (globalThis._VSCODE_DISABLE_CSS_IMPORT_MAP) { + return; // disabled in certain development setups + } + + if (Array.isArray(configuration.cssModules) && configuration.cssModules.length > 0) { + performance.mark('code/willAddCssLoader'); + + globalThis._VSCODE_CSS_LOAD = function (url) { + const link = document.createElement('link'); + link.setAttribute('rel', 'stylesheet'); + link.setAttribute('type', 'text/css'); + link.setAttribute('href', url); + + window.document.head.appendChild(link); + }; + + const importMap: { imports: Record } = { imports: {} }; + for (const cssModule of configuration.cssModules) { + const cssUrl = new URL(cssModule, baseUrl).href; + const jsSrc = `globalThis._VSCODE_CSS_LOAD('${cssUrl}');\n`; + const blob = new Blob([jsSrc], { type: 'application/javascript' }); + importMap.imports[cssUrl] = URL.createObjectURL(blob); + } + + const ttp = window.trustedTypes?.createPolicy('vscode-bootstrapImportMap', { createScript(value) { return value; }, }); + const importMapSrc = JSON.stringify(importMap, undefined, 2); + const importMapScript = document.createElement('script'); + importMapScript.type = 'importmap'; + importMapScript.setAttribute('nonce', '0c6a828f1297'); + // @ts-expect-error + importMapScript.textContent = ttp?.createScript(importMapSrc) ?? importMapSrc; + window.document.head.appendChild(importMapScript); + + performance.mark('code/didAddCssLoader'); + } + } + + //#endregion + + const { result, configuration } = await load( + { + configureDeveloperSettings: function (windowConfig) { + return { + // disable automated devtools opening on error when running extension tests + // as this can lead to nondeterministic test execution (devtools steals focus) + forceDisableShowDevtoolsOnError: typeof windowConfig.extensionTestsPath === 'string' || windowConfig['enable-smoke-test-driver'] === true, + // enable devtools keybindings in extension development window + forceEnableDeveloperKeybindings: Array.isArray(windowConfig.extensionDevelopmentPath) && windowConfig.extensionDevelopmentPath.length > 0, + removeDeveloperKeybindingsAfterLoad: true + }; + }, + beforeImport: function (windowConfig) { + + // Show our splash as early as possible + showSplash(windowConfig); + + // Code windows have a `vscodeWindowId` property to identify them + Object.defineProperty(window, 'vscodeWindowId', { + get: () => windowConfig.windowId + }); + + // It looks like browsers only lazily enable + // the element when needed. Since we + // leverage canvas elements in our code in many + // locations, we try to help the browser to + // initialize canvas when it is idle, right + // before we wait for the scripts to be loaded. + window.requestIdleCallback(() => { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + context?.clearRect(0, 0, canvas.width, canvas.height); + canvas.remove(); + }, { timeout: 50 }); + + // Track import() perf + performance.mark('code/willLoadWorkbenchMain'); + } + } + ); + + // Mark start of workbench + performance.mark('code/didLoadWorkbenchMain'); + + // Load workbench + result.main(configuration); +}()); diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts new file mode 100644 index 0000000000000..7e8c8e57a474a --- /dev/null +++ b/src/vs/sessions/sessions.common.main.ts @@ -0,0 +1,439 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//#region --- editor/workbench core + +import '../editor/editor.all.js'; + +import '../workbench/api/browser/extensionHost.contribution.js'; +import '../workbench/browser/workbench.contribution.js'; + +//#endregion + + +//#region --- workbench actions + +import '../workbench/browser/actions/textInputActions.js'; +import '../workbench/browser/actions/developerActions.js'; +import '../workbench/browser/actions/helpActions.js'; +import '../workbench/browser/actions/listCommands.js'; +import '../workbench/browser/actions/navigationActions.js'; +import '../workbench/browser/actions/windowActions.js'; +import '../workbench/browser/actions/workspaceActions.js'; +import '../workbench/browser/actions/workspaceCommands.js'; +import '../workbench/browser/actions/quickAccessActions.js'; +import '../workbench/browser/actions/widgetNavigationCommands.js'; + +//#endregion + + +//#region --- API Extension Points + +import '../workbench/services/actions/common/menusExtensionPoint.js'; +import '../workbench/api/common/configurationExtensionPoint.js'; +import '../workbench/api/browser/viewsExtensionPoint.js'; + +//#endregion + + +//#region --- workbench parts + +import '../workbench/browser/parts/editor/editor.contribution.js'; +import '../workbench/browser/parts/editor/editorParts.js'; +import '../workbench/browser/parts/banner/bannerPart.js'; +import '../workbench/browser/parts/statusbar/statusbarPart.js'; + +//#endregion + + +//#region --- workbench services + +import '../platform/actions/common/actions.contribution.js'; +import '../platform/undoRedo/common/undoRedoService.js'; +import '../platform/mcp/common/mcpResourceScannerService.js'; +import '../workbench/services/workspaces/common/editSessionIdentityService.js'; +import '../workbench/services/workspaces/common/canonicalUriService.js'; +import '../workbench/services/extensions/browser/extensionUrlHandler.js'; +import '../workbench/services/keybinding/common/keybindingEditing.js'; +import '../workbench/services/decorations/browser/decorationsService.js'; +import '../workbench/services/dialogs/common/dialogService.js'; +import '../workbench/services/progress/browser/progressService.js'; +import '../workbench/services/editor/browser/codeEditorService.js'; +import '../workbench/services/preferences/browser/preferencesService.js'; +import '../workbench/services/configuration/common/jsonEditingService.js'; +import '../workbench/services/textmodelResolver/common/textModelResolverService.js'; +import '../workbench/services/editor/browser/editorService.js'; +import '../workbench/services/editor/browser/editorResolverService.js'; +import '../workbench/services/aiEmbeddingVector/common/aiEmbeddingVectorService.js'; +import '../workbench/services/aiRelatedInformation/common/aiRelatedInformationService.js'; +import '../workbench/services/aiSettingsSearch/common/aiSettingsSearchService.js'; +import '../workbench/services/history/browser/historyService.js'; +import '../workbench/services/activity/browser/activityService.js'; +import '../workbench/services/keybinding/browser/keybindingService.js'; +import '../workbench/services/untitled/common/untitledTextEditorService.js'; +import '../workbench/services/textresourceProperties/common/textResourcePropertiesService.js'; +import '../workbench/services/textfile/common/textEditorService.js'; +import '../workbench/services/language/common/languageService.js'; +import '../workbench/services/model/common/modelService.js'; +import '../workbench/services/notebook/common/notebookDocumentService.js'; +import '../workbench/services/commands/common/commandService.js'; +import '../workbench/services/themes/browser/workbenchThemeService.js'; +import '../workbench/services/label/common/labelService.js'; +import '../workbench/services/extensions/common/extensionManifestPropertiesService.js'; +import '../workbench/services/extensionManagement/common/extensionGalleryService.js'; +import '../workbench/services/extensionManagement/browser/extensionEnablementService.js'; +import '../workbench/services/extensionManagement/browser/builtinExtensionsScannerService.js'; +import '../workbench/services/extensionRecommendations/common/extensionIgnoredRecommendationsService.js'; +import '../workbench/services/extensionRecommendations/common/workspaceExtensionsConfig.js'; +import '../workbench/services/extensionManagement/common/extensionFeaturesManagemetService.js'; +import '../workbench/services/notification/common/notificationService.js'; +import '../workbench/services/userDataSync/common/userDataSyncUtil.js'; +import '../workbench/services/userDataProfile/browser/userDataProfileImportExportService.js'; +import '../workbench/services/userDataProfile/browser/userDataProfileManagement.js'; +import '../workbench/services/userDataProfile/common/remoteUserDataProfiles.js'; +import '../workbench/services/remote/common/remoteExplorerService.js'; +import '../workbench/services/remote/common/remoteExtensionsScanner.js'; +import '../workbench/services/terminal/common/embedderTerminalService.js'; +import '../workbench/services/workingCopy/common/workingCopyService.js'; +import '../workbench/services/workingCopy/common/workingCopyFileService.js'; +import '../workbench/services/workingCopy/common/workingCopyEditorService.js'; +import '../workbench/services/filesConfiguration/common/filesConfigurationService.js'; +import '../workbench/services/views/browser/viewDescriptorService.js'; +import '../workbench/services/views/browser/viewsService.js'; +import '../workbench/services/quickinput/browser/quickInputService.js'; +import '../workbench/services/userDataSync/browser/userDataSyncWorkbenchService.js'; +import '../workbench/services/authentication/browser/authenticationService.js'; +import '../workbench/services/authentication/browser/authenticationExtensionsService.js'; +import '../workbench/services/authentication/browser/authenticationUsageService.js'; +import '../workbench/services/authentication/browser/authenticationAccessService.js'; +import '../workbench/services/authentication/browser/authenticationMcpUsageService.js'; +import '../workbench/services/authentication/browser/authenticationMcpAccessService.js'; +import '../workbench/services/authentication/browser/authenticationMcpService.js'; +import '../workbench/services/authentication/browser/dynamicAuthenticationProviderStorageService.js'; +import '../workbench/services/authentication/browser/authenticationQueryService.js'; +import '../platform/hover/browser/hoverService.js'; +import '../platform/userInteraction/browser/userInteractionServiceImpl.js'; +import '../workbench/services/assignment/common/assignmentService.js'; +import '../workbench/services/outline/browser/outlineService.js'; +import '../workbench/services/languageDetection/browser/languageDetectionWorkerServiceImpl.js'; +import '../editor/common/services/languageFeaturesService.js'; +import '../editor/common/services/semanticTokensStylingService.js'; +import '../editor/common/services/treeViewsDndService.js'; +import '../workbench/services/textMate/browser/textMateTokenizationFeature.contribution.js'; +import '../workbench/services/treeSitter/browser/treeSitter.contribution.js'; +import '../workbench/services/userActivity/common/userActivityService.js'; +import '../workbench/services/userActivity/browser/userActivityBrowser.js'; +import '../workbench/services/userAttention/browser/userAttentionBrowser.js'; +import '../workbench/services/editor/browser/editorPaneService.js'; +import '../workbench/services/editor/common/customEditorLabelService.js'; +import '../workbench/services/dataChannel/browser/dataChannelService.js'; +import '../workbench/services/inlineCompletions/common/inlineCompletionsUnification.js'; +import '../workbench/services/chat/common/chatEntitlementService.js'; +import '../workbench/services/log/common/defaultLogLevels.js'; + +import { InstantiationType, registerSingleton } from '../platform/instantiation/common/extensions.js'; +import { GlobalExtensionEnablementService } from '../platform/extensionManagement/common/extensionEnablementService.js'; +import { IAllowedExtensionsService, IGlobalExtensionEnablementService } from '../platform/extensionManagement/common/extensionManagement.js'; +import { ContextViewService } from '../platform/contextview/browser/contextViewService.js'; +import { IContextViewService } from '../platform/contextview/browser/contextView.js'; +import { IListService, ListService } from '../platform/list/browser/listService.js'; +import { MarkerDecorationsService } from '../editor/common/services/markerDecorationsService.js'; +import { IMarkerDecorationsService } from '../editor/common/services/markerDecorations.js'; +import { IMarkerService } from '../platform/markers/common/markers.js'; +import { MarkerService } from '../platform/markers/common/markerService.js'; +import { ContextKeyService } from '../platform/contextkey/browser/contextKeyService.js'; +import { IContextKeyService } from '../platform/contextkey/common/contextkey.js'; +import { ITextResourceConfigurationService } from '../editor/common/services/textResourceConfiguration.js'; +import { TextResourceConfigurationService } from '../editor/common/services/textResourceConfigurationService.js'; +import { IDownloadService } from '../platform/download/common/download.js'; +import { DownloadService } from '../platform/download/common/downloadService.js'; +import { OpenerService } from '../editor/browser/services/openerService.js'; +import { IOpenerService } from '../platform/opener/common/opener.js'; +import { IgnoredExtensionsManagementService, IIgnoredExtensionsManagementService } from '../platform/userDataSync/common/ignoredExtensions.js'; +import { ExtensionStorageService, IExtensionStorageService } from '../platform/extensionManagement/common/extensionStorage.js'; +import { IUserDataSyncLogService } from '../platform/userDataSync/common/userDataSync.js'; +import { UserDataSyncLogService } from '../platform/userDataSync/common/userDataSyncLog.js'; +import { AllowedExtensionsService } from '../platform/extensionManagement/common/allowedExtensionsService.js'; +import { IAllowedMcpServersService, IMcpGalleryService } from '../platform/mcp/common/mcpManagement.js'; +import { McpGalleryService } from '../platform/mcp/common/mcpGalleryService.js'; +import { AllowedMcpServersService } from '../platform/mcp/common/allowedMcpServersService.js'; +import { IWebWorkerService } from '../platform/webWorker/browser/webWorkerService.js'; +import { WebWorkerService } from '../platform/webWorker/browser/webWorkerServiceImpl.js'; + +registerSingleton(IUserDataSyncLogService, UserDataSyncLogService, InstantiationType.Delayed); +registerSingleton(IAllowedExtensionsService, AllowedExtensionsService, InstantiationType.Delayed); +registerSingleton(IIgnoredExtensionsManagementService, IgnoredExtensionsManagementService, InstantiationType.Delayed); +registerSingleton(IGlobalExtensionEnablementService, GlobalExtensionEnablementService, InstantiationType.Delayed); +registerSingleton(IExtensionStorageService, ExtensionStorageService, InstantiationType.Delayed); +registerSingleton(IContextViewService, ContextViewService, InstantiationType.Delayed); +registerSingleton(IListService, ListService, InstantiationType.Delayed); +registerSingleton(IMarkerDecorationsService, MarkerDecorationsService, InstantiationType.Delayed); +registerSingleton(IMarkerService, MarkerService, InstantiationType.Delayed); +registerSingleton(IContextKeyService, ContextKeyService, InstantiationType.Delayed); +registerSingleton(ITextResourceConfigurationService, TextResourceConfigurationService, InstantiationType.Delayed); +registerSingleton(IDownloadService, DownloadService, InstantiationType.Delayed); +registerSingleton(IOpenerService, OpenerService, InstantiationType.Delayed); +registerSingleton(IWebWorkerService, WebWorkerService, InstantiationType.Delayed); +registerSingleton(IMcpGalleryService, McpGalleryService, InstantiationType.Delayed); +registerSingleton(IAllowedMcpServersService, AllowedMcpServersService, InstantiationType.Delayed); + +//#endregion + + +//#region --- workbench contributions + +// Default Account +import '../workbench/services/accounts/browser/defaultAccount.js'; + +// Telemetry +import '../workbench/contrib/telemetry/browser/telemetry.contribution.js'; + +// Preferences +import '../workbench/contrib/preferences/browser/preferences.contribution.js'; +import '../workbench/contrib/preferences/browser/keybindingsEditorContribution.js'; +import '../workbench/contrib/preferences/browser/preferencesSearch.js'; + +// Performance +import '../workbench/contrib/performance/browser/performance.contribution.js'; + +// Notebook +import '../workbench/contrib/notebook/browser/notebook.contribution.js'; + +// Speech +import '../workbench/contrib/speech/browser/speech.contribution.js'; + +// Chat +import '../workbench/contrib/chat/browser/chat.contribution.js'; +import '../workbench/contrib/inlineChat/browser/inlineChat.contribution.js'; +import '../workbench/contrib/mcp/browser/mcp.contribution.js'; +import '../workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.js'; +import '../workbench/contrib/chat/browser/contextContrib/chatContext.contribution.js'; + +// Interactive +import '../workbench/contrib/interactive/browser/interactive.contribution.js'; + +// repl +import '../workbench/contrib/replNotebook/browser/repl.contribution.js'; + +// Testing +import '../workbench/contrib/testing/browser/testing.contribution.js'; + +// Logs +import '../workbench/contrib/logs/common/logs.contribution.js'; + +// Quickaccess +import '../workbench/contrib/quickaccess/browser/quickAccess.contribution.js'; + +// Explorer +import '../workbench/contrib/files/browser/explorerViewlet.js'; +import '../workbench/contrib/files/browser/fileActions.contribution.js'; +import '../workbench/contrib/files/browser/files.contribution.js'; + +// Bulk Edit +import '../workbench/contrib/bulkEdit/browser/bulkEditService.js'; +import '../workbench/contrib/bulkEdit/browser/preview/bulkEdit.contribution.js'; + +// Rename Symbol Tracker for Inline completions. +import '../workbench/contrib/inlineCompletions/browser/renameSymbolTrackerService.js'; + +// Search +import '../workbench/contrib/search/browser/search.contribution.js'; +import '../workbench/contrib/search/browser/searchView.js'; + +// Search Editor +import '../workbench/contrib/searchEditor/browser/searchEditor.contribution.js'; + +// Sash +import '../workbench/contrib/sash/browser/sash.contribution.js'; + +// SCM +import '../workbench/contrib/scm/browser/scm.contribution.js'; + +// Debug +import '../workbench/contrib/debug/browser/debug.contribution.js'; +import '../workbench/contrib/debug/browser/debugEditorContribution.js'; +import '../workbench/contrib/debug/browser/breakpointEditorContribution.js'; +import '../workbench/contrib/debug/browser/callStackEditorContribution.js'; +import '../workbench/contrib/debug/browser/repl.js'; +import '../workbench/contrib/debug/browser/debugViewlet.js'; + +// Markers +import '../workbench/contrib/markers/browser/markers.contribution.js'; + +// Process Explorer +import '../workbench/contrib/processExplorer/browser/processExplorer.contribution.js'; + +// Merge Editor +import '../workbench/contrib/mergeEditor/browser/mergeEditor.contribution.js'; + +// Multi Diff Editor +import '../workbench/contrib/multiDiffEditor/browser/multiDiffEditor.contribution.js'; + +// Commands +import '../workbench/contrib/commands/common/commands.contribution.js'; + +// Comments +import '../workbench/contrib/comments/browser/comments.contribution.js'; + +// URL Support +import '../workbench/contrib/url/browser/url.contribution.js'; + +// Webview +import '../workbench/contrib/webview/browser/webview.contribution.js'; +import '../workbench/contrib/webviewPanel/browser/webviewPanel.contribution.js'; +import '../workbench/contrib/webviewView/browser/webviewView.contribution.js'; +import '../workbench/contrib/customEditor/browser/customEditor.contribution.js'; + +// External Uri Opener +import '../workbench/contrib/externalUriOpener/common/externalUriOpener.contribution.js'; + +// Extensions Management +import '../workbench/contrib/extensions/browser/extensions.contribution.js'; +import '../workbench/contrib/extensions/browser/extensionsViewlet.js'; + +// Output View +import '../workbench/contrib/output/browser/output.contribution.js'; +import '../workbench/contrib/output/browser/outputView.js'; + +// Terminal +import '../workbench/contrib/terminal/terminal.all.js'; + +// External terminal +import '../workbench/contrib/externalTerminal/browser/externalTerminal.contribution.js'; + +// Relauncher +import '../workbench/contrib/relauncher/browser/relauncher.contribution.js'; + +// Tasks +import '../workbench/contrib/tasks/browser/task.contribution.js'; + +// Remote +import '../workbench/contrib/remote/common/remote.contribution.js'; +import '../workbench/contrib/remote/browser/remote.contribution.js'; + +// Emmet +import '../workbench/contrib/emmet/browser/emmet.contribution.js'; + +// CodeEditor Contributions +import '../workbench/contrib/codeEditor/browser/codeEditor.contribution.js'; + +// Markdown +import '../workbench/contrib/markdown/browser/markdown.contribution.js'; + +// Keybindings Contributions +import '../workbench/contrib/keybindings/browser/keybindings.contribution.js'; + +// Snippets +import '../workbench/contrib/snippets/browser/snippets.contribution.js'; + +// Formatter Help +import '../workbench/contrib/format/browser/format.contribution.js'; + +// Folding +import '../workbench/contrib/folding/browser/folding.contribution.js'; + +// Limit Indicator +import '../workbench/contrib/limitIndicator/browser/limitIndicator.contribution.js'; + +// Inlay Hint Accessibility +import '../workbench/contrib/inlayHints/browser/inlayHintsAccessibilty.js'; + +// Themes +import '../workbench/contrib/themes/browser/themes.contribution.js'; + +// Update +import '../workbench/contrib/update/browser/update.contribution.js'; + +// Surveys +import '../workbench/contrib/surveys/browser/nps.contribution.js'; +import '../workbench/contrib/surveys/browser/languageSurveys.contribution.js'; + +// Welcome +import '../workbench/contrib/welcomeGettingStarted/browser/gettingStarted.contribution.js'; +import '../workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.contribution.js'; +import '../workbench/contrib/welcomeWalkthrough/browser/walkThrough.contribution.js'; +import '../workbench/contrib/welcomeViews/common/viewsWelcome.contribution.js'; +import '../workbench/contrib/welcomeViews/common/newFile.contribution.js'; + +// Call Hierarchy +import '../workbench/contrib/callHierarchy/browser/callHierarchy.contribution.js'; + +// Type Hierarchy +import '../workbench/contrib/typeHierarchy/browser/typeHierarchy.contribution.js'; + +// Outline +import '../workbench/contrib/codeEditor/browser/outline/documentSymbolsOutline.js'; +import '../workbench/contrib/outline/browser/outline.contribution.js'; + +// Language Detection +import '../workbench/contrib/languageDetection/browser/languageDetection.contribution.js'; + +// Language Status +import '../workbench/contrib/languageStatus/browser/languageStatus.contribution.js'; + +// Authentication +import '../workbench/contrib/authentication/browser/authentication.contribution.js'; + +// User Data Sync +import '../workbench/contrib/userDataSync/browser/userDataSync.contribution.js'; + +// User Data Profiles +import '../workbench/contrib/userDataProfile/browser/userDataProfile.contribution.js'; + +// Continue Edit Session +import '../workbench/contrib/editSessions/browser/editSessions.contribution.js'; + +// Remote Coding Agents +import '../workbench/contrib/remoteCodingAgents/browser/remoteCodingAgents.contribution.js'; + +// Code Actions +import '../workbench/contrib/codeActions/browser/codeActions.contribution.js'; + +// Timeline +import '../workbench/contrib/timeline/browser/timeline.contribution.js'; + +// Local History +import '../workbench/contrib/localHistory/browser/localHistory.contribution.js'; + +// Workspace +import '../workbench/contrib/workspace/browser/workspace.contribution.js'; + +// Workspaces +import '../workbench/contrib/workspaces/browser/workspaces.contribution.js'; + +// List +import '../workbench/contrib/list/browser/list.contribution.js'; + +// Accessibility Signals +import '../workbench/contrib/accessibilitySignals/browser/accessibilitySignal.contribution.js'; + +// Bracket Pair Colorizer 2 Telemetry +import '../workbench/contrib/bracketPairColorizer2Telemetry/browser/bracketPairColorizer2Telemetry.contribution.js'; + +// Accessibility +import '../workbench/contrib/accessibility/browser/accessibility.contribution.js'; + +// Metered Connection +import '../workbench/contrib/meteredConnection/browser/meteredConnection.contribution.js'; + +// Share +import '../workbench/contrib/share/browser/share.contribution.js'; + +// Synchronized Scrolling +import '../workbench/contrib/scrollLocking/browser/scrollLocking.contribution.js'; + +// Inline Completions +import '../workbench/contrib/inlineCompletions/browser/inlineCompletions.contribution.js'; + +// Drop or paste into +import '../workbench/contrib/dropOrPasteInto/browser/dropOrPasteInto.contribution.js'; + +// Edit Telemetry +import '../workbench/contrib/editTelemetry/browser/editTelemetry.contribution.js'; + +// Opener +import '../workbench/contrib/opener/browser/opener.contribution.js'; + +//#endregion diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts new file mode 100644 index 0000000000000..f690a68b72e12 --- /dev/null +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -0,0 +1,198 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import './sessions.common.main.js'; + +//#region --- workbench (agentic desktop main) + +import './electron-browser/sessions.main.js'; +import '../workbench/electron-browser/desktop.contribution.js'; + +//#endregion + +//#region --- workbench parts + +import '../workbench/electron-browser/parts/dialogs/dialog.contribution.js'; + +//#endregion + + +//#region --- workbench services + +import '../workbench/services/textfile/electron-browser/nativeTextFileService.js'; +import '../workbench/services/dialogs/electron-browser/fileDialogService.js'; +import '../workbench/services/workspaces/electron-browser/workspacesService.js'; +import '../workbench/services/menubar/electron-browser/menubarService.js'; +import '../workbench/services/update/electron-browser/updateService.js'; +import '../workbench/services/url/electron-browser/urlService.js'; +import '../workbench/services/lifecycle/electron-browser/lifecycleService.js'; +import '../workbench/services/host/electron-browser/nativeHostService.js'; +import '../platform/meteredConnection/electron-browser/meteredConnectionService.js'; +import '../workbench/services/request/electron-browser/requestService.js'; +import '../workbench/services/clipboard/electron-browser/clipboardService.js'; +import '../workbench/services/contextmenu/electron-browser/contextmenuService.js'; +import '../workbench/services/workspaces/electron-browser/workspaceEditingService.js'; +import '../workbench/services/configurationResolver/electron-browser/configurationResolverService.js'; +import '../workbench/services/accessibility/electron-browser/accessibilityService.js'; +import '../workbench/services/keybinding/electron-browser/nativeKeyboardLayout.js'; +import '../workbench/services/path/electron-browser/pathService.js'; +import '../workbench/services/themes/electron-browser/nativeHostColorSchemeService.js'; +import '../workbench/services/extensionManagement/electron-browser/extensionManagementService.js'; +import '../workbench/services/mcp/electron-browser/mcpGalleryManifestService.js'; +import '../workbench/services/mcp/electron-browser/mcpWorkbenchManagementService.js'; +import '../workbench/services/encryption/electron-browser/encryptionService.js'; +import '../workbench/services/imageResize/electron-browser/imageResizeService.js'; +import '../workbench/services/browserElements/electron-browser/browserElementsService.js'; +import '../workbench/services/secrets/electron-browser/secretStorageService.js'; +import '../workbench/services/localization/electron-browser/languagePackService.js'; +import '../workbench/services/telemetry/electron-browser/telemetryService.js'; +import '../workbench/services/extensions/electron-browser/extensionHostStarter.js'; +import '../platform/extensionResourceLoader/common/extensionResourceLoaderService.js'; +import '../workbench/services/localization/electron-browser/localeService.js'; +import '../workbench/services/extensions/electron-browser/extensionsScannerService.js'; +import '../workbench/services/extensionManagement/electron-browser/extensionManagementServerService.js'; +import '../workbench/services/extensionManagement/electron-browser/extensionGalleryManifestService.js'; +import '../workbench/services/extensionManagement/electron-browser/extensionTipsService.js'; +import '../workbench/services/userDataSync/electron-browser/userDataSyncService.js'; +import '../workbench/services/userDataSync/electron-browser/userDataAutoSyncService.js'; +import '../workbench/services/timer/electron-browser/timerService.js'; +import '../workbench/services/environment/electron-browser/shellEnvironmentService.js'; +import '../workbench/services/integrity/electron-browser/integrityService.js'; +import '../workbench/services/workingCopy/electron-browser/workingCopyBackupService.js'; +import '../workbench/services/checksum/electron-browser/checksumService.js'; +import '../platform/remote/electron-browser/sharedProcessTunnelService.js'; +import '../workbench/services/tunnel/electron-browser/tunnelService.js'; +import '../platform/diagnostics/electron-browser/diagnosticsService.js'; +import '../platform/profiling/electron-browser/profilingService.js'; +import '../platform/telemetry/electron-browser/customEndpointTelemetryService.js'; +import '../platform/remoteTunnel/electron-browser/remoteTunnelService.js'; +import '../workbench/services/files/electron-browser/elevatedFileService.js'; +import '../workbench/services/search/electron-browser/searchService.js'; +import '../workbench/services/workingCopy/electron-browser/workingCopyHistoryService.js'; +import '../workbench/services/userDataSync/browser/userDataSyncEnablementService.js'; +import '../workbench/services/extensions/electron-browser/nativeExtensionService.js'; +import '../platform/userDataProfile/electron-browser/userDataProfileStorageService.js'; +import '../workbench/services/auxiliaryWindow/electron-browser/auxiliaryWindowService.js'; +import '../platform/extensionManagement/electron-browser/extensionsProfileScannerService.js'; +import '../platform/webContentExtractor/electron-browser/webContentExtractorService.js'; +import '../workbench/services/process/electron-browser/processService.js'; +import '../workbench/services/power/electron-browser/powerService.js'; + +import { registerSingleton } from '../platform/instantiation/common/extensions.js'; +import { IUserDataInitializationService, UserDataInitializationService } from '../workbench/services/userData/browser/userDataInit.js'; +import { SyncDescriptor } from '../platform/instantiation/common/descriptors.js'; + +registerSingleton(IUserDataInitializationService, new SyncDescriptor(UserDataInitializationService, [[]], true)); + + +//#endregion + + +//#region --- workbench contributions + +// Logs +import '../workbench/contrib/logs/electron-browser/logs.contribution.js'; + +// Localizations +import '../workbench/contrib/localization/electron-browser/localization.contribution.js'; + +// Explorer +import '../workbench/contrib/files/electron-browser/fileActions.contribution.js'; + +// CodeEditor Contributions +import '../workbench/contrib/codeEditor/electron-browser/codeEditor.contribution.js'; + +// Debug +import '../workbench/contrib/debug/electron-browser/extensionHostDebugService.js'; + +// Extensions Management +import '../workbench/contrib/extensions/electron-browser/extensions.contribution.js'; + +// Issues +import '../workbench/contrib/issue/electron-browser/issue.contribution.js'; + +// Process Explorer +import '../workbench/contrib/processExplorer/electron-browser/processExplorer.contribution.js'; + +// Remote +import '../workbench/contrib/remote/electron-browser/remote.contribution.js'; + +// Terminal +import '../workbench/contrib/terminal/electron-browser/terminal.contribution.js'; + +// Themes +import '../workbench/contrib/themes/browser/themes.test.contribution.js'; +import '../workbench/services/themes/electron-browser/themes.contribution.js'; +// User Data Sync +import '../workbench/contrib/userDataSync/electron-browser/userDataSync.contribution.js'; + +// Tags +import '../workbench/contrib/tags/electron-browser/workspaceTagsService.js'; +import '../workbench/contrib/tags/electron-browser/tags.contribution.js'; +// Performance +import '../workbench/contrib/performance/electron-browser/performance.contribution.js'; + +// Tasks +import '../workbench/contrib/tasks/electron-browser/taskService.js'; + +// External terminal +import '../workbench/contrib/externalTerminal/electron-browser/externalTerminal.contribution.js'; + +// Webview +import '../workbench/contrib/webview/electron-browser/webview.contribution.js'; + +// Browser +import '../workbench/contrib/browserView/electron-browser/browserView.contribution.js'; + +// Splash +import '../workbench/contrib/splash/electron-browser/splash.contribution.js'; + +// Local History +import '../workbench/contrib/localHistory/electron-browser/localHistory.contribution.js'; + +// Merge Editor +import '../workbench/contrib/mergeEditor/electron-browser/mergeEditor.contribution.js'; + +// Multi Diff Editor +import '../workbench/contrib/multiDiffEditor/browser/multiDiffEditor.contribution.js'; + +// Remote Tunnel +import '../workbench/contrib/remoteTunnel/electron-browser/remoteTunnel.contribution.js'; + +// Chat +import '../workbench/contrib/chat/electron-browser/chat.contribution.js'; +import '../workbench/contrib/inlineChat/electron-browser/inlineChat.contribution.js'; +// Encryption +import '../workbench/contrib/encryption/electron-browser/encryption.contribution.js'; + +// Emergency Alert +import '../workbench/contrib/emergencyAlert/electron-browser/emergencyAlert.contribution.js'; + +// MCP +import '../workbench/contrib/mcp/electron-browser/mcp.contribution.js'; + +// Policy Export +import '../workbench/contrib/policyExport/electron-browser/policyExport.contribution.js'; + +//#endregion + + +//#region --- sessions contributions + +import './browser/paneCompositePartService.js'; +import './browser/layoutActions.js'; + +import './contrib/accountMenu/browser/account.contribution.js'; +import './contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.js'; +import './contrib/aiCustomizationManagement/browser/aiCustomizationManagement.contribution.js'; +import './contrib/chat/browser/chat.contribution.js'; +import './contrib/sessions/browser/sessions.contribution.js'; +import './contrib/changesView/browser/changesView.contribution.js'; +import './contrib/configuration/browser/configuration.contribution.js'; + +//#endregion + +export { main } from './electron-browser/sessions.main.js'; diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 130f387072c4d..74800fefdee70 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -39,7 +39,7 @@ import { ILanguageModelToolsService } from '../../contrib/chat/common/tools/lang import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; import { IExtensionService } from '../../services/extensions/common/extensions.js'; import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; -import { ExtHostChatAgentsShape2, ExtHostContext, IChatNotebookEditDto, IChatParticipantMetadata, IChatProgressDto, IDynamicChatAgentProps, IExtensionChatAgentMetadata, MainContext, MainThreadChatAgentsShape2 } from '../common/extHost.protocol.js'; +import { ExtHostChatAgentsShape2, ExtHostContext, IChatNotebookEditDto, IChatParticipantMetadata, IChatProgressDto, IChatSessionContextDto, IDynamicChatAgentProps, IExtensionChatAgentMetadata, MainContext, MainThreadChatAgentsShape2 } from '../common/extHost.protocol.js'; import { NotebookDto } from './mainThreadNotebookDto.js'; interface AgentData { @@ -194,9 +194,21 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA const chatSession = this._chatService.getSession(request.sessionResource); this._pendingProgress.set(request.requestId, { progress, chatSession }); try { + const contributedSession = chatSession?.contributedChatSession; + let chatSessionContext: IChatSessionContextDto | undefined; + if (contributedSession) { + chatSessionContext = { + chatSessionResource: contributedSession.chatSessionResource, + isUntitled: contributedSession.isUntitled, + initialSessionOptions: contributedSession.initialSessionOptions?.map(o => ({ + optionId: o.optionId, + value: typeof o.value === 'string' ? o.value : o.value.id, + })), + }; + } return await this._proxy.$invokeAgent(handle, request, { history, - chatSessionContext: chatSession?.contributedChatSession + chatSessionContext, }, token) ?? {}; } finally { this._pendingProgress.delete(request.requestId); diff --git a/src/vs/workbench/api/browser/mainThreadWorkspace.ts b/src/vs/workbench/api/browser/mainThreadWorkspace.ts index 30ebb3092893f..02a2e749a1cf0 100644 --- a/src/vs/workbench/api/browser/mainThreadWorkspace.ts +++ b/src/vs/workbench/api/browser/mainThreadWorkspace.ts @@ -140,7 +140,6 @@ export class MainThreadWorkspace implements MainThreadWorkspaceShape { id: workspace.id, name: this._labelService.getWorkspaceLabel(workspace), transient: workspace.transient, - isAgentSessionsWorkspace: workspace.isAgentSessionsWorkspace }; } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index b8e9775191ade..8cdf04941f63c 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1086,7 +1086,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I }, get isAgentSessionsWorkspace() { checkProposedApiEnabled(extension, 'agentSessionsWorkspace'); - return extHostWorkspace.isAgentSessionsWorkspace; + return !!initData.environment.isSessionsWindow; }, updateWorkspaceFolders: (index, deleteCount, ...workspaceFoldersToAdd) => { return extHostWorkspace.updateWorkspaceFolders(extension, index, deleteCount || 0, ...workspaceFoldersToAdd); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 4816227458396..2025e31c43636 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1460,6 +1460,7 @@ export type IChatAgentHistoryEntryDto = { export interface IChatSessionContextDto { readonly chatSessionResource: UriComponents; readonly isUntitled: boolean; + readonly initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string }>; } export interface ExtHostChatAgentsShape2 { diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index bbf66df837fdc..888128faa32fd 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -755,6 +755,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS label: context.chatSessionContext.isUntitled ? 'Untitled Session' : 'Session', }, isUntitled: context.chatSessionContext.isUntitled, + initialSessionOptions: context.chatSessionContext.initialSessionOptions, }; } diff --git a/src/vs/workbench/api/common/extHostWorkspace.ts b/src/vs/workbench/api/common/extHostWorkspace.ts index 55ca51e9a6445..3b01c6c8ac630 100644 --- a/src/vs/workbench/api/common/extHostWorkspace.ts +++ b/src/vs/workbench/api/common/extHostWorkspace.ts @@ -95,7 +95,7 @@ class ExtHostWorkspaceImpl extends Workspace { return { workspace: null, added: [], removed: [] }; } - const { id, name, folders, configuration, transient, isUntitled, isAgentSessionsWorkspace } = data; + const { id, name, folders, configuration, transient, isUntitled } = data; const newWorkspaceFolders: vscode.WorkspaceFolder[] = []; // If we have an existing workspace, we try to find the folders that match our @@ -123,7 +123,7 @@ class ExtHostWorkspaceImpl extends Workspace { // make sure to restore sort order based on index newWorkspaceFolders.sort((f1, f2) => f1.index < f2.index ? -1 : 1); - const workspace = new ExtHostWorkspaceImpl(id, name, newWorkspaceFolders, !!transient, configuration ? URI.revive(configuration) : null, !!isUntitled, !!isAgentSessionsWorkspace, uri => ignorePathCasing(uri, extHostFileSystemInfo)); + const workspace = new ExtHostWorkspaceImpl(id, name, newWorkspaceFolders, !!transient, configuration ? URI.revive(configuration) : null, !!isUntitled, uri => ignorePathCasing(uri, extHostFileSystemInfo)); const { added, removed } = delta(oldWorkspace ? oldWorkspace.workspaceFolders : [], workspace.workspaceFolders, compareWorkspaceFolderByUri, extHostFileSystemInfo); return { workspace, added, removed }; @@ -143,8 +143,8 @@ class ExtHostWorkspaceImpl extends Workspace { private readonly _workspaceFolders: vscode.WorkspaceFolder[] = []; private readonly _structure: TernarySearchTree; - constructor(id: string, private _name: string, folders: vscode.WorkspaceFolder[], transient: boolean, configuration: URI | null, private _isUntitled: boolean, isAgentSessionsWorkspace: boolean, ignorePathCasing: (key: URI) => boolean) { - super(id, folders.map(f => new WorkspaceFolder(f)), transient, configuration, ignorePathCasing, isAgentSessionsWorkspace); + constructor(id: string, private _name: string, folders: vscode.WorkspaceFolder[], transient: boolean, configuration: URI | null, private _isUntitled: boolean, ignorePathCasing: (key: URI) => boolean) { + super(id, folders.map(f => new WorkspaceFolder(f)), transient, configuration, ignorePathCasing); this._structure = TernarySearchTree.forUris(ignorePathCasing, () => true); // setup the workspace folder data structure @@ -226,7 +226,7 @@ export class ExtHostWorkspace implements ExtHostWorkspaceShape, IExtHostWorkspac this._proxy = extHostRpc.getProxy(MainContext.MainThreadWorkspace); this._messageService = extHostRpc.getProxy(MainContext.MainThreadMessageService); const data = initData.workspace; - this._confirmedWorkspace = data ? new ExtHostWorkspaceImpl(data.id, data.name, [], !!data.transient, data.configuration ? URI.revive(data.configuration) : null, !!data.isUntitled, !!data.isAgentSessionsWorkspace, uri => ignorePathCasing(uri, extHostFileSystemInfo)) : undefined; + this._confirmedWorkspace = data ? new ExtHostWorkspaceImpl(data.id, data.name, [], !!data.transient, data.configuration ? URI.revive(data.configuration) : null, !!data.isUntitled, uri => ignorePathCasing(uri, extHostFileSystemInfo)) : undefined; } $initializeWorkspace(data: IWorkspaceData | null, trusted: boolean): void { @@ -249,10 +249,6 @@ export class ExtHostWorkspace implements ExtHostWorkspaceShape, IExtHostWorkspac return this._actualWorkspace ? this._actualWorkspace.name : undefined; } - get isAgentSessionsWorkspace(): boolean { - return this._actualWorkspace?.isAgentSessionsWorkspace ?? false; - } - get workspaceFile(): vscode.Uri | undefined { if (this._actualWorkspace) { if (this._actualWorkspace.configuration) { diff --git a/src/vs/workbench/browser/contextkeys.ts b/src/vs/workbench/browser/contextkeys.ts index 366e575955898..16c7e5e226710 100644 --- a/src/vs/workbench/browser/contextkeys.ts +++ b/src/vs/workbench/browser/contextkeys.ts @@ -6,7 +6,7 @@ import { Disposable } from '../../base/common/lifecycle.js'; import { IContextKeyService, IContextKey, setConstant as setConstantContextKey } from '../../platform/contextkey/common/contextkey.js'; import { IsMacContext, IsLinuxContext, IsWindowsContext, IsWebContext, IsMacNativeContext, IsDevelopmentContext, IsIOSContext, ProductQualityContext, IsMobileContext } from '../../platform/contextkey/common/contextkeys.js'; -import { SplitEditorsVertically, InEditorZenModeContext, AuxiliaryBarVisibleContext, SideBarVisibleContext, PanelAlignmentContext, PanelMaximizedContext, PanelVisibleContext, EmbedderIdentifierContext, EditorTabsVisibleContext, IsMainEditorCenteredLayoutContext, MainEditorAreaVisibleContext, DirtyWorkingCopiesContext, EmptyWorkspaceSupportContext, EnterMultiRootWorkspaceSupportContext, HasWebFileSystemAccess, IsMainWindowFullscreenContext, OpenFolderWorkspaceSupportContext, RemoteNameContext, VirtualWorkspaceContext, WorkbenchStateContext, WorkspaceFolderCountContext, PanelPositionContext, TemporaryWorkspaceContext, TitleBarVisibleContext, TitleBarStyleContext, IsAuxiliaryWindowFocusedContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupIndexContext, ActiveEditorGroupLastContext, ActiveEditorGroupLockedContext, MultipleEditorGroupsContext, EditorsVisibleContext, AuxiliaryBarMaximizedContext, InAutomationContext, IsAgentSessionsWorkspaceContext } from '../common/contextkeys.js'; +import { SplitEditorsVertically, InEditorZenModeContext, AuxiliaryBarVisibleContext, SideBarVisibleContext, PanelAlignmentContext, PanelMaximizedContext, PanelVisibleContext, EmbedderIdentifierContext, EditorTabsVisibleContext, IsMainEditorCenteredLayoutContext, MainEditorAreaVisibleContext, DirtyWorkingCopiesContext, EmptyWorkspaceSupportContext, EnterMultiRootWorkspaceSupportContext, HasWebFileSystemAccess, IsMainWindowFullscreenContext, OpenFolderWorkspaceSupportContext, RemoteNameContext, VirtualWorkspaceContext, WorkbenchStateContext, WorkspaceFolderCountContext, PanelPositionContext, TemporaryWorkspaceContext, TitleBarVisibleContext, TitleBarStyleContext, IsAuxiliaryWindowFocusedContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupIndexContext, ActiveEditorGroupLastContext, ActiveEditorGroupLockedContext, MultipleEditorGroupsContext, EditorsVisibleContext, AuxiliaryBarMaximizedContext, InAutomationContext, IsSessionsWindowContext } from '../common/contextkeys.js'; import { preferredSideBySideGroupDirection, GroupDirection, IEditorGroupsService } from '../services/editor/common/editorGroupsService.js'; import { IConfigurationService } from '../../platform/configuration/common/configuration.js'; import { IWorkbenchEnvironmentService } from '../services/environment/common/environmentService.js'; @@ -47,7 +47,7 @@ export class WorkbenchContextKeysHandler extends Disposable { private virtualWorkspaceContext: IContextKey; private temporaryWorkspaceContext: IContextKey; - private isAgentSessionsWorkspaceContext: IContextKey; + private isSessionsWindowContext: IContextKey; private inAutomationContext: IContextKey; private inZenModeContext: IContextKey; @@ -94,8 +94,8 @@ export class WorkbenchContextKeysHandler extends Disposable { this.virtualWorkspaceContext = VirtualWorkspaceContext.bindTo(this.contextKeyService); this.temporaryWorkspaceContext = TemporaryWorkspaceContext.bindTo(this.contextKeyService); - this.isAgentSessionsWorkspaceContext = IsAgentSessionsWorkspaceContext.bindTo(this.contextKeyService); - this.isAgentSessionsWorkspaceContext.set(!!this.contextService.getWorkspace().isAgentSessionsWorkspace); + this.isSessionsWindowContext = IsSessionsWindowContext.bindTo(this.contextKeyService); + this.isSessionsWindowContext.set(this.environmentService.isSessionsWindow); this.updateWorkspaceContextKeys(); // Capabilities diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index b51e004b0457f..89f0b6d3b555a 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -1597,7 +1597,7 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.auxiliaryBarPartView = auxiliaryBarPart; this.statusBarPartView = statusBar; - const viewMap = { + const viewMap: Record = { [Parts.ACTIVITYBAR_PART]: this.activityBarPartView, [Parts.BANNER_PART]: this.bannerPartView, [Parts.TITLEBAR_PART]: this.titleBarPartView, diff --git a/src/vs/workbench/browser/panecomposite.ts b/src/vs/workbench/browser/panecomposite.ts index fe21eedbcb718..12407ea94a0bc 100644 --- a/src/vs/workbench/browser/panecomposite.ts +++ b/src/vs/workbench/browser/panecomposite.ts @@ -193,6 +193,7 @@ export const Extensions = { Viewlets: 'workbench.contributions.viewlets', Panels: 'workbench.contributions.panels', Auxiliary: 'workbench.contributions.auxiliary', + ChatBar: 'workbench.contributions.chatbar', }; export class PaneCompositeRegistry extends CompositeRegistry { @@ -229,3 +230,4 @@ export class PaneCompositeRegistry extends CompositeRegistry { Registry.add(Extensions.Viewlets, new PaneCompositeRegistry()); Registry.add(Extensions.Panels, new PaneCompositeRegistry()); Registry.add(Extensions.Auxiliary, new PaneCompositeRegistry()); +Registry.add(Extensions.ChatBar, new PaneCompositeRegistry()); diff --git a/src/vs/workbench/common/contextkeys.ts b/src/vs/workbench/common/contextkeys.ts index 50cc224cfc236..c0348746b6ee1 100644 --- a/src/vs/workbench/common/contextkeys.ts +++ b/src/vs/workbench/common/contextkeys.ts @@ -33,7 +33,7 @@ export const RemoteNameContext = new RawContextKey('remoteName', '', loc export const VirtualWorkspaceContext = new RawContextKey('virtualWorkspace', '', localize('virtualWorkspace', "The scheme of the current workspace is from a virtual file system or an empty string.")); export const TemporaryWorkspaceContext = new RawContextKey('temporaryWorkspace', false, localize('temporaryWorkspace', "The scheme of the current workspace is from a temporary file system.")); -export const IsAgentSessionsWorkspaceContext = new RawContextKey('isAgentSessionsWorkspace', false, localize('isAgentSessionsWorkspace', "Whether the current workspace is the agent sessions workspace.")); +export const IsSessionsWindowContext = new RawContextKey('isSessionsWindow', false, localize('isSessionsWindow', "Whether the current window is a sessions window.")); export const HasWebFileSystemAccess = new RawContextKey('hasWebFileSystemAccess', false, true); // Support for FileSystemAccess web APIs (https://wicg.github.io/file-system-access) diff --git a/src/vs/workbench/common/views.ts b/src/vs/workbench/common/views.ts index 71d443000734e..b439e8700356e 100644 --- a/src/vs/workbench/common/views.ts +++ b/src/vs/workbench/common/views.ts @@ -39,7 +39,8 @@ export namespace Extensions { export const enum ViewContainerLocation { Sidebar, Panel, - AuxiliaryBar + AuxiliaryBar, + ChatBar, } export function ViewContainerLocationToString(viewContainerLocation: ViewContainerLocation) { @@ -47,6 +48,7 @@ export function ViewContainerLocationToString(viewContainerLocation: ViewContain case ViewContainerLocation.Sidebar: return 'sidebar'; case ViewContainerLocation.Panel: return 'panel'; case ViewContainerLocation.AuxiliaryBar: return 'auxiliarybar'; + case ViewContainerLocation.ChatBar: return 'chatbar'; } } @@ -58,6 +60,24 @@ type OpenCommandActionDescriptor = { readonly keybindings?: IKeybindings & { when?: ContextKeyExpression }; }; +/** + * Specifies in which window a view or view container should be visible. + */ +export const enum WindowVisibility { + /** + * Visible only in the editor window + */ + Editor = 1, + /** + * Visible only in sessions window + */ + Sessions = 2, + /** + * Visible in both editor and sessions windows + */ + Both = 3, +} + /** * View Container Contexts */ @@ -119,6 +139,12 @@ export interface IViewContainerDescriptor { readonly rejectAddedViews?: boolean; + /** + * Specifies in which window this view container should be visible. + * Defaults to WindowVisibility.Editor + */ + readonly windowVisibility?: WindowVisibility; + requestedIndex?: number; } @@ -299,6 +325,12 @@ export interface IViewDescriptor { readonly openCommandActionDescriptor?: OpenCommandActionDescriptor; readonly accessibilityHelpContent?: MarkdownString; + + /** + * Specifies in which window this view should be visible. + * Defaults to WindowVisibility.Workbench (main workbench only). + */ + readonly windowVisibility?: WindowVisibility; } export interface ICustomViewDescriptor extends IViewDescriptor { diff --git a/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackAttachment.ts b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackAttachment.ts new file mode 100644 index 0000000000000..b942e505b6c92 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackAttachment.ts @@ -0,0 +1,166 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableMap } from '../../../../../base/common/lifecycle.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { basename } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IRange } from '../../../../../editor/common/core/range.js'; +import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { localize } from '../../../../../nls.js'; +import { IAgentFeedbackService, IAgentFeedback } from './agentFeedbackService.js'; +import { IChatWidgetService } from '../chat.js'; +import { IAgentFeedbackVariableEntry } from '../../common/attachments/chatVariableEntries.js'; + +const ATTACHMENT_ID_PREFIX = 'agentFeedback:'; + +/** + * Keeps the "N feedback items" attachment in the chat input in sync with the + * AgentFeedbackService. One attachment per session resource, updated reactively. + * Clears feedback after the chat prompt is sent. + */ +export class AgentFeedbackAttachmentContribution extends Disposable { + + static readonly ID = 'workbench.contrib.agentFeedbackAttachment'; + + /** Track onDidAcceptInput subscriptions per widget session */ + private readonly _widgetListeners = this._store.add(new DisposableMap()); + + /** Cache of resolved code snippets keyed by feedback ID */ + private readonly _snippetCache = new Map(); + + constructor( + @IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService, + @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, + @ITextModelService private readonly _textModelService: ITextModelService, + ) { + super(); + + this._store.add(this._agentFeedbackService.onDidChangeFeedback(e => { + this._updateAttachment(e.sessionResource); + this._ensureAcceptListener(e.sessionResource); + })); + } + + private async _updateAttachment(sessionResource: URI): Promise { + const widget = this._chatWidgetService.getWidgetBySessionResource(sessionResource); + if (!widget) { + return; + } + + const feedbackItems = this._agentFeedbackService.getFeedback(sessionResource); + const attachmentId = ATTACHMENT_ID_PREFIX + sessionResource.toString(); + + if (feedbackItems.length === 0) { + widget.attachmentModel.delete(attachmentId); + this._snippetCache.clear(); + return; + } + + const value = await this._buildFeedbackValue(feedbackItems); + + const entry: IAgentFeedbackVariableEntry = { + kind: 'agentFeedback', + id: attachmentId, + name: feedbackItems.length === 1 + ? localize('agentFeedback.one', "1 comment") + : localize('agentFeedback.many', "{0} comments", feedbackItems.length), + icon: Codicon.comment, + sessionResource, + feedbackItems: feedbackItems.map(f => ({ + id: f.id, + text: f.text, + resourceUri: f.resourceUri, + range: f.range, + })), + value, + }; + + // Upsert + widget.attachmentModel.delete(attachmentId); + widget.attachmentModel.addContext(entry); + } + + /** + * Builds a rich string value for the agent feedback attachment that includes + * the code snippet at each feedback item's location alongside the feedback text. + * Uses a cache keyed by feedback ID to avoid re-resolving snippets for + * items that haven't changed. + */ + private async _buildFeedbackValue(feedbackItems: readonly IAgentFeedback[]): Promise { + // Prune stale cache entries for items that no longer exist + const currentIds = new Set(feedbackItems.map(f => f.id)); + for (const cachedId of this._snippetCache.keys()) { + if (!currentIds.has(cachedId)) { + this._snippetCache.delete(cachedId); + } + } + + // Resolve only new (uncached) snippets + const uncachedItems = feedbackItems.filter(f => !this._snippetCache.has(f.id)); + if (uncachedItems.length > 0) { + await Promise.all(uncachedItems.map(async f => { + const snippet = await this._getCodeSnippet(f.resourceUri, f.range); + this._snippetCache.set(f.id, snippet); + })); + } + + // Build the final string from cache + const parts: string[] = ['The following comments were made on the code changes:']; + for (const item of feedbackItems) { + const codeSnippet = this._snippetCache.get(item.id); + const fileName = basename(item.resourceUri); + const lineRef = item.range.startLineNumber === item.range.endLineNumber + ? `${item.range.startLineNumber}` + : `${item.range.startLineNumber}-${item.range.endLineNumber}`; + + let part = `[${fileName}:${lineRef}]`; + if (codeSnippet) { + part += `\n\`\`\`\n${codeSnippet}\n\`\`\``; + } + part += `\nComment: ${item.text}`; + parts.push(part); + } + + return parts.join('\n\n'); + } + + /** + * Resolves the text model for a resource and extracts the code in the given range. + * Returns undefined if the model cannot be resolved. + */ + private async _getCodeSnippet(resourceUri: URI, range: IRange): Promise { + try { + const ref = await this._textModelService.createModelReference(resourceUri); + try { + return ref.object.textEditorModel.getValueInRange(range); + } finally { + ref.dispose(); + } + } catch { + return undefined; + } + } + + /** + * Ensure we listen for the chat widget's submit event so we can clear feedback after send. + */ + private _ensureAcceptListener(sessionResource: URI): void { + const key = sessionResource.toString(); + if (this._widgetListeners.has(key)) { + return; + } + + const widget = this._chatWidgetService.getWidgetBySessionResource(sessionResource); + if (!widget) { + return; + } + + this._widgetListeners.set(key, widget.onDidSubmitAgent(() => { + this._agentFeedbackService.clearFeedback(sessionResource); + this._widgetListeners.deleteAndDispose(key); + })); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackAttachmentWidget.ts b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackAttachmentWidget.ts new file mode 100644 index 0000000000000..290e3f2637b11 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackAttachmentWidget.ts @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/agentFeedbackAttachment.css'; +import * as dom from '../../../../../base/browser/dom.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import * as event from '../../../../../base/common/event.js'; +import { localize } from '../../../../../nls.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IAgentFeedbackVariableEntry } from '../../common/attachments/chatVariableEntries.js'; +import { AgentFeedbackHover } from './agentFeedbackHover.js'; + +/** + * Attachment widget that renders "N comments" with a comment icon + * and a custom hover showing all feedback items with actions. + */ +export class AgentFeedbackAttachmentWidget extends Disposable { + + readonly element: HTMLElement; + + private readonly _onDidDelete = this._store.add(new event.Emitter()); + readonly onDidDelete = this._onDidDelete.event; + + private readonly _onDidOpen = this._store.add(new event.Emitter()); + readonly onDidOpen = this._onDidOpen.event; + + constructor( + private readonly _attachment: IAgentFeedbackVariableEntry, + options: { shouldFocusClearButton: boolean; supportsDeletion: boolean }, + container: HTMLElement, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + + this.element = dom.append(container, dom.$('.chat-attached-context-attachment.agent-feedback-attachment')); + this.element.tabIndex = 0; + this.element.role = 'button'; + + // Icon + const iconSpan = dom.$('span'); + iconSpan.classList.add(...ThemeIcon.asClassNameArray(Codicon.comment)); + const pillIcon = dom.$('div.chat-attached-context-pill', {}, iconSpan); + this.element.appendChild(pillIcon); + + // Label + const label = dom.$('span.chat-attached-context-custom-text', {}, this._attachment.name); + this.element.appendChild(label); + + // Clear button + if (options.supportsDeletion) { + const clearBtn = dom.append(this.element, dom.$('.chat-attached-context-clear-button')); + const clearIcon = dom.$('span'); + clearIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.close)); + clearBtn.appendChild(clearIcon); + clearBtn.title = localize('removeAttachment', "Remove"); + this._store.add(dom.addDisposableListener(clearBtn, dom.EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this._onDidDelete.fire(e); + })); + if (options.shouldFocusClearButton) { + clearBtn.focus(); + } + } + + // Aria label + this.element.ariaLabel = localize('chat.agentFeedback', "Attached agent feedback, {0}", this._attachment.name); + + // Custom interactive hover + this._store.add(this._instantiationService.createInstance(AgentFeedbackHover, this.element, this._attachment)); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackEditorActions.ts b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackEditorActions.ts new file mode 100644 index 0000000000000..e64ffc5658e66 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackEditorActions.ts @@ -0,0 +1,182 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../../base/common/codicons.js'; +import { localize, localize2 } from '../../../../../nls.js'; +import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { isEqual } from '../../../../../base/common/resources.js'; +import { EditorsOrder, IEditorIdentifier } from '../../../../common/editor.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IChatWidgetService } from '../chat.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { CHAT_CATEGORY } from '../actions/chatActions.js'; +import { IAgentFeedbackService } from './agentFeedbackService.js'; +import { getActiveResourceCandidates } from './agentFeedbackEditorUtils.js'; + +export const submitFeedbackActionId = 'agentFeedbackEditor.action.submit'; +export const navigatePreviousFeedbackActionId = 'agentFeedbackEditor.action.navigatePrevious'; +export const navigateNextFeedbackActionId = 'agentFeedbackEditor.action.navigateNext'; +export const clearAllFeedbackActionId = 'agentFeedbackEditor.action.clearAll'; +export const navigationBearingFakeActionId = 'agentFeedbackEditor.navigation.bearings'; + +abstract class AgentFeedbackEditorAction extends Action2 { + + constructor(desc: ConstructorParameters[0]) { + super({ + category: CHAT_CATEGORY, + ...desc, + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const editorService = accessor.get(IEditorService); + const agentFeedbackService = accessor.get(IAgentFeedbackService); + + const candidates = getActiveResourceCandidates(editorService.activeEditorPane?.input); + const sessionResource = candidates + .map(candidate => agentFeedbackService.getMostRecentSessionForResource(candidate)) + .find((value): value is URI => !!value); + if (!sessionResource) { + return; + } + + return this.runWithSession(accessor, sessionResource); + } + + abstract runWithSession(accessor: ServicesAccessor, sessionResource: URI): Promise | void; +} + +class SubmitFeedbackAction extends AgentFeedbackEditorAction { + + constructor() { + super({ + id: submitFeedbackActionId, + title: localize2('agentFeedback.submit', 'Submit Feedback'), + shortTitle: localize2('agentFeedback.submitShort', 'Submit'), + icon: Codicon.send, + precondition: ChatContextKeys.enabled, + menu: { + id: MenuId.AgentFeedbackEditorContent, + group: 'a_submit', + order: 0, + when: ChatContextKeys.enabled, + }, + }); + } + + override async runWithSession(accessor: ServicesAccessor, sessionResource: URI): Promise { + const chatWidgetService = accessor.get(IChatWidgetService); + const agentFeedbackService = accessor.get(IAgentFeedbackService); + const editorService = accessor.get(IEditorService); + + const widget = chatWidgetService.getWidgetBySessionResource(sessionResource); + if (!widget) { + return; + } + + // Close all editors belonging to the session resource + const editorsToClose: IEditorIdentifier[] = []; + for (const { editor, groupId } of editorService.getEditors(EditorsOrder.SEQUENTIAL)) { + const candidates = getActiveResourceCandidates(editor); + const belongsToSession = candidates.some(uri => + isEqual(agentFeedbackService.getMostRecentSessionForResource(uri), sessionResource) + ); + if (belongsToSession) { + editorsToClose.push({ editor, groupId }); + } + } + if (editorsToClose.length) { + await editorService.closeEditors(editorsToClose); + } + + await widget.acceptInput('Act on the provided feedback'); + } +} + +class NavigateFeedbackAction extends AgentFeedbackEditorAction { + + constructor(private readonly _next: boolean) { + super({ + id: _next ? navigateNextFeedbackActionId : navigatePreviousFeedbackActionId, + title: _next + ? localize2('agentFeedback.next', 'Go to Next Feedback Comment') + : localize2('agentFeedback.previous', 'Go to Previous Feedback Comment'), + icon: _next ? Codicon.arrowDown : Codicon.arrowUp, + f1: true, + precondition: ChatContextKeys.enabled, + menu: { + id: MenuId.AgentFeedbackEditorContent, + group: 'navigate', + order: _next ? 2 : 1, + when: ChatContextKeys.enabled, + }, + }); + } + + override runWithSession(accessor: ServicesAccessor, sessionResource: URI): void { + const agentFeedbackService = accessor.get(IAgentFeedbackService); + const editorService = accessor.get(IEditorService); + + const feedback = agentFeedbackService.getNextFeedback(sessionResource, this._next); + if (!feedback) { + return; + } + + editorService.openEditor({ + resource: feedback.resourceUri, + options: { + selection: feedback.range, + preserveFocus: false, + revealIfVisible: true, + } + }); + } +} + +class ClearAllFeedbackAction extends AgentFeedbackEditorAction { + + constructor() { + super({ + id: clearAllFeedbackActionId, + title: localize2('agentFeedback.clear', 'Clear'), + tooltip: localize2('agentFeedback.clearAllTooltip', 'Clear All Feedback'), + icon: Codicon.clearAll, + f1: true, + precondition: ContextKeyExpr.and(ChatContextKeys.enabled), + menu: { + id: MenuId.AgentFeedbackEditorContent, + group: 'a_submit', + order: 1, + when: ChatContextKeys.enabled, + }, + }); + } + + override runWithSession(accessor: ServicesAccessor, sessionResource: URI): void { + const agentFeedbackService = accessor.get(IAgentFeedbackService); + agentFeedbackService.clearFeedback(sessionResource); + } +} + +export function registerAgentFeedbackEditorActions(): void { + registerAction2(SubmitFeedbackAction); + registerAction2(class extends NavigateFeedbackAction { constructor() { super(false); } }); + registerAction2(class extends NavigateFeedbackAction { constructor() { super(true); } }); + registerAction2(ClearAllFeedbackAction); + + MenuRegistry.appendMenuItem(MenuId.AgentFeedbackEditorContent, { + command: { + id: navigationBearingFakeActionId, + title: localize('label', 'Navigation Status'), + precondition: ContextKeyExpr.false(), + }, + group: 'navigate', + order: -1, + when: ChatContextKeys.enabled, + }); +} diff --git a/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackEditorOverlay.ts b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackEditorOverlay.ts new file mode 100644 index 0000000000000..e0ba9f6c0a550 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackEditorOverlay.ts @@ -0,0 +1,253 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/agentFeedbackEditorOverlay.css'; +import { Disposable, DisposableMap, DisposableStore, combinedDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { autorun, observableFromEvent, observableSignalFromEvent, observableValue } from '../../../../../base/common/observable.js'; +import { ActionViewItem, IBaseActionViewItemOptions } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { IAction } from '../../../../../base/common/actions.js'; +import { Event } from '../../../../../base/common/event.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; +import { MenuId } from '../../../../../platform/actions/common/actions.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 { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { IWorkbenchContribution } from '../../../../common/contributions.js'; +import { EditorGroupView } from '../../../../browser/parts/editor/editorGroupView.js'; +import { IEditorGroup, IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; +import { IAgentFeedbackService } from './agentFeedbackService.js'; +import { navigateNextFeedbackActionId, navigatePreviousFeedbackActionId, navigationBearingFakeActionId, submitFeedbackActionId } from './agentFeedbackEditorActions.js'; +import { assertType } from '../../../../../base/common/types.js'; +import { localize } from '../../../../../nls.js'; +import { getActiveResourceCandidates } from './agentFeedbackEditorUtils.js'; + +class AgentFeedbackActionViewItem extends ActionViewItem { + + constructor( + action: IAction, + options: IBaseActionViewItemOptions, + private readonly _keybindingService: IKeybindingService, + private readonly _primaryActionIds: readonly string[] = [submitFeedbackActionId], + ) { + const isIconOnly = action.id === navigatePreviousFeedbackActionId || action.id === navigateNextFeedbackActionId; + super(undefined, action, { ...options, icon: isIconOnly, label: !isIconOnly, keybindingNotRenderedWithLabel: true }); + } + + override render(container: HTMLElement): void { + super.render(container); + if (this._primaryActionIds.includes(this._action.id)) { + this.element?.classList.add('primary'); + } + } + + protected override getTooltip(): string | undefined { + const value = super.getTooltip(); + if (!value || this.options.keybinding) { + return value; + } + return this._keybindingService.appendKeybinding(value, this._action.id); + } +} + +class AgentFeedbackOverlayWidget extends Disposable { + + private readonly _domNode: HTMLElement; + private readonly _toolbarNode: HTMLElement; + private readonly _showStore = this._store.add(new DisposableStore()); + private readonly _navigationBearings = observableValue<{ activeIdx: number; totalCount: number }>(this, { activeIdx: -1, totalCount: 0 }); + + constructor( + @IInstantiationService private readonly _instaService: IInstantiationService, + @IKeybindingService private readonly _keybindingService: IKeybindingService, + ) { + super(); + + this._domNode = document.createElement('div'); + this._domNode.classList.add('agent-feedback-editor-overlay-widget'); + + this._toolbarNode = document.createElement('div'); + this._toolbarNode.classList.add('agent-feedback-editor-overlay-toolbar'); + } + + getDomNode(): HTMLElement { + return this._domNode; + } + + show(navigationBearings: { activeIdx: number; totalCount: number }): void { + this._showStore.clear(); + this._navigationBearings.set(navigationBearings, undefined); + + if (!this._domNode.contains(this._toolbarNode)) { + this._domNode.appendChild(this._toolbarNode); + } + + this._showStore.add(this._instaService.createInstance(MenuWorkbenchToolBar, this._toolbarNode, MenuId.AgentFeedbackEditorContent, { + telemetrySource: 'agentFeedback.overlayToolbar', + hiddenItemStrategy: HiddenItemStrategy.Ignore, + toolbarOptions: { + primaryGroup: () => true, + useSeparatorsInPrimaryActions: true + }, + menuOptions: { renderShortTitle: true }, + actionViewItemProvider: (action, options) => { + if (action.id === navigationBearingFakeActionId) { + const that = this; + return new class extends ActionViewItem { + constructor() { + super(undefined, action, { ...options, icon: false, label: true, keybindingNotRenderedWithLabel: true }); + } + + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('label-item'); + + this._store.add(autorun(r => { + assertType(this.label); + const { activeIdx, totalCount } = that._navigationBearings.read(r); + if (totalCount > 0) { + const current = activeIdx === -1 ? 1 : activeIdx + 1; + this.label.innerText = localize('nOfM', '{0}/{1}', current, totalCount); + } else { + this.label.innerText = localize('zero', '0/0'); + } + })); + } + }; + } + + return new AgentFeedbackActionViewItem(action, options, this._keybindingService); + }, + })); + this._showStore.add(toDisposable(() => this._toolbarNode.remove())); + } + + hide(): void { + this._showStore.clear(); + this._navigationBearings.set({ activeIdx: -1, totalCount: 0 }, undefined); + this._toolbarNode.remove(); + } +} + +class AgentFeedbackOverlayController { + + private readonly _store = new DisposableStore(); + private readonly _domNode = document.createElement('div'); + + constructor( + container: HTMLElement, + group: IEditorGroup, + @IAgentFeedbackService agentFeedbackService: IAgentFeedbackService, + @IInstantiationService instaService: IInstantiationService, + ) { + this._domNode.classList.add('agent-feedback-editor-overlay'); + this._domNode.style.position = 'absolute'; + this._domNode.style.bottom = '24px'; + this._domNode.style.right = '24px'; + this._domNode.style.zIndex = '100'; + + const widget = this._store.add(instaService.createInstance(AgentFeedbackOverlayWidget)); + this._domNode.appendChild(widget.getDomNode()); + this._store.add(toDisposable(() => this._domNode.remove())); + + const show = () => { + if (!container.contains(this._domNode)) { + container.appendChild(this._domNode); + } + }; + + const hide = () => { + if (container.contains(this._domNode)) { + widget.hide(); + this._domNode.remove(); + } + }; + + const activeSignal = observableSignalFromEvent(this, Event.any( + group.onDidActiveEditorChange, + group.onDidModelChange, + agentFeedbackService.onDidChangeFeedback, + agentFeedbackService.onDidChangeNavigation, + )); + + this._store.add(autorun(r => { + activeSignal.read(r); + + const candidates = getActiveResourceCandidates(group.activeEditorPane?.input); + let shouldShow = false; + let navigationBearings = { activeIdx: -1, totalCount: 0 }; + for (const candidate of candidates) { + const sessionResource = agentFeedbackService.getMostRecentSessionForResource(candidate); + if (sessionResource && agentFeedbackService.getFeedback(sessionResource).length > 0) { + shouldShow = true; + navigationBearings = agentFeedbackService.getNavigationBearing(sessionResource); + break; + } + } + + if (!shouldShow) { + hide(); + return; + } + + widget.show(navigationBearings); + show(); + })); + } + + dispose(): void { + this._store.dispose(); + } +} + +export class AgentFeedbackEditorOverlay implements IWorkbenchContribution { + + static readonly ID = 'chat.agentFeedback.editorOverlay'; + + private readonly _store = new DisposableStore(); + + constructor( + @IEditorGroupsService editorGroupsService: IEditorGroupsService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + const editorGroups = observableFromEvent( + this, + Event.any(editorGroupsService.onDidAddGroup, editorGroupsService.onDidRemoveGroup), + () => editorGroupsService.groups + ); + + const overlayWidgets = this._store.add(new DisposableMap()); + + this._store.add(autorun(r => { + const groups = editorGroups.read(r); + const toDelete = new Set(overlayWidgets.keys()); + + for (const group of groups) { + if (!(group instanceof EditorGroupView)) { + continue; + } + + toDelete.delete(group); + + if (!overlayWidgets.has(group)) { + const scopedInstaService = instantiationService.createChild( + new ServiceCollection([IContextKeyService, group.scopedContextKeyService]) + ); + + const ctrl = scopedInstaService.createInstance(AgentFeedbackOverlayController, group.element, group); + overlayWidgets.set(group, combinedDisposable(ctrl, scopedInstaService)); + } + } + + for (const group of toDelete) { + overlayWidgets.deleteAndDispose(group); + } + })); + } + + dispose(): void { + this._store.dispose(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackEditorUtils.ts b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackEditorUtils.ts new file mode 100644 index 0000000000000..64b648b59ba19 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackEditorUtils.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../base/common/uri.js'; +import { EditorResourceAccessor, SideBySideEditor } from '../../../../common/editor.js'; + +export function getActiveResourceCandidates(input: Parameters[0]): URI[] { + const result: URI[] = []; + const resources = EditorResourceAccessor.getOriginalUri(input, { supportSideBySide: SideBySideEditor.BOTH }); + if (!resources) { + return result; + } + + if (URI.isUri(resources)) { + result.push(resources); + return result; + } + + if (resources.secondary) { + result.push(resources.secondary); + } + if (resources.primary) { + result.push(resources.primary); + } + + return result; +} diff --git a/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackHover.ts b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackHover.ts new file mode 100644 index 0000000000000..c730e9460cf00 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackHover.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { HoverStyle } from '../../../../../base/browser/ui/hover/hover.js'; +import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { IRange } from '../../../../../editor/common/core/range.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../../../browser/labels.js'; +import { IAgentFeedbackService } from './agentFeedbackService.js'; +import { IAgentFeedbackVariableEntry } from '../../common/attachments/chatVariableEntries.js'; + +/** + * Creates the custom hover content for the "N comments" attachment. + * Shows each feedback item with its file, range, text, and actions (remove / go to). + */ +export class AgentFeedbackHover extends Disposable { + + constructor( + private readonly _element: HTMLElement, + private readonly _attachment: IAgentFeedbackVariableEntry, + @IHoverService private readonly _hoverService: IHoverService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IEditorService private readonly _editorService: IEditorService, + @IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService, + ) { + super(); + + // Show on hover (delayed) + this._store.add(this._hoverService.setupDelayedHover( + this._element, + () => this._buildHoverContent(), + { groupId: 'chat-attachments' } + )); + + // Show immediately on click + this._store.add(dom.addDisposableListener(this._element, dom.EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this._showHoverNow(); + })); + } + + private _showHoverNow(): void { + const opts = this._buildHoverContent(); + this._hoverService.showInstantHover({ + content: opts.content, + target: this._element, + style: opts.style, + position: opts.position, + trapFocus: opts.trapFocus, + }); + } + + private _buildHoverContent(): { content: HTMLElement; style: HoverStyle; position: { hoverPosition: HoverPosition }; trapFocus: boolean; dispose: () => void } { + const disposables = new DisposableStore(); + const hoverElement = dom.$('div.agent-feedback-hover'); + + const title = dom.$('div.agent-feedback-hover-title'); + title.textContent = this._attachment.feedbackItems.length === 1 + ? localize('agentFeedbackHover.titleOne', "1 feedback comment") + : localize('agentFeedbackHover.titleMany', "{0} feedback comments", this._attachment.feedbackItems.length); + hoverElement.appendChild(title); + + const list = dom.$('div.agent-feedback-hover-list'); + hoverElement.appendChild(list); + + // Create ResourceLabels for file icons + const resourceLabels = disposables.add(this._instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); + + // Group feedback items by file + const byFile = new Map(); + for (const item of this._attachment.feedbackItems) { + const key = item.resourceUri.toString(); + let group = byFile.get(key); + if (!group) { + group = []; + byFile.set(key, group); + } + group.push(item); + } + + for (const [, items] of byFile) { + // File header with icon via ResourceLabels + const fileHeader = dom.$('div.agent-feedback-hover-file-header'); + list.appendChild(fileHeader); + const label = resourceLabels.create(fileHeader); + label.setFile(items[0].resourceUri, { hidePath: false }); + + for (const item of items) { + const row = dom.$('div.agent-feedback-hover-row'); + list.appendChild(row); + + // Feedback text - clicking goes to location + const text = dom.$('div.agent-feedback-hover-text'); + text.textContent = item.text; + row.appendChild(text); + + row.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + this._goToFeedback(item.resourceUri, item.range); + }); + + // Remove button + const removeBtn = dom.$('a.agent-feedback-hover-remove'); + removeBtn.title = localize('agentFeedbackHover.remove', "Remove feedback"); + const removeIcon = dom.$('span'); + removeIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.close)); + removeBtn.appendChild(removeIcon); + row.appendChild(removeBtn); + + removeBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + this._agentFeedbackService.removeFeedback(this._attachment.sessionResource, item.id); + }); + } + } + + return { + content: hoverElement, + style: HoverStyle.Pointer, + position: { hoverPosition: HoverPosition.BELOW }, + trapFocus: true, + dispose: () => disposables.dispose(), + }; + } + + private _goToFeedback(resourceUri: URI, range: IRange): void { + this._editorService.openEditor({ + resource: resourceUri, + options: { + selection: range, + preserveFocus: false, + revealIfVisible: true, + } + }); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackService.ts b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackService.ts new file mode 100644 index 0000000000000..f1352d999bd4d --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentFeedback/agentFeedbackService.ts @@ -0,0 +1,570 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IRange } from '../../../../../editor/common/core/range.js'; +import { Comment, CommentThread, CommentThreadCollapsibleState, CommentThreadState, CommentInput } from '../../../../../editor/common/languages.js'; +import { createDecorator, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ICommentController, ICommentInfo, ICommentService, INotebookCommentInfo } from '../../../comments/browser/commentService.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; +import { registerAction2, Action2, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { localize } from '../../../../../nls.js'; +import { isEqual } from '../../../../../base/common/resources.js'; +import { IChatEditingService } from '../../common/editing/chatEditingService.js'; +import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js'; +import { agentSessionContainsResource, editingEntriesContainResource } from '../sessionResourceMatching.js'; +import { IChatWidget, IChatWidgetService } from '../chat.js'; + +// --- Types -------------------------------------------------------------------- + +export interface IAgentFeedback { + readonly id: string; + readonly text: string; + readonly resourceUri: URI; + readonly range: IRange; + readonly sessionResource: URI; +} + +export interface IAgentFeedbackChangeEvent { + readonly sessionResource: URI; + readonly feedbackItems: readonly IAgentFeedback[]; +} + +export interface IAgentFeedbackNavigationBearing { + readonly activeIdx: number; + readonly totalCount: number; +} + +// --- Service Interface -------------------------------------------------------- + +export const IAgentFeedbackService = createDecorator('agentFeedbackService'); + +export interface IAgentFeedbackService { + readonly _serviceBrand: undefined; + + readonly onDidChangeFeedback: Event; + readonly onDidChangeNavigation: Event; + + /** + * Add a feedback item for the given session. + */ + addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string): IAgentFeedback; + + /** + * Remove a single feedback item. + */ + removeFeedback(sessionResource: URI, feedbackId: string): void; + + /** + * Get all feedback items for a session. + */ + getFeedback(sessionResource: URI): readonly IAgentFeedback[]; + + /** + * Resolve the most recently updated session that has feedback for a given resource. + */ + getMostRecentSessionForResource(resourceUri: URI): URI | undefined; + + /** + * Navigate to next/previous feedback item in a session. + */ + getNextFeedback(sessionResource: URI, next: boolean): IAgentFeedback | undefined; + + /** + * Get the current navigation bearings for a session. + */ + getNavigationBearing(sessionResource: URI): IAgentFeedbackNavigationBearing; + + /** + * Clear all feedback items for a session (e.g., after sending). + */ + clearFeedback(sessionResource: URI): void; +} + +// --- Implementation ----------------------------------------------------------- + +const AGENT_FEEDBACK_OWNER = 'agentFeedbackController'; +const AGENT_FEEDBACK_CONTEXT_VALUE = 'agentFeedback'; +const AGENT_FEEDBACK_ATTACHMENT_ID_PREFIX = 'agentFeedback:'; + +export class AgentFeedbackService extends Disposable implements IAgentFeedbackService { + + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeFeedback = this._store.add(new Emitter()); + readonly onDidChangeFeedback = this._onDidChangeFeedback.event; + private readonly _onDidChangeNavigation = this._store.add(new Emitter()); + readonly onDidChangeNavigation = this._onDidChangeNavigation.event; + + /** sessionResource → feedback items */ + private readonly _feedbackBySession = new Map(); + private readonly _sessionUpdatedOrder = new Map(); + private _sessionUpdatedSequence = 0; + private readonly _navigationAnchorBySession = new Map(); + + private _controllerRegistered = false; + private _nextThreadHandle = 1; + + constructor( + @ICommentService private readonly _commentService: ICommentService, + @IChatEditingService private readonly _chatEditingService: IChatEditingService, + @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, + @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, + ) { + super(); + + this._registerChatWidgetListeners(); + } + + private _registerChatWidgetListeners(): void { + for (const widget of this._chatWidgetService.getAllWidgets()) { + this._registerWidgetListeners(widget); + } + + this._store.add(this._chatWidgetService.onDidAddWidget(widget => { + this._registerWidgetListeners(widget); + })); + } + + private _registerWidgetListeners(widget: IChatWidget): void { + this._store.add(widget.attachmentModel.onDidChange(e => { + for (const deletedId of e.deleted) { + if (!deletedId.startsWith(AGENT_FEEDBACK_ATTACHMENT_ID_PREFIX)) { + continue; + } + + const sessionResourceString = deletedId.slice(AGENT_FEEDBACK_ATTACHMENT_ID_PREFIX.length); + if (!sessionResourceString) { + continue; + } + + const sessionResource = URI.parse(sessionResourceString); + if (this.getFeedback(sessionResource).length > 0) { + this.clearFeedback(sessionResource); + } + } + })); + } + + private _ensureController(): void { + if (this._controllerRegistered) { + return; + } + this._controllerRegistered = true; + + const self = this; + + const controller: ICommentController = { + id: AGENT_FEEDBACK_OWNER, + label: 'Agent Feedback', + features: {}, + contextValue: AGENT_FEEDBACK_CONTEXT_VALUE, + owner: AGENT_FEEDBACK_OWNER, + activeComment: undefined, + createCommentThreadTemplate: async () => { }, + updateCommentThreadTemplate: async () => { }, + deleteCommentThreadMain: () => { }, + toggleReaction: async () => { }, + getDocumentComments: async (resource: URI, _token: CancellationToken): Promise> => { + // Return threads for this resource from all sessions + const threads: CommentThread[] = []; + for (const [, sessionFeedback] of self._feedbackBySession) { + for (const f of sessionFeedback) { + if (f.resourceUri.toString() === resource.toString()) { + threads.push(self._createThread(f)); + } + } + } + return { + threads, + commentingRanges: { ranges: [], resource, fileComments: false }, + uniqueOwner: AGENT_FEEDBACK_OWNER, + }; + }, + getNotebookComments: async (_resource: URI, _token: CancellationToken): Promise => { + return { threads: [], uniqueOwner: AGENT_FEEDBACK_OWNER }; + }, + setActiveCommentAndThread: async () => { }, + }; + + this._commentService.registerCommentController(AGENT_FEEDBACK_OWNER, controller); + this._store.add({ dispose: () => this._commentService.unregisterCommentController(AGENT_FEEDBACK_OWNER) }); + + // Register delete action for our feedback threads + this._store.add(registerAction2(class extends Action2 { + constructor() { + super({ + id: 'agentFeedback.deleteThread', + title: localize('agentFeedback.delete', "Delete Feedback"), + icon: Codicon.trash, + menu: { + id: MenuId.CommentThreadTitle, + when: ContextKeyExpr.equals('commentController', AGENT_FEEDBACK_CONTEXT_VALUE), + group: 'navigation', + } + }); + } + run(accessor: ServicesAccessor, ...args: unknown[]): void { + const agentFeedbackService = accessor.get(IAgentFeedbackService); + const arg = args[0] as { thread?: { threadId?: string }; threadId?: string } | undefined; + const thread = arg?.thread ?? arg; + if (thread?.threadId) { + const sessionResource = self._findSessionForFeedback(thread.threadId); + if (sessionResource) { + agentFeedbackService.removeFeedback(sessionResource, thread.threadId); + } + } + } + })); + } + + addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string): IAgentFeedback { + this._ensureController(); + + const key = sessionResource.toString(); + let feedbackItems = this._feedbackBySession.get(key); + if (!feedbackItems) { + feedbackItems = []; + this._feedbackBySession.set(key, feedbackItems); + } + + const feedback: IAgentFeedback = { + id: generateUuid(), + text, + resourceUri, + range, + sessionResource, + }; + feedbackItems.push(feedback); + this._sessionUpdatedOrder.set(key, ++this._sessionUpdatedSequence); + this._onDidChangeNavigation.fire(sessionResource); + + this._syncThreads(sessionResource); + this._onDidChangeFeedback.fire({ sessionResource, feedbackItems }); + + return feedback; + } + + removeFeedback(sessionResource: URI, feedbackId: string): void { + const key = sessionResource.toString(); + const feedbackItems = this._feedbackBySession.get(key); + if (!feedbackItems) { + return; + } + + const idx = feedbackItems.findIndex(f => f.id === feedbackId); + if (idx >= 0) { + const removed = feedbackItems[idx]; + feedbackItems.splice(idx, 1); + this._activeThreadIds.delete(feedbackId); + if (this._navigationAnchorBySession.get(key) === feedbackId) { + this._navigationAnchorBySession.delete(key); + this._onDidChangeNavigation.fire(sessionResource); + } + if (feedbackItems.length > 0) { + this._sessionUpdatedOrder.set(key, ++this._sessionUpdatedSequence); + } else { + this._sessionUpdatedOrder.delete(key); + } + + // Fire updateComments with the thread in removed[] so the editor + // controller's onDidUpdateCommentThreads handler removes the zone widget + const thread = this._createThread(removed); + thread.isDisposed = true; + this._commentService.updateComments(AGENT_FEEDBACK_OWNER, { + added: [], + removed: [thread], + changed: [], + pending: [], + }); + + this._onDidChangeFeedback.fire({ sessionResource, feedbackItems }); + } + } + + /** + * Find which session a feedback item belongs to by its ID. + */ + _findSessionForFeedback(feedbackId: string): URI | undefined { + for (const [, feedbackItems] of this._feedbackBySession) { + const item = feedbackItems.find(f => f.id === feedbackId); + if (item) { + return item.sessionResource; + } + } + return undefined; + } + + getFeedback(sessionResource: URI): readonly IAgentFeedback[] { + return this._feedbackBySession.get(sessionResource.toString()) ?? []; + } + + getMostRecentSessionForResource(resourceUri: URI): URI | undefined { + let bestSession: URI | undefined; + let bestSequence = -1; + + for (const [, feedbackItems] of this._feedbackBySession) { + if (!feedbackItems.length) { + continue; + } + + const candidate = feedbackItems[0].sessionResource; + if (!this._sessionContainsResource(candidate, resourceUri, feedbackItems)) { + continue; + } + + const sequence = this._sessionUpdatedOrder.get(candidate.toString()) ?? 0; + if (sequence > bestSequence) { + bestSession = candidate; + bestSequence = sequence; + } + } + + return bestSession; + } + + private _sessionContainsResource(sessionResource: URI, resourceUri: URI, feedbackItems: readonly IAgentFeedback[]): boolean { + if (feedbackItems.some(item => isEqual(item.resourceUri, resourceUri))) { + return true; + } + + for (const editingSession of this._chatEditingService.editingSessionsObs.get()) { + if (!isEqual(editingSession.chatSessionResource, sessionResource)) { + continue; + } + + if (editingEntriesContainResource(editingSession.entries.get(), resourceUri)) { + return true; + } + } + + for (const session of this._agentSessionsService.model.sessions) { + if (!isEqual(session.resource, sessionResource)) { + continue; + } + + if (agentSessionContainsResource(session, resourceUri)) { + return true; + } + } + + return false; + } + + getNextFeedback(sessionResource: URI, next: boolean): IAgentFeedback | undefined { + const key = sessionResource.toString(); + const feedbackItems = this._feedbackBySession.get(key); + if (!feedbackItems?.length) { + this._navigationAnchorBySession.delete(key); + return undefined; + } + + const anchorId = this._navigationAnchorBySession.get(key); + let anchorIndex = anchorId ? feedbackItems.findIndex(item => item.id === anchorId) : -1; + + if (anchorIndex < 0 && !next) { + anchorIndex = 0; + } + + const nextIndex = next + ? (anchorIndex + 1) % feedbackItems.length + : (anchorIndex - 1 + feedbackItems.length) % feedbackItems.length; + + const feedback = feedbackItems[nextIndex]; + this._navigationAnchorBySession.set(key, feedback.id); + this._onDidChangeNavigation.fire(sessionResource); + return feedback; + } + + getNavigationBearing(sessionResource: URI): IAgentFeedbackNavigationBearing { + const key = sessionResource.toString(); + const feedbackItems = this._feedbackBySession.get(key) ?? []; + const anchorId = this._navigationAnchorBySession.get(key); + const activeIdx = anchorId ? feedbackItems.findIndex(item => item.id === anchorId) : -1; + return { activeIdx, totalCount: feedbackItems.length }; + } + + clearFeedback(sessionResource: URI): void { + const key = sessionResource.toString(); + const feedbackItems = this._feedbackBySession.get(key); + if (feedbackItems && feedbackItems.length > 0) { + const removedThreads = feedbackItems.map(f => { + this._activeThreadIds.delete(f.id); + const thread = this._createThread(f); + thread.isDisposed = true; + return thread; + }); + + this._commentService.updateComments(AGENT_FEEDBACK_OWNER, { + added: [], + removed: removedThreads, + changed: [], + pending: [], + }); + } + this._feedbackBySession.delete(key); + this._sessionUpdatedOrder.delete(key); + this._navigationAnchorBySession.delete(key); + this._onDidChangeNavigation.fire(sessionResource); + this._onDidChangeFeedback.fire({ sessionResource, feedbackItems: [] }); + } + + /** Threads currently known to the comment service, keyed by feedback id */ + private readonly _activeThreadIds = new Set(); + + /** + * Sync feedback threads to the ICommentService using updateComments for + * incremental add/remove, which the editor controller listens to. + */ + private _syncThreads(_sessionResource: URI): void { + // Collect all current feedback IDs + const currentIds = new Set(); + const allFeedback: IAgentFeedback[] = []; + for (const [, sessionFeedback] of this._feedbackBySession) { + for (const f of sessionFeedback) { + currentIds.add(f.id); + allFeedback.push(f); + } + } + + // Determine added and removed + const added: CommentThread[] = []; + const removed: CommentThread[] = []; + + for (const f of allFeedback) { + if (!this._activeThreadIds.has(f.id)) { + added.push(this._createThread(f)); + } + } + + for (const id of this._activeThreadIds) { + if (!currentIds.has(id)) { + // Create a minimal thread just for removal (needs threadId and resource) + removed.push(this._createRemovedThread(id)); + } + } + + // Update tracking + this._activeThreadIds.clear(); + for (const id of currentIds) { + this._activeThreadIds.add(id); + } + + if (added.length || removed.length) { + this._commentService.updateComments(AGENT_FEEDBACK_OWNER, { + added, + removed, + changed: [], + pending: [], + }); + } + } + + private _createRemovedThread(feedbackId: string): CommentThread { + const noopEvent = Event.None; + return { + isDocumentCommentThread(): this is CommentThread { return true; }, + commentThreadHandle: -1, + controllerHandle: 0, + threadId: feedbackId, + resource: null, + range: undefined, + label: undefined, + contextValue: undefined, + comments: undefined, + onDidChangeComments: noopEvent, + collapsibleState: CommentThreadCollapsibleState.Collapsed, + initialCollapsibleState: CommentThreadCollapsibleState.Collapsed, + onDidChangeInitialCollapsibleState: noopEvent, + state: undefined, + applicability: undefined, + canReply: false, + input: undefined, + onDidChangeInput: noopEvent, + onDidChangeLabel: noopEvent, + onDidChangeCollapsibleState: noopEvent, + onDidChangeState: noopEvent, + onDidChangeCanReply: noopEvent, + isDisposed: true, + isTemplate: false, + }; + } + + private _createThread(feedback: IAgentFeedback): CommentThread { + const handle = this._nextThreadHandle++; + + const threadComment: Comment = { + uniqueIdInThread: 1, + body: feedback.text, + userName: 'You', + }; + + return new AgentFeedbackThread(handle, feedback.id, feedback.resourceUri.toString(), feedback.range, [threadComment]); + } +} + +/** + * A CommentThread implementation with proper emitters so the editor + * comment controller can react to state changes (collapse/expand). + */ +class AgentFeedbackThread implements CommentThread { + + private readonly _onDidChangeComments = new Emitter(); + readonly onDidChangeComments = this._onDidChangeComments.event; + + private readonly _onDidChangeCollapsibleState = new Emitter(); + readonly onDidChangeCollapsibleState = this._onDidChangeCollapsibleState.event; + + private readonly _onDidChangeInitialCollapsibleState = new Emitter(); + readonly onDidChangeInitialCollapsibleState = this._onDidChangeInitialCollapsibleState.event; + + private readonly _onDidChangeInput = new Emitter(); + readonly onDidChangeInput = this._onDidChangeInput.event; + + private readonly _onDidChangeLabel = new Emitter(); + readonly onDidChangeLabel = this._onDidChangeLabel.event; + + private readonly _onDidChangeState = new Emitter(); + readonly onDidChangeState = this._onDidChangeState.event; + + private readonly _onDidChangeCanReply = new Emitter(); + readonly onDidChangeCanReply = this._onDidChangeCanReply.event; + + readonly controllerHandle = 0; + readonly label = undefined; + readonly contextValue = undefined; + readonly applicability = undefined; + readonly input = undefined; + readonly isTemplate = false; + + private _collapsibleState = CommentThreadCollapsibleState.Collapsed; + get collapsibleState(): CommentThreadCollapsibleState { return this._collapsibleState; } + set collapsibleState(value: CommentThreadCollapsibleState) { + this._collapsibleState = value; + this._onDidChangeCollapsibleState.fire(value); + } + + readonly initialCollapsibleState = CommentThreadCollapsibleState.Collapsed; + readonly state = CommentThreadState.Unresolved; + readonly canReply = false; + isDisposed = false; + + constructor( + readonly commentThreadHandle: number, + readonly threadId: string, + readonly resource: string, + readonly range: IRange, + readonly comments: readonly Comment[], + ) { } + + isDocumentCommentThread(): this is CommentThread { + return true; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentFeedback/media/agentFeedbackAttachment.css b/src/vs/workbench/contrib/chat/browser/agentFeedback/media/agentFeedbackAttachment.css new file mode 100644 index 0000000000000..c67d88f542272 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentFeedback/media/agentFeedbackAttachment.css @@ -0,0 +1,91 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.agent-feedback-hover { + max-width: 400px; + padding: 4px 0; +} + +.agent-feedback-hover-title { + font-weight: bold; + font-size: 12px; + padding: 2px 8px 6px; + border-bottom: 1px solid var(--vscode-editorWidget-border); + margin-bottom: 4px; +} + +.agent-feedback-hover-list { + max-height: 300px; + overflow-y: auto; +} + +.agent-feedback-hover-file-header { + font-size: 11px; + font-weight: bold; + color: var(--vscode-foreground); + padding: 6px 8px 2px; + font-family: var(--monaco-monospace-font); +} + +.agent-feedback-hover-file-header:not(:first-child) { + border-top: 1px solid var(--vscode-editorWidget-border); + margin-top: 4px; + padding-top: 8px; +} + +.agent-feedback-hover-row { + padding: 4px 8px; + display: flex; + align-items: center; + gap: 4px; + border-radius: 4px; + cursor: pointer; + position: relative; +} + +.agent-feedback-hover-row:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.agent-feedback-hover-line { + font-size: 11px; + color: var(--vscode-descriptionForeground); +} + +.agent-feedback-hover-text { + font-size: 12px; + white-space: pre-wrap; + word-break: break-word; + flex: 1; +} + +.agent-feedback-hover-remove { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--vscode-descriptionForeground); + opacity: 0; + flex-shrink: 0; + width: 20px; + height: 20px; + border-radius: 4px; +} + +.agent-feedback-hover-row:hover .agent-feedback-hover-remove { + opacity: 1; +} + +.agent-feedback-hover-remove:hover { + color: var(--vscode-foreground); + background-color: var(--vscode-toolbar-hoverBackground); +} + +/* Attachment widget pill styling */ +.agent-feedback-attachment .chat-attached-context-pill { + display: flex; + align-items: center; + padding: 0 4px; +} diff --git a/src/vs/workbench/contrib/chat/browser/agentFeedback/media/agentFeedbackEditorOverlay.css b/src/vs/workbench/contrib/chat/browser/agentFeedback/media/agentFeedbackEditorOverlay.css new file mode 100644 index 0000000000000..1acdbe228ce56 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentFeedback/media/agentFeedbackEditorOverlay.css @@ -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. + *--------------------------------------------------------------------------------------------*/ + +.agent-feedback-editor-overlay-widget { + padding: 2px 4px; + color: var(--vscode-foreground); + background-color: var(--vscode-editorWidget-background); + border-radius: 6px; + border: 1px solid var(--vscode-contrastBorder); + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + z-index: 10; + box-shadow: 0 2px 8px var(--vscode-widget-shadow); + overflow: hidden; +} + +.agent-feedback-editor-overlay-widget .action-item > .action-label { + padding: 4px 6px; + font-size: 11px; + line-height: 14px; + border-radius: 4px; +} + +.agent-feedback-editor-overlay-widget .monaco-action-bar .actions-container { + gap: 4px; +} + +.agent-feedback-editor-overlay-widget .action-item.primary > .action-label { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} + +.monaco-workbench .agent-feedback-editor-overlay-widget .monaco-action-bar .action-item.primary > .action-label:hover { + background-color: var(--vscode-button-hoverBackground); +} + +.agent-feedback-editor-overlay-widget .action-item > .action-label.codicon:not(.separator) { + color: var(--vscode-foreground); + width: 22px; + height: 22px; + padding: 0; + font-size: 16px; + line-height: 22px; + display: flex; + align-items: center; + justify-content: center; +} + +.agent-feedback-editor-overlay-widget .monaco-action-bar .action-item.disabled { + + > .action-label.codicon::before, + > .action-label.codicon, + > .action-label, + > .action-label:hover { + color: var(--vscode-button-separator); + opacity: 1; + } +} + +.agent-feedback-editor-overlay-widget .action-item.label-item { + font-variant-numeric: tabular-nums; +} + +.agent-feedback-editor-overlay-widget .monaco-action-bar .action-item.label-item > .action-label, +.agent-feedback-editor-overlay-widget .monaco-action-bar .action-item.label-item > .action-label:hover { + color: var(--vscode-foreground); + opacity: 1; +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index f58192d8d3751..d74a3f6a63253 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -9,14 +9,14 @@ import { IContextMenuService } from '../../../../../platform/contextview/browser import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IOpenEvent, WorkbenchCompressibleAsyncDataTree } from '../../../../../platform/list/browser/listService.js'; import { $, append, EventHelper } from '../../../../../base/browser/dom.js'; -import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, IMarshalledAgentSessionContext, isAgentSession, isAgentSessionSection } from './agentSessionsModel.js'; +import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, IMarshalledAgentSessionContext, isAgentSession, isAgentSessionSection, isSessionInProgressStatus } from './agentSessionsModel.js'; import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionsSorter, IAgentSessionsFilter, IAgentSessionsSorterOptions } from './agentSessionsViewer.js'; import { FuzzyScore } from '../../../../../base/common/filters.js'; import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; import { IChatSessionsService } from '../../common/chatSessionsService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ACTION_ID_NEW_CHAT } from '../actions/chatActions.js'; -import { Event } from '../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { Throttler } from '../../../../../base/common/async.js'; import { ITreeContextMenuEvent } from '../../../../../base/browser/ui/tree/tree.js'; @@ -27,7 +27,7 @@ import { IAgentSessionsService } from './agentSessionsService.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IListStyles } from '../../../../../base/browser/ui/list/listWidget.js'; import { IStyleOverride } from '../../../../../platform/theme/browser/defaultStyles.js'; -import { IAgentSessionsControl } from './agentSessions.js'; +import { getAgentSessionTime, IAgentSessionsControl } from './agentSessions.js'; import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; import { URI } from '../../../../../base/common/uri.js'; import { ISessionOpenOptions, openSession } from './agentSessionsOpener.js'; @@ -43,8 +43,10 @@ export interface IAgentSessionsControlOptions extends IAgentSessionsSorterOption getHoverPosition(): HoverPosition; trackActiveEditorSession(): boolean; + collapseOlderSections?(): boolean; overrideSessionOpenOptions?(openEvent: IOpenEvent): ISessionOpenOptions; + overrideSessionOpen?(resource: URI, openOptions?: ISessionOpenOptions): Promise; notifySessionOpened?(resource: URI, widget: IChatWidget): void; } @@ -70,6 +72,9 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo private readonly updateSessionsListThrottler = this._register(new Throttler()); + private readonly _onDidUpdate = this._register(new Emitter()); + readonly onDidUpdate: Event = this._onDidUpdate.event; + private visible: boolean = true; private focusedAgentSessionArchivedContextKey: IContextKey; @@ -142,6 +147,15 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo if (element.section === AgentSessionSection.Archived && this.options.filter.getExcludes().archived) { return true; // Archived section is collapsed when archived are excluded } + if (this.options.collapseOlderSections?.()) { + const olderSections = [AgentSessionSection.Week, AgentSessionSection.Older, AgentSessionSection.Archived]; + if (olderSections.includes(element.section)) { + return true; // Collapse older time sections if option is enabled + } + if (element.section === AgentSessionSection.Yesterday && this.hasTodaySessions()) { + return true; // Also collapse Yesterday when there are sessions from Today + } + } } return false; @@ -225,6 +239,17 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo })); } + private hasTodaySessions(): boolean { + const startOfToday = new Date().setHours(0, 0, 0, 0); + + return this.agentSessionsService.model.sessions.some(session => + !session.isArchived() && ( + isSessionInProgressStatus(session.status) || + getAgentSessionTime(session.timing) >= startOfToday + ) + ); + } + private async openAgentSession(e: IOpenEvent): Promise { const element = e.element; if (!element || isAgentSessionSection(element)) { @@ -237,9 +262,13 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo }); const options = this.options.overrideSessionOpenOptions?.(e) ?? e; - const widget = await this.instantiationService.invokeFunction(openSession, element, options); - if (widget) { - this.options.notifySessionOpened?.(element.resource, widget); + if (this.options.overrideSessionOpen) { + await this.options.overrideSessionOpen(element.resource, options); + } else { + const widget = await this.instantiationService.invokeFunction(openSession, element, options); + if (widget) { + this.options.notifySessionOpened?.(element.resource, widget); + } } } @@ -341,7 +370,11 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo } async update(): Promise { - return this.updateSessionsListThrottler.queue(async () => this.sessionsList?.updateChildren()); + return this.updateSessionsListThrottler.queue(async () => { + await this.sessionsList?.updateChildren(); + + this._onDidUpdate.fire(); + }); } setVisible(visible: boolean): void { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index 37a75d93f19d1..ea409d2eef2dd 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -21,6 +21,7 @@ import { ILogService, LogLevel } from '../../../../../platform/log/common/log.js import { IProductService } from '../../../../../platform/product/common/productService.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js'; import { Extensions, IOutputChannelRegistry, IOutputService } from '../../../../services/output/common/output.js'; @@ -396,7 +397,8 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode @IInstantiationService private readonly instantiationService: IInstantiationService, @IStorageService private readonly storageService: IStorageService, @IProductService private readonly productService: IProductService, - @IChatWidgetService private readonly chatWidgetService: IChatWidgetService + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, ) { super(); @@ -429,6 +431,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode this._register(this.chatSessionsService.onDidChangeItemsProviders(({ chatSessionType }) => this.resolve(chatSessionType))); this._register(this.chatSessionsService.onDidChangeAvailability(() => this.resolve(undefined))); this._register(this.chatSessionsService.onDidChangeSessionItems(({ chatSessionType }) => this.updateItems([chatSessionType], CancellationToken.None))); + this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => this.resolve(undefined))); // State this._register(this.storageService.onWillSaveState(() => { diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 47b9c59f51dfa..b30f87e3391a0 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -141,6 +141,10 @@ import { ChatWindowNotifier } from './chatWindowNotifier.js'; import { ChatRepoInfoContribution } from './chatRepoInfo.js'; import { VALID_PROMPT_FOLDER_PATTERN } from '../common/promptSyntax/utils/promptFilesLocator.js'; import { ChatTipService, IChatTipService } from './chatTipService.js'; +import { AgentFeedbackService, IAgentFeedbackService } from './agentFeedback/agentFeedbackService.js'; +import { AgentFeedbackAttachmentContribution } from './agentFeedback/agentFeedbackAttachment.js'; +import { AgentFeedbackEditorOverlay } from './agentFeedback/agentFeedbackEditorOverlay.js'; +import { registerAgentFeedbackEditorActions } from './agentFeedback/agentFeedbackEditorActions.js'; import { ChatQueuePickerRendering } from './widget/input/chatQueuePickerActionItem.js'; import { ExploreAgentDefaultModel } from './exploreAgentDefaultModel.js'; import { PlanAgentDefaultModel } from './planAgentDefaultModel.js'; @@ -1447,6 +1451,7 @@ registerWorkbenchContribution2(ChatAgentRecommendation.ID, ChatAgentRecommendati registerWorkbenchContribution2(ChatEditingEditorAccessibility.ID, ChatEditingEditorAccessibility, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatQueuePickerRendering.ID, ChatQueuePickerRendering, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatEditingEditorOverlay.ID, ChatEditingEditorOverlay, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(AgentFeedbackEditorOverlay.ID, AgentFeedbackEditorOverlay, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(SimpleBrowserOverlay.ID, SimpleBrowserOverlay, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatEditingEditorContextKeys.ID, ChatEditingEditorContextKeys, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatTransferContribution.ID, ChatTransferContribution, WorkbenchPhase.BlockRestore); @@ -1458,6 +1463,7 @@ registerWorkbenchContribution2(UserToolSetsContributions.ID, UserToolSetsContrib registerWorkbenchContribution2(PromptLanguageFeaturesProvider.ID, PromptLanguageFeaturesProvider, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatWindowNotifier.ID, ChatWindowNotifier, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatRepoInfoContribution.ID, ChatRepoInfoContribution, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(AgentFeedbackAttachmentContribution.ID, AgentFeedbackAttachmentContribution, WorkbenchPhase.AfterRestored); registerChatActions(); registerChatAccessibilityActions(); @@ -1477,6 +1483,7 @@ registerNewChatActions(); registerChatContextActions(); registerChatDeveloperActions(); registerChatEditorActions(); +registerAgentFeedbackEditorActions(); registerChatElicitationActions(); registerChatToolActions(); registerLanguageModelActions(); @@ -1513,5 +1520,6 @@ registerSingleton(IChatTodoListService, ChatTodoListService, InstantiationType.D registerSingleton(IChatOutputRendererService, ChatOutputRendererService, InstantiationType.Delayed); registerSingleton(IChatLayoutService, ChatLayoutService, InstantiationType.Delayed); registerSingleton(IChatTipService, ChatTipService, InstantiationType.Delayed); +registerSingleton(IAgentFeedbackService, AgentFeedbackService, InstantiationType.Delayed); ChatWidget.CONTRIBS.push(ChatDynamicVariableModel); diff --git a/src/vs/workbench/contrib/chat/browser/sessionResourceMatching.ts b/src/vs/workbench/contrib/chat/browser/sessionResourceMatching.ts new file mode 100644 index 0000000000000..5e30a35b967ad --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/sessionResourceMatching.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isEqual } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { isIChatSessionFileChange2 } from '../common/chatSessionsService.js'; +import { IModifiedFileEntry } from '../common/editing/chatEditingService.js'; +import { IAgentSession } from './agentSessions/agentSessionsModel.js'; + +export function editingEntriesContainResource(entries: readonly IModifiedFileEntry[], resourceUri: URI): boolean { + for (const entry of entries) { + if (isEqual(entry.modifiedURI, resourceUri) || isEqual(entry.originalURI, resourceUri)) { + return true; + } + } + + return false; +} + +export function agentSessionContainsResource(session: IAgentSession, resourceUri: URI): boolean { + if (!(session.changes instanceof Array)) { + return false; + } + + for (const change of session.changes) { + if (isIChatSessionFileChange2(change)) { + if (isEqual(change.uri, resourceUri) || (change.originalUri && isEqual(change.originalUri, resourceUri)) || (change.modifiedUri && isEqual(change.modifiedUri, resourceUri))) { + return true; + } + } else if (isEqual(change.modifiedUri, resourceUri) || (change.originalUri && isEqual(change.originalUri, resourceUri))) { + return true; + } + } + + return false; +} diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts index 708621ded2bb6..9653b59d817b9 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts @@ -6,6 +6,7 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { revive } from '../../../../../../base/common/marshalling.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { isEqual } from '../../../../../../base/common/resources.js'; import { truncate } from '../../../../../../base/common/strings.js'; @@ -335,7 +336,8 @@ export class ChatEditorInputSerializer implements IEditorSerializer { deserialize(instantiationService: IInstantiationService, serializedEditor: string): EditorInput | undefined { try { // Old inputs have a session id for local session - const parsed: ISerializedChatEditorInput & { readonly sessionId: string | undefined } = JSON.parse(serializedEditor); + // Use revive to properly restore URIs and other special objects in options.target.data + const parsed = revive(JSON.parse(serializedEditor)); // First if we have a modern session resource, use that if (parsed.sessionResource) { diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index c11c625bc9c1a..1435641c9ebaa 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -63,6 +63,7 @@ import { IAgentSessionsService } from '../../agentSessions/agentSessionsService. import { HoverPosition } from '../../../../../../base/browser/ui/hover/hoverWidget.js'; import { IAgentSession } from '../../agentSessions/agentSessionsModel.js'; import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; interface IChatViewPaneState extends Partial { /** @deprecated */ @@ -119,6 +120,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, @ICommandService private readonly commandService: ICommandService, @IActivityService private readonly activityService: IActivityService, + @IWorkbenchEnvironmentService private readonly workbenchEnvironmentService: IWorkbenchEnvironmentService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); @@ -493,8 +495,10 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { const editorOverflowWidgetsDomNode = this.layoutService.getContainer(getWindow(chatControlsContainer)).appendChild($('.chat-editor-overflow.monaco-editor')); this._register(toDisposable(() => editorOverflowWidgetsDomNode.remove())); - // Chat Title - this.createChatTitleControl(chatControlsContainer); + // Chat Title (unless we are hosted in the chat bar) + if (this.viewDescriptorService.getViewLocationById(this.id) !== ViewContainerLocation.ChatBar) { + this.createChatTitleControl(chatControlsContainer); + } // Chat Widget const scopedInstantiationService = this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, this.scopedContextKeyService]))); @@ -516,7 +520,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { }, editorOverflowWidgetsDomNode, enableImplicitContext: true, - enableWorkingSet: 'explicit', + enableWorkingSet: this.workbenchEnvironmentService.isSessionsWindow + ? 'implicit' + : 'explicit', supportsChangingModes: true, dndContainer: parent, }, diff --git a/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts b/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts index af40f1b5b331a..58a14a2ad8966 100644 --- a/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts +++ b/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts @@ -286,13 +286,24 @@ export interface IDebugVariableEntry extends IBaseChatRequestVariableEntry { readonly type?: string; } +export interface IAgentFeedbackVariableEntry extends IBaseChatRequestVariableEntry { + readonly kind: 'agentFeedback'; + readonly sessionResource: URI; + readonly feedbackItems: ReadonlyArray<{ + readonly id: string; + readonly text: string; + readonly resourceUri: URI; + readonly range: IRange; + }>; +} + export type IChatRequestVariableEntry = IGenericChatRequestVariableEntry | IChatRequestImplicitVariableEntry | IChatRequestPasteVariableEntry | ISymbolVariableEntry | ICommandResultVariableEntry | IDiagnosticVariableEntry | IImageVariableEntry | IChatRequestToolEntry | IChatRequestToolSetEntry | IChatRequestDirectoryEntry | IChatRequestFileEntry | INotebookOutputVariableEntry | IElementVariableEntry | IPromptFileVariableEntry | IPromptTextVariableEntry | ISCMHistoryItemVariableEntry | ISCMHistoryItemChangeVariableEntry | ISCMHistoryItemChangeRangeVariableEntry | ITerminalVariableEntry - | IChatRequestStringVariableEntry | IChatRequestWorkspaceVariableEntry | IDebugVariableEntry; + | IChatRequestStringVariableEntry | IChatRequestWorkspaceVariableEntry | IDebugVariableEntry | IAgentFeedbackVariableEntry; export namespace IChatRequestVariableEntry { @@ -361,6 +372,10 @@ export function isDebugVariableEntry(obj: IChatRequestVariableEntry): obj is IDe return obj.kind === 'debugVariable'; } +export function isAgentFeedbackVariableEntry(obj: IChatRequestVariableEntry): obj is IAgentFeedbackVariableEntry { + return obj.kind === 'agentFeedback'; +} + export function isPasteVariableEntry(obj: IChatRequestVariableEntry): obj is IChatRequestPasteVariableEntry { return obj.kind === 'paste'; } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 7e0aa5701f283..610e43616bf93 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1446,6 +1446,7 @@ export interface IChatSessionContext { readonly chatSessionType: string; readonly chatSessionResource: URI; readonly isUntitled: boolean; + readonly initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string | { id: string; name: string } }>; } export const KEYWORD_ACTIVIATION_SETTING_ID = 'accessibility.voice.keywordActivation'; diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index c07de21aed866..e804c91874405 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -1393,6 +1393,7 @@ export interface IChatModel extends IDisposable { toExport(): IExportableChatData; toJSON(): ISerializableChatData; readonly contributedChatSession: IChatSessionContext | undefined; + setContributedChatSession(session: IChatSessionContext | undefined): void; readonly repoData: IExportableRepoData | undefined; setRepoData(data: IExportableRepoData | undefined): void; 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 50ca5a8dc0dbd..2b7485a0b05ba 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -194,6 +194,14 @@ export class PromptFilesLocator { eventEmitter.fire(); } })); + disposables.add(this.onDidChangeWorkspaceFolders()(() => { + parentFolders = this.getLocalParentFolders(type); + this.pathService.userHome().then(userHome => { + allSourceFolders = [...this.getSourceFoldersSync(type, userHome)]; + updateExternalFolderWatchers(); + }); + eventEmitter.fire(); + })); disposables.add(this.fileService.onDidFilesChange(e => { if (e.affects(userDataFolder)) { eventEmitter.fire(); diff --git a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts index 4e8021f5899c1..16559339c6c43 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts @@ -2,17 +2,15 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { VSBuffer } from '../../../../../base/common/buffer.js'; import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; import { localize2 } from '../../../../../nls.js'; import { Action2 } from '../../../../../platform/actions/common/actions.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; -import { INativeEnvironmentService } from '../../../../../platform/environment/common/environment.js'; -import { IFileService } from '../../../../../platform/files/common/files.js'; import { INativeHostService } from '../../../../../platform/native/common/native.js'; import { ChatEntitlementContextKeys } from '../../../../services/chat/common/chatEntitlementService.js'; import { CHAT_CATEGORY } from '../../browser/actions/chatActions.js'; import { ProductQualityContext } from '../../../../../platform/contextkey/common/contextkeys.js'; +import { IsSessionsWindowContext } from '../../../../common/contextkeys.js'; export class OpenSessionsWindowAction extends Action2 { constructor() { @@ -20,28 +18,13 @@ export class OpenSessionsWindowAction extends Action2 { id: 'workbench.action.openSessionsWindow', title: localize2('openSessionsWindow', "Open Sessions Window"), category: CHAT_CATEGORY, - precondition: ContextKeyExpr.and(ProductQualityContext.notEqualsTo('stable'), ChatEntitlementContextKeys.Setup.hidden.negate()), + precondition: ContextKeyExpr.and(ProductQualityContext.notEqualsTo('stable'), ChatEntitlementContextKeys.Setup.hidden.negate(), IsSessionsWindowContext.negate()), f1: true, }); } async run(accessor: ServicesAccessor) { - const environmentService = accessor.get(INativeEnvironmentService); const nativeHostService = accessor.get(INativeHostService); - const fileService = accessor.get(IFileService); - - // Create workspace file if it doesn't exist - const workspaceUri = environmentService.agentSessionsWorkspace; - if (!workspaceUri) { - throw new Error('Agent Sessions workspace is not configured'); - } - - const workspaceExists = await fileService.exists(workspaceUri); - if (!workspaceExists) { - const emptyWorkspaceContent = JSON.stringify({ folders: [] }, null, '\t'); - await fileService.writeFile(workspaceUri, VSBuffer.fromString(emptyWorkspaceContent)); - } - - await nativeHostService.openWindow([{ workspaceUri }], { forceNewWindow: true }); + await nativeHostService.openSessionsWindow(); } } diff --git a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts index f21450267d46b..9401936b62eae 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts @@ -10,7 +10,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { IChatEditingSession } from '../../../common/editing/chatEditingService.js'; import { IChatChangeEvent, IChatModel, IChatPendingRequest, IChatRequestModel, IChatRequestNeedsInputInfo, IExportableChatData, IExportableRepoData, IInputModel, ISerializableChatData } from '../../../common/model/chatModel.js'; import { ChatAgentLocation } from '../../../common/constants.js'; -import { IChatSessionTiming } from '../../../common/chatService/chatService.js'; +import { IChatSessionContext, IChatSessionTiming } from '../../../common/chatService/chatService.js'; export class MockChatModel extends Disposable implements IChatModel { readonly onDidDispose = this._register(new Emitter()).event; @@ -49,6 +49,10 @@ export class MockChatModel extends Disposable implements IChatModel { this.lastRequestObs = observableValue('lastRequest', undefined); } + setContributedChatSession(session: IChatSessionContext | undefined): void { + throw new Error('Method not implemented.'); + } + readonly hasRequests = false; readonly lastRequest: IChatRequestModel | undefined; diff --git a/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts b/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts index a6b9475f06dca..bb37f7a15d61b 100644 --- a/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts +++ b/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts @@ -238,8 +238,8 @@ export class WorkspaceChangeExtHostRelauncher extends Disposable implements IWor return; // no restart when in tests: see https://github.com/microsoft/vscode/issues/66936 } - if (contextService.getWorkspace().isAgentSessionsWorkspace) { - return; // no restart for agent sessions workspace + if (environmentService.isSessionsWindow) { + return; // no restart for sessions window } if (environmentService.remoteAuthority) { diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts b/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts index 40b580ef0f0fc..71745671670df 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.contribution.ts @@ -19,7 +19,7 @@ import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/edit import { ViewPaneContainer } from '../../../browser/parts/views/viewPaneContainer.js'; import { WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; import { EditorExtensions, IEditorFactoryRegistry } from '../../../common/editor.js'; -import { IViewContainersRegistry, IViewsRegistry, Extensions as ViewContainerExtensions, ViewContainerLocation } from '../../../common/views.js'; +import { IViewContainersRegistry, IViewsRegistry, Extensions as ViewContainerExtensions, ViewContainerLocation, WindowVisibility } from '../../../common/views.js'; import { ITerminalProfileService, TERMINAL_VIEW_ID, TerminalCommandId } from '../common/terminal.js'; import { TerminalEditingService } from './terminalEditingService.js'; import { registerColors } from '../common/terminalColorRegistry.js'; @@ -111,6 +111,7 @@ const VIEW_CONTAINER = Registry.as(ViewContainerExtensi storageId: TERMINAL_VIEW_ID, hideIfEmpty: true, order: 3, + windowVisibility: WindowVisibility.Both }, ViewContainerLocation.Panel, { doNotRegisterOpenCommand: true, isDefault: true }); Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews([{ id: TERMINAL_VIEW_ID, @@ -119,6 +120,7 @@ Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews canToggleVisibility: true, canMoveView: true, ctorDescriptor: new SyncDescriptor(TerminalViewPane), + windowVisibility: WindowVisibility.Both, openCommandActionDescriptor: { id: TerminalCommandId.Toggle, mnemonicTitle: nls.localize({ key: 'miToggleIntegratedTerminal', comment: ['&& denotes a mnemonic'] }, "&&Terminal"), diff --git a/src/vs/workbench/contrib/terminal/browser/terminalGroup.ts b/src/vs/workbench/contrib/terminal/browser/terminalGroup.ts index da3402de126b1..fbd8325319e16 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalGroup.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalGroup.ts @@ -573,6 +573,8 @@ export class TerminalGroup extends Disposable implements ITerminalGroup { return this._layoutService.getSideBarPosition(); case ViewContainerLocation.AuxiliaryBar: return this._layoutService.getSideBarPosition() === Position.LEFT ? Position.RIGHT : Position.LEFT; + default: + return this._panelPosition; } } diff --git a/src/vs/workbench/services/configuration/browser/configurationService.ts b/src/vs/workbench/services/configuration/browser/configurationService.ts index 7da80d02749db..d095b024d40a2 100644 --- a/src/vs/workbench/services/configuration/browser/configurationService.ts +++ b/src/vs/workbench/services/configuration/browser/configurationService.ts @@ -109,7 +109,7 @@ export class WorkspaceService extends Disposable implements IWorkbenchConfigurat constructor( { remoteAuthority, configurationCache }: { remoteAuthority?: string; configurationCache: IConfigurationCache }, - private readonly environmentService: IBrowserWorkbenchEnvironmentService, + environmentService: IBrowserWorkbenchEnvironmentService, private readonly userDataProfileService: IUserDataProfileService, private readonly userDataProfilesService: IUserDataProfilesService, private readonly fileService: IFileService, @@ -519,8 +519,7 @@ export class WorkspaceService extends Disposable implements IWorkbenchConfigurat const workspaceConfigPath = workspaceIdentifier.configPath; const workspaceFolders = toWorkspaceFolders(this.workspaceConfiguration.getFolders(), workspaceConfigPath, this.uriIdentityService.extUri); const workspaceId = workspaceIdentifier.id; - const isAgentSessionsWorkspace = this.uriIdentityService.extUri.isEqual(workspaceConfigPath, this.environmentService.agentSessionsWorkspace); - const workspace = new Workspace(workspaceId, workspaceFolders, this.workspaceConfiguration.isTransient(), workspaceConfigPath, uri => this.uriIdentityService.extUri.ignorePathCasing(uri), isAgentSessionsWorkspace); + const workspace = new Workspace(workspaceId, workspaceFolders, this.workspaceConfiguration.isTransient(), workspaceConfigPath, uri => this.uriIdentityService.extUri.ignorePathCasing(uri)); workspace.initialized = this.workspaceConfiguration.initialized; return workspace; } diff --git a/src/vs/workbench/services/environment/browser/environmentService.ts b/src/vs/workbench/services/environment/browser/environmentService.ts index ec1a621d29e78..d0bc952a87478 100644 --- a/src/vs/workbench/services/environment/browser/environmentService.ts +++ b/src/vs/workbench/services/environment/browser/environmentService.ts @@ -254,6 +254,9 @@ export class BrowserWorkbenchEnvironmentService implements IBrowserWorkbenchEnvi @memoize get disableWorkspaceTrust(): boolean { return !this.options.enableWorkspaceTrust; } + @memoize + get isSessionsWindow(): boolean { return false; } + @memoize get profile(): string | undefined { return this.payload?.get('profile'); } diff --git a/src/vs/workbench/services/environment/common/environmentService.ts b/src/vs/workbench/services/environment/common/environmentService.ts index 5312892fe6f1d..3ad7edaa43405 100644 --- a/src/vs/workbench/services/environment/common/environmentService.ts +++ b/src/vs/workbench/services/environment/common/environmentService.ts @@ -35,6 +35,7 @@ export interface IWorkbenchEnvironmentService extends IEnvironmentService { readonly skipReleaseNotes: boolean; readonly skipWelcome: boolean; readonly disableWorkspaceTrust: boolean; + readonly isSessionsWindow: boolean; readonly webviewExternalEndpoint: string; // --- Development diff --git a/src/vs/workbench/services/environment/electron-browser/environmentService.ts b/src/vs/workbench/services/environment/electron-browser/environmentService.ts index 563753dfedc65..9d08b14460784 100644 --- a/src/vs/workbench/services/environment/electron-browser/environmentService.ts +++ b/src/vs/workbench/services/environment/electron-browser/environmentService.ts @@ -151,6 +151,9 @@ export class NativeWorkbenchEnvironmentService extends AbstractNativeEnvironment @memoize get filesToWait(): IPathsToWaitFor | undefined { return this.configuration.filesToWait; } + @memoize + get isSessionsWindow(): boolean { return !!this.configuration.isSessionsWindow; } + constructor( private readonly configuration: INativeWindowConfiguration, productService: IProductService diff --git a/src/vs/workbench/services/extensions/browser/webWorkerExtensionHost.ts b/src/vs/workbench/services/extensions/browser/webWorkerExtensionHost.ts index a95e4ae1b5074..3d0f306ac45f9 100644 --- a/src/vs/workbench/services/extensions/browser/webWorkerExtensionHost.ts +++ b/src/vs/workbench/services/extensions/browser/webWorkerExtensionHost.ts @@ -318,14 +318,14 @@ export class WebWorkerExtensionHost extends Disposable implements IExtensionHost extensionTestsLocationURI: this._environmentService.extensionTestsLocationURI, globalStorageHome: this._userDataProfilesService.defaultProfile.globalStorageHome, workspaceStorageHome: this._environmentService.workspaceStorageHome, - extensionLogLevel: this._defaultLogLevelsService.defaultLogLevels.extensions + extensionLogLevel: this._defaultLogLevelsService.defaultLogLevels.extensions, + isSessionsWindow: this._environmentService.isSessionsWindow }, workspace: this._contextService.getWorkbenchState() === WorkbenchState.EMPTY ? undefined : { configuration: workspace.configuration || undefined, id: workspace.id, name: this._labelService.getWorkspaceLabel(workspace), - transient: workspace.transient, - isAgentSessionsWorkspace: workspace.isAgentSessionsWorkspace + transient: workspace.transient }, consoleForward: { includeStack: false, diff --git a/src/vs/workbench/services/extensions/common/extensionHostProtocol.ts b/src/vs/workbench/services/extensions/common/extensionHostProtocol.ts index 54b8c4f9e78c3..4f7f8ff9fe889 100644 --- a/src/vs/workbench/services/extensions/common/extensionHostProtocol.ts +++ b/src/vs/workbench/services/extensions/common/extensionHostProtocol.ts @@ -75,6 +75,7 @@ export interface IEnvironment { useHostProxy?: boolean; skipWorkspaceStorageLock?: boolean; extensionLogLevel?: [string, LogLevel][]; + isSessionsWindow?: boolean; } export interface IStaticWorkspaceData { @@ -83,7 +84,6 @@ export interface IStaticWorkspaceData { transient?: boolean; configuration?: UriComponents | null; isUntitled?: boolean | null; - isAgentSessionsWorkspace?: boolean; } export interface MessagePortLike { diff --git a/src/vs/workbench/services/extensions/common/remoteExtensionHost.ts b/src/vs/workbench/services/extensions/common/remoteExtensionHost.ts index 9a3b997f2a6a7..cc716aa469f62 100644 --- a/src/vs/workbench/services/extensions/common/remoteExtensionHost.ts +++ b/src/vs/workbench/services/extensions/common/remoteExtensionHost.ts @@ -225,7 +225,8 @@ export class RemoteExtensionHost extends Disposable implements IExtensionHost { extensionTestsLocationURI: this._environmentService.extensionTestsLocationURI, globalStorageHome: remoteInitData.globalStorageHome, workspaceStorageHome: remoteInitData.workspaceStorageHome, - extensionLogLevel: this._defaultLogLevelsService.defaultLogLevels.extensions + extensionLogLevel: this._defaultLogLevelsService.defaultLogLevels.extensions, + isSessionsWindow: this._environmentService.isSessionsWindow }, workspace: this._contextService.getWorkbenchState() === WorkbenchState.EMPTY ? null : { configuration: workspace.configuration, diff --git a/src/vs/workbench/services/extensions/electron-browser/localProcessExtensionHost.ts b/src/vs/workbench/services/extensions/electron-browser/localProcessExtensionHost.ts index d5bfbd3b5624d..af93d8558dea5 100644 --- a/src/vs/workbench/services/extensions/electron-browser/localProcessExtensionHost.ts +++ b/src/vs/workbench/services/extensions/electron-browser/localProcessExtensionHost.ts @@ -488,15 +488,15 @@ export class NativeLocalProcessExtensionHost extends Disposable implements IExte extensionTestsLocationURI: this._environmentService.extensionTestsLocationURI, globalStorageHome: this._userDataProfilesService.defaultProfile.globalStorageHome, workspaceStorageHome: this._environmentService.workspaceStorageHome, - extensionLogLevel: this._defaultLogLevelsService.defaultLogLevels.extensions + extensionLogLevel: this._defaultLogLevelsService.defaultLogLevels.extensions, + isSessionsWindow: this._environmentService.isSessionsWindow }, workspace: this._contextService.getWorkbenchState() === WorkbenchState.EMPTY ? undefined : { configuration: workspace.configuration ?? undefined, id: workspace.id, name: this._labelService.getWorkspaceLabel(workspace), isUntitled: workspace.configuration ? isUntitledWorkspace(workspace.configuration, this._environmentService) : false, - transient: workspace.transient, - isAgentSessionsWorkspace: workspace.isAgentSessionsWorkspace + transient: workspace.transient }, remote: { authority: this._environmentService.remoteAuthority, diff --git a/src/vs/workbench/services/label/common/labelService.ts b/src/vs/workbench/services/label/common/labelService.ts index de859bba7ddda..47b509ff20f17 100644 --- a/src/vs/workbench/services/label/common/labelService.ts +++ b/src/vs/workbench/services/label/common/labelService.ts @@ -308,10 +308,6 @@ export class LabelService extends Disposable implements ILabelService { getWorkspaceLabel(workspace: IWorkspace | IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier | URI, options?: { verbose: Verbosity }): string { if (isWorkspace(workspace)) { - if (workspace.isAgentSessionsWorkspace) { - return localize('agentSessionsWorkspace', "Agent Sessions"); - } - const identifier = toWorkspaceIdentifier(workspace); if (isSingleFolderWorkspaceIdentifier(identifier) || isWorkspaceIdentifier(identifier)) { return this.getWorkspaceLabel(identifier, options); diff --git a/src/vs/workbench/services/layout/browser/layoutService.ts b/src/vs/workbench/services/layout/browser/layoutService.ts index 9e05d8b0687c8..aa67ec08d6ad0 100644 --- a/src/vs/workbench/services/layout/browser/layoutService.ts +++ b/src/vs/workbench/services/layout/browser/layoutService.ts @@ -25,6 +25,7 @@ export const enum Parts { SIDEBAR_PART = 'workbench.parts.sidebar', PANEL_PART = 'workbench.parts.panel', AUXILIARYBAR_PART = 'workbench.parts.auxiliarybar', + CHATBAR_PART = 'workbench.parts.chatbar', EDITOR_PART = 'workbench.parts.editor', STATUSBAR_PART = 'workbench.parts.statusbar' } diff --git a/src/vs/workbench/services/views/browser/viewDescriptorService.ts b/src/vs/workbench/services/views/browser/viewDescriptorService.ts index 62cf852a83b0a..79ff9dd03ccd1 100644 --- a/src/vs/workbench/services/views/browser/viewDescriptorService.ts +++ b/src/vs/workbench/services/views/browser/viewDescriptorService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ViewContainerLocation, IViewDescriptorService, ViewContainer, IViewsRegistry, IViewContainersRegistry, IViewDescriptor, Extensions as ViewExtensions, ViewVisibilityState, defaultViewIcon, ViewContainerLocationToString, VIEWS_LOG_ID, VIEWS_LOG_NAME } from '../../../common/views.js'; +import { ViewContainerLocation, IViewDescriptorService, ViewContainer, IViewsRegistry, IViewContainersRegistry, IViewDescriptor, Extensions as ViewExtensions, ViewVisibilityState, defaultViewIcon, ViewContainerLocationToString, VIEWS_LOG_ID, VIEWS_LOG_NAME, WindowVisibility } from '../../../common/views.js'; import { IContextKey, RawContextKey, IContextKeyService, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IExtensionService } from '../../extensions/common/extensions.js'; @@ -24,6 +24,7 @@ import { ILogger, ILoggerService } from '../../../../platform/log/common/log.js' import { Lazy } from '../../../../base/common/lazy.js'; import { IViewsService } from '../common/viewsService.js'; import { windowLogGroup } from '../../log/common/logConstants.js'; +import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js'; interface IViewsCustomizations { viewContainerLocations: IStringDictionary; @@ -69,6 +70,7 @@ export class ViewDescriptorService extends Disposable implements IViewDescriptor get viewContainers(): ReadonlyArray { return this.viewContainersRegistry.all; } private readonly logger: Lazy; + private readonly isSessionsWindow: boolean; constructor( @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -77,10 +79,12 @@ export class ViewDescriptorService extends Disposable implements IViewDescriptor @IExtensionService private readonly extensionService: IExtensionService, @ITelemetryService private readonly telemetryService: ITelemetryService, @ILoggerService loggerService: ILoggerService, + @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, ) { super(); this.logger = new Lazy(() => loggerService.createLogger(VIEWS_LOG_ID, { name: VIEWS_LOG_NAME, group: windowLogGroup })); + this.isSessionsWindow = environmentService.isSessionsWindow; this.activeViewContextKeys = new Map>(); this.movableViewContextKeys = new Map>(); @@ -263,7 +267,11 @@ export class ViewDescriptorService extends Disposable implements IViewDescriptor } getViewDescriptorById(viewId: string): IViewDescriptor | null { - return this.viewsRegistry.getView(viewId); + const view = this.viewsRegistry.getView(viewId); + if (view && !this.isViewVisible(view)) { + return null; + } + return view; } getViewLocationById(viewId: string): ViewContainerLocation | null { @@ -276,6 +284,12 @@ export class ViewDescriptorService extends Disposable implements IViewDescriptor } getViewContainerByViewId(viewId: string): ViewContainer | null { + // Check if the view itself should be visible in current workspace + const view = this.viewsRegistry.getView(viewId); + if (view && !this.isViewVisible(view)) { + return null; + } + const containerId = this.viewDescriptorsCustomLocations.get(viewId); return containerId ? @@ -284,11 +298,21 @@ export class ViewDescriptorService extends Disposable implements IViewDescriptor } getViewContainerLocation(viewContainer: ViewContainer): ViewContainerLocation { - return this.viewContainersCustomLocations.get(viewContainer.id) ?? this.getDefaultViewContainerLocation(viewContainer); + const location = this.viewContainersCustomLocations.get(viewContainer.id) ?? this.getDefaultViewContainerLocation(viewContainer); + return this.getEffectiveViewContainerLocation(location); } getDefaultViewContainerLocation(viewContainer: ViewContainer): ViewContainerLocation { - return this.viewContainersRegistry.getViewContainerLocation(viewContainer); + return this.getEffectiveViewContainerLocation(this.viewContainersRegistry.getViewContainerLocation(viewContainer)); + } + + private getEffectiveViewContainerLocation(location: ViewContainerLocation): ViewContainerLocation { + // When not in agent sessions workspace, view containers contributed to ChatBar + // should be registered at the AuxiliaryBar location instead + if (!this.isSessionsWindow && location === ViewContainerLocation.ChatBar) { + return ViewContainerLocation.AuxiliaryBar; + } + return location; } getDefaultContainerById(viewId: string): ViewContainer | null { @@ -300,19 +324,40 @@ export class ViewDescriptorService extends Disposable implements IViewDescriptor } getViewContainerById(id: string): ViewContainer | null { - return this.viewContainersRegistry.get(id) || null; + const viewContainer = this.viewContainersRegistry.get(id) || null; + if (viewContainer && !this.isViewContainerVisible(viewContainer)) { + return null; + } + return viewContainer; } getViewContainersByLocation(location: ViewContainerLocation): ViewContainer[] { - return this.viewContainers.filter(v => this.getViewContainerLocation(v) === location); + return this.viewContainers.filter(v => this.getViewContainerLocation(v) === location && this.isViewContainerVisible(v)); + } + + private isViewContainerVisible(viewContainer: ViewContainer): boolean { + const layoutVisibility = viewContainer.windowVisibility; + if (this.isSessionsWindow) { + return layoutVisibility === WindowVisibility.Sessions || layoutVisibility === WindowVisibility.Both; + } + return !layoutVisibility || layoutVisibility === WindowVisibility.Editor || layoutVisibility === WindowVisibility.Both; + } + + private isViewVisible(view: IViewDescriptor): boolean { + const layoutVisibility = view.windowVisibility; + if (this.isSessionsWindow) { + return layoutVisibility === WindowVisibility.Sessions || layoutVisibility === WindowVisibility.Both; + } + return !layoutVisibility || layoutVisibility === WindowVisibility.Editor || layoutVisibility === WindowVisibility.Both; } getDefaultViewContainer(location: ViewContainerLocation): ViewContainer | undefined { - return this.viewContainersRegistry.getDefaultViewContainers(location)[0]; + const viewContainers = this.viewContainersRegistry.getDefaultViewContainers(location); + return viewContainers.find(viewContainer => this.isViewContainerVisible(viewContainer)); } canMoveViews(): boolean { - return true; + return !this.isSessionsWindow; } moveViewContainerToLocation(viewContainer: ViewContainer, location: ViewContainerLocation, requestedIndex?: number, reason?: string): void { @@ -673,10 +718,16 @@ export class ViewDescriptorService extends Disposable implements IViewDescriptor } private getStoredViewCustomizationsValue(): string { + if (this.isSessionsWindow) { + return '{}'; + } return this.storageService.get(ViewDescriptorService.VIEWS_CUSTOMIZATIONS, StorageScope.PROFILE, '{}'); } private setStoredViewCustomizationsValue(value: string): void { + if (this.isSessionsWindow) { + return; + } this.storageService.store(ViewDescriptorService.VIEWS_CUSTOMIZATIONS, value, StorageScope.PROFILE, StorageTarget.USER); } diff --git a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts index 702928e173ace..74ce76f240dfa 100644 --- a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts @@ -103,6 +103,8 @@ export class TestNativeHostService implements INativeHostService { throw new Error('Method not implemented.'); } + async openSessionsWindow(): Promise { } + async toggleFullScreen(): Promise { } async isMaximized(): Promise { return true; } async isFullScreen(): Promise { return true; } diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index e069bc0f6b677..04df7ca03d790 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -464,6 +464,11 @@ declare module 'vscode' { export interface ChatSessionContext { readonly chatSessionItem: ChatSessionItem; // Maps to URI of chat session editor (could be 'untitled-1', etc..) readonly isUntitled: boolean; + /** + * The initial option selections for the session, provided with the first request. + * Contains the options the user selected (or defaults) before the session was created. + */ + readonly initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string | ChatSessionProviderOptionItem }>; } export interface ChatSessionCapabilities { From d797e9f9146c285620df3a07b356c86a4f7aa7d9 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 17 Feb 2026 16:13:44 +0100 Subject: [PATCH 15/20] sessions - tweak workspace settings for `vs/sessions` (#295775) * sessions - tweak workspace settings for `vs/sessions` * ccr --- .vscode/settings.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 3e903f646758c..9587ec262cfc0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -49,7 +49,7 @@ "build/**/*.js.map": true, "build/**/*.js": { "when": "$(basename).ts" - } + }, }, "files.associations": { "cglicenses.json": "jsonc", @@ -69,7 +69,8 @@ "extensions/terminal-suggest/src/completions/upstream/**": true, "test/smoke/out/**": true, "test/automation/out/**": true, - "test/integration/browser/out/**": true + "test/integration/browser/out/**": true, + "src/vs/sessions/**": true }, // --- Search --- "search.exclude": { @@ -92,6 +93,7 @@ "src/vs/editor/test/node/diffing/fixtures/**": true, "build/loader.min": true, "**/*.snap": true, + "src/vs/sessions/**": true }, // --- TypeScript --- "typescript.experimental.useTsgo": true, From d112b3987e43f9d29c57d2bb155bc678014d9c3e Mon Sep 17 00:00:00 2001 From: Robo Date: Wed, 18 Feb 2026 00:13:55 +0900 Subject: [PATCH 16/20] chore: update application name logic for win32 scripts (#295777) --- build/gulpfile.vscode.ts | 37 ++++++++++++++++++++++++------------- scripts/code-cli.bat | 3 ++- scripts/code.bat | 3 ++- scripts/node-electron.bat | 3 ++- scripts/test.bat | 3 ++- 5 files changed, 32 insertions(+), 17 deletions(-) diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index cbf6cc9452708..31c31f58406ab 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -387,14 +387,20 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d })); - const packageSubJsonStream = gulp.src(['package.json'], { base: '.' }) - .pipe(jsonEditor((json: Record) => { - json.name = `sessions-${quality || 'oss-dev'}`; - return json; - })) - .pipe(rename('package.sub.json')); - - const embedded = (product as typeof product & { embedded?: { nameShort: string; nameLong: string; applicationName: string; dataFolderName: string; darwinBundleIdentifier: string } }).embedded; + const isInsiderOrExploration = quality === 'insider' || quality === 'exploration'; + const embedded = isInsiderOrExploration + ? (product as typeof product & { embedded?: { nameShort: string; nameLong: string; applicationName: string; dataFolderName: string; darwinBundleIdentifier: string } }).embedded + : undefined; + + const packageSubJsonStream = isInsiderOrExploration + ? gulp.src(['package.json'], { base: '.' }) + .pipe(jsonEditor((json: Record) => { + json.name = `sessions-${quality || 'oss-dev'}`; + return json; + })) + .pipe(rename('package.sub.json')) + : undefined; + const productSubJsonStream = embedded ? gulp.src(['product.json'], { base: '.' }) .pipe(jsonEditor((json: Record) => { @@ -406,7 +412,7 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d return json; })) .pipe(rename('product.sub.json')) - : gulp.src(['product.sub.json'], { base: '.', allowEmpty: true }); + : undefined; const license = gulp.src([product.licenseFileName, 'ThirdPartyNotices.txt', 'licenses/**'], { base: '.', allowEmpty: true }); @@ -443,17 +449,22 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d 'node_modules/vsda/**' // retain copy of `vsda` in node_modules for internal use ], 'node_modules.asar')); - let all = es.merge( + const mergeStreams = [ packageJsonStream, productJsonStream, - packageSubJsonStream, - productSubJsonStream, license, api, telemetry, sources, deps - ); + ]; + if (packageSubJsonStream) { + mergeStreams.push(packageSubJsonStream); + } + if (productSubJsonStream) { + mergeStreams.push(productSubJsonStream); + } + let all = es.merge(...mergeStreams); if (platform === 'win32') { all = es.merge(all, gulp.src([ diff --git a/scripts/code-cli.bat b/scripts/code-cli.bat index e28f03f6cdcb8..543106f45c836 100644 --- a/scripts/code-cli.bat +++ b/scripts/code-cli.bat @@ -8,7 +8,8 @@ pushd %~dp0.. :: Get electron, compile, built-in extensions if "%VSCODE_SKIP_PRELAUNCH%"=="" node build/lib/preLaunch.ts -for /f "tokens=2 delims=:," %%a in ('findstr /R /C:"\"nameShort\":.*" product.json') do set NAMESHORT=%%~a +set "NAMESHORT=" +for /f "tokens=2 delims=:," %%a in ('findstr /R /C:"\"nameShort\":.*" product.json') do if not defined NAMESHORT set "NAMESHORT=%%~a" set NAMESHORT=%NAMESHORT: "=% set NAMESHORT=%NAMESHORT:"=%.exe set CODE=".build\electron\%NAMESHORT%" diff --git a/scripts/code.bat b/scripts/code.bat index 784efeaecaf78..62cfd0b4c4908 100644 --- a/scripts/code.bat +++ b/scripts/code.bat @@ -10,7 +10,8 @@ if "%VSCODE_SKIP_PRELAUNCH%"=="" ( node build/lib/preLaunch.ts ) -for /f "tokens=2 delims=:," %%a in ('findstr /R /C:"\"nameShort\":.*" product.json') do set NAMESHORT=%%~a +set "NAMESHORT=" +for /f "tokens=2 delims=:," %%a in ('findstr /R /C:"\"nameShort\":.*" product.json') do if not defined NAMESHORT set "NAMESHORT=%%~a" set NAMESHORT=%NAMESHORT: "=% set NAMESHORT=%NAMESHORT:"=%.exe set CODE=".build\electron\%NAMESHORT%" diff --git a/scripts/node-electron.bat b/scripts/node-electron.bat index ee24da09accd6..33d4fdd71fc44 100644 --- a/scripts/node-electron.bat +++ b/scripts/node-electron.bat @@ -5,7 +5,8 @@ set ELECTRON_RUN_AS_NODE=1 pushd %~dp0\.. -for /f "tokens=2 delims=:," %%a in ('findstr /R /C:"\"nameShort\":.*" product.json') do set NAMESHORT=%%~a +set "NAMESHORT=" +for /f "tokens=2 delims=:," %%a in ('findstr /R /C:"\"nameShort\":.*" product.json') do if not defined NAMESHORT set "NAMESHORT=%%~a" set NAMESHORT=%NAMESHORT: "=% set NAMESHORT=%NAMESHORT:"=%.exe set CODE=".build\electron\%NAMESHORT%" diff --git a/scripts/test.bat b/scripts/test.bat index 4e0f89fb501b0..dee40972d809b 100644 --- a/scripts/test.bat +++ b/scripts/test.bat @@ -6,7 +6,8 @@ set ELECTRON_RUN_AS_NODE= pushd %~dp0\.. :: Get Code.exe location -for /f "tokens=2 delims=:," %%a in ('findstr /R /C:"\"nameShort\":.*" product.json') do set NAMESHORT=%%~a +set "NAMESHORT=" +for /f "tokens=2 delims=:," %%a in ('findstr /R /C:"\"nameShort\":.*" product.json') do if not defined NAMESHORT set "NAMESHORT=%%~a" set NAMESHORT=%NAMESHORT: "=% set NAMESHORT=%NAMESHORT:"=%.exe set CODE=".build\electron\%NAMESHORT%" From 8ad59c5ed787e609529d46a8429b6d31f623ee92 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:15:42 +0100 Subject: [PATCH 17/20] SCM - fix cyclic dependency (#295779) --- src/vs/workbench/contrib/scm/browser/scm.contribution.ts | 5 +++-- src/vs/workbench/contrib/scm/browser/scmInput.ts | 9 ++++++--- src/vs/workbench/contrib/scm/browser/scmViewPane.ts | 1 - 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/scm/browser/scm.contribution.ts b/src/vs/workbench/contrib/scm/browser/scm.contribution.ts index c38dc40be0647..8f2ea73835db4 100644 --- a/src/vs/workbench/contrib/scm/browser/scm.contribution.ts +++ b/src/vs/workbench/contrib/scm/browser/scm.contribution.ts @@ -49,6 +49,7 @@ import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.j import { SCMHistoryItemContextContribution } from './scmHistoryChatContext.js'; import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; import { CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID } from '../../chat/browser/actions/chatActions.js'; +import { SCMInputContextKeys } from './scmInput.js'; import product from '../../../../platform/product/common/product.js'; ModesRegistry.registerLanguage({ @@ -465,7 +466,7 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ weight: KeybindingWeight.WorkbenchContrib, when: ContextKeyExpr.and( ContextKeyExpr.has('scmRepository'), - ContextKeys.SCMInputHasValidationMessage), + SCMInputContextKeys.SCMInputHasValidationMessage), primary: KeyCode.Escape, handler: async (accessor) => { const scmViewService = accessor.get(ISCMViewService); @@ -480,7 +481,7 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ ContextKeyExpr.has('scmRepository'), SuggestContext.Visible.toNegated(), InlineCompletionContextKeys.inlineSuggestionVisible.toNegated(), - ContextKeys.SCMInputHasValidationMessage.toNegated(), + SCMInputContextKeys.SCMInputHasValidationMessage.toNegated(), EditorContextKeys.hasNonEmptySelection.toNegated()), primary: KeyCode.Escape, handler: async (accessor) => { diff --git a/src/vs/workbench/contrib/scm/browser/scmInput.ts b/src/vs/workbench/contrib/scm/browser/scmInput.ts index 32c7bdd4f5c44..bcc312c6ba2c3 100644 --- a/src/vs/workbench/contrib/scm/browser/scmInput.ts +++ b/src/vs/workbench/contrib/scm/browser/scmInput.ts @@ -10,7 +10,7 @@ import { append, $, Dimension, trackFocus } from '../../../../base/browser/dom.j import { InputValidationType, ISCMInput, IInputValidation, ISCMViewService, SCMInputChangeReason, ISCMInputValueProviderContext } from '../common/scm.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IContextViewService, IContextMenuService, IOpenContextView } from '../../../../platform/contextview/browser/contextView.js'; -import { IContextKeyService, IContextKey, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextKeyService, IContextKey, ContextKeyExpr, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { MenuItemAction, IMenuService, registerAction2, MenuId, Action2 } from '../../../../platform/actions/common/actions.js'; @@ -71,7 +71,10 @@ import { AccessibilityCommandId } from '../../accessibility/common/accessibility import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; import product from '../../../../platform/product/common/product.js'; import { CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID } from '../../chat/browser/actions/chatActions.js'; -import { ContextKeys } from './scmViewPane.js'; + +export const SCMInputContextKeys = { + SCMInputHasValidationMessage: new RawContextKey('scmInputHasValidationMessage', false), +}; const enum SCMInputWidgetCommandId { CancelAction = 'scm.input.cancelAction', @@ -578,7 +581,7 @@ export class SCMInputWidget { this.contextKeyService = this.disposables.add(contextKeyService.createScoped(this.element)); this.repositoryIdContextKey = this.contextKeyService.createKey('scmRepository', undefined); - this.validationMessageContextKey = ContextKeys.SCMInputHasValidationMessage.bindTo(this.contextKeyService); + this.validationMessageContextKey = SCMInputContextKeys.SCMInputHasValidationMessage.bindTo(this.contextKeyService); this.inputEditorOptions = new SCMInputWidgetEditorOptions(overflowWidgetsDomNode, this.configurationService); this.disposables.add(this.inputEditorOptions.onDidChange(this.onDidChangeEditorOptions, this)); diff --git a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts index 2f54b335bad75..a2887d47f2521 100644 --- a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts @@ -945,7 +945,6 @@ export const ContextKeys = { SCMCurrentHistoryItemRefInFilter: new RawContextKey('scmCurrentHistoryItemRefInFilter', false), RepositoryCount: new RawContextKey('scmRepositoryCount', 0), RepositoryVisibilityCount: new RawContextKey('scmRepositoryVisibleCount', 0), - SCMInputHasValidationMessage: new RawContextKey('scmInputHasValidationMessage', false), RepositoryVisibility(repository: ISCMRepository) { return new RawContextKey(`scmRepositoryVisible:${repository.provider.id}`, false); } From 2a817bb097e37e5d8c32d7d6546a9ef9701d2fb0 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:17:28 +0100 Subject: [PATCH 18/20] Engineering - Delete the worktree hook file for the time being (#295776) Delete the worktree hook file for the time being --- .github/hooks/worktree.json | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 .github/hooks/worktree.json diff --git a/.github/hooks/worktree.json b/.github/hooks/worktree.json deleted file mode 100644 index a9f68bd12b9f1..0000000000000 --- a/.github/hooks/worktree.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "version": 1, - "hooks": { - "sessionStart": [ - { - "type": "command", - "bash": "npm install", - "powershell": "npm install", - "timeoutSec": 120 - } - ], - "sessionEnd": [ - { - "type": "command", - "bash": "npm run compile", - "powershell": "npm run compile", - "timeoutSec": 120 - } - ] - } -} From 7ff89864d631df9cc57fe8f26e5b465b88031583 Mon Sep 17 00:00:00 2001 From: Alex Ross <38270282+alexr00@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:23:22 +0100 Subject: [PATCH 19/20] Add list scroll right offset variable (#295741) --- build/lib/stylelint/vscode-known-variables.json | 1 + 1 file changed, 1 insertion(+) diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index acf81bb35cb99..a913a9534fcfc 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -917,6 +917,7 @@ "--inline-chat-frame-progress", "--insert-border-color", "--last-tab-margin-right", + "--list-scroll-right-offset", "--monaco-monospace-font", "--monaco-monospace-font", "--notebook-cell-input-preview-font-family", From a102af468e4efa003b6f7ee0a869a120a67af85e Mon Sep 17 00:00:00 2001 From: Sam Shubham <68236217+sam-shubham@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:48:40 +0530 Subject: [PATCH 20/20] Right align actions tree view (#295266) * WIP: keep actions visible when tree has horizontal scroll Fixes #251722 * feat(npm): fixed conssitency of right side aligned buttons * Fix TreeView action alignment during horizontal scrolling Offset actions using --list-scroll-right-offset so they remain aligned to the viewport edge. Apply only to rows containing action items and adjust spacing for correct visual alignment. * fixed extra space issue on nodes having file decoraters * Remove known variable - will add back in a separate PR --------- Co-authored-by: Alex Ross <38270282+alexr00@users.noreply.github.com> --- src/vs/base/browser/ui/list/listView.ts | 8 +++++ .../browser/parts/views/media/views.css | 32 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/vs/base/browser/ui/list/listView.ts b/src/vs/base/browser/ui/list/listView.ts index 978dbb0197e76..6e29b67c503a5 100644 --- a/src/vs/base/browser/ui/list/listView.ts +++ b/src/vs/base/browser/ui/list/listView.ts @@ -370,6 +370,7 @@ export class ListView implements IListView { this.scrollableElementWidthDelayer.cancel(); this.scrollableElement.setScrollDimensions({ width: this.renderWidth, scrollWidth: this.renderWidth }); this.rowsContainer.style.width = ''; + this.domNode.style.removeProperty('--list-scroll-right-offset'); } } @@ -894,6 +895,11 @@ export class ListView implements IListView { this.scrollableElement.setScrollDimensions({ width: typeof width === 'number' ? width : getContentWidth(this.domNode) }); + + const scrollPos = this.scrollableElement.getScrollPosition(); + const scrollDims = this.scrollableElement.getScrollDimensions(); + const rightOffset = Math.max(0, scrollDims.scrollWidth - scrollPos.scrollLeft - this.renderWidth); + this.domNode.style.setProperty('--list-scroll-right-offset', `${Math.max(rightOffset - 12, 0)}px`); } } @@ -935,6 +941,8 @@ export class ListView implements IListView { if (this.horizontalScrolling && scrollWidth !== undefined) { this.rowsContainer.style.width = `${Math.max(scrollWidth, this.renderWidth)}px`; + const rightOffset = Math.max(0, scrollWidth - (renderLeft ?? 0) - this.renderWidth); + this.domNode.style.setProperty('--list-scroll-right-offset', `${Math.max(rightOffset - 12, 0)}px`); } this.lastRenderTop = renderTop; diff --git a/src/vs/workbench/browser/parts/views/media/views.css b/src/vs/workbench/browser/parts/views/media/views.css index 02909b0bd7793..e40976750cf5c 100644 --- a/src/vs/workbench/browser/parts/views/media/views.css +++ b/src/vs/workbench/browser/parts/views/media/views.css @@ -63,6 +63,7 @@ .monaco-workbench .tree-explorer-viewlet-tree-view .message .button-container:not(:last-child) { padding-bottom: 8px; } + .monaco-workbench .tree-explorer-viewlet-tree-view .message.hide { display: none; } @@ -179,6 +180,7 @@ font-size: 13px; line-height: 15px; } + .customview-tree .monaco-list .monaco-list-row .custom-view-tree-node-item .monaco-inputbox { line-height: normal; flex: 1; @@ -218,6 +220,7 @@ .customview-tree .monaco-list .monaco-list-row .custom-view-tree-node-item > .custom-view-tree-node-item-resourceLabel > .custom-view-tree-node-item-icon.disabled { opacity: 0.6; } + /* makes spinning icons square */ .customview-tree .monaco-list .monaco-list-row .custom-view-tree-node-item > .custom-view-tree-node-item-resourceLabel > .custom-view-tree-node-item-icon.codicon.codicon-modifier-spin { padding-left: 6px; @@ -251,6 +254,35 @@ display: block; } +/* When horizontal scrolling is enabled, shift actions back to the viewport's right edge */ +.customview-tree .monaco-list.horizontal-scrolling .monaco-list-row:has(.actions .action-item):hover .custom-view-tree-node-item .actions, + +.customview-tree .monaco-list.horizontal-scrolling .monaco-list-row:has(.actions .action-item).selected .custom-view-tree-node-item .actions, + +.customview-tree .monaco-list.horizontal-scrolling .monaco-list-row:has(.actions .action-item).focused .custom-view-tree-node-item .actions { + position: relative; + right: var(--list-scroll-right-offset, 0px); + background-color: var(--vscode-list-hoverBackground); + padding-left: 4px; +} + +.customview-tree .monaco-list.horizontal-scrolling .monaco-list-row:has(.monaco-icon-label[class*="monaco-decoration-"]) .actions { + right: max(calc(var(--list-scroll-right-offset, 0px) - 17px), + 0px) !important; +} + +.customview-tree .monaco-list.horizontal-scrolling:focus .monaco-list-row.selected .custom-view-tree-node-item .actions { + background-color: var(--vscode-list-activeSelectionBackground); +} + +.customview-tree .monaco-list.horizontal-scrolling:not(:focus) .monaco-list-row.selected .custom-view-tree-node-item .actions { + background-color: var(--vscode-list-inactiveSelectionBackground); +} + +.customview-tree .monaco-list.horizontal-scrolling .monaco-list-row.focused:not(.selected) .custom-view-tree-node-item .actions { + background-color: var(--vscode-list-focusBackground); +} + /* filter view pane */ .monaco-workbench .auxiliarybar.pane-composite-part > .title.has-composite-bar > .title-actions .monaco-action-bar .action-item.viewpane-filter-container {