From 93a13f64e1510aeb27995af6df388b4965e93fe5 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Wed, 18 Mar 2026 19:00:54 +0100 Subject: [PATCH 01/24] chat input: make slash command clickable (#302881) * chat input: make slash command clickable * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * update --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../input/editor/chatInputEditorContrib.ts | 64 ++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts index 0bf73505e4e..d22c7463cf9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts @@ -8,7 +8,9 @@ import { Disposable, MutableDisposable } from '../../../../../../../base/common/ import { autorun } from '../../../../../../../base/common/observable.js'; import { themeColorFromId } from '../../../../../../../base/common/themables.js'; import { URI } from '../../../../../../../base/common/uri.js'; +import { MouseTargetType } from '../../../../../../../editor/browser/editorBrowser.js'; import { ICodeEditorService } from '../../../../../../../editor/browser/services/codeEditorService.js'; +import { Position } from '../../../../../../../editor/common/core/position.js'; import { Range } from '../../../../../../../editor/common/core/range.js'; import { IDecorationOptions } from '../../../../../../../editor/common/editorCommon.js'; import { TrackedRangeStickiness } from '../../../../../../../editor/common/model.js'; @@ -17,6 +19,7 @@ import { ILabelService } from '../../../../../../../platform/label/common/label. import { inputPlaceholderForeground } from '../../../../../../../platform/theme/common/colorRegistry.js'; import { IThemeService } from '../../../../../../../platform/theme/common/themeService.js'; import { IChatAgentCommand, IChatAgentData, IChatAgentService } from '../../../../common/participants/chatAgents.js'; +import { localize } from '../../../../../../../nls.js'; import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../../../common/widget/chatColors.js'; import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, ChatRequestSlashPromptPart, ChatRequestTextPart, ChatRequestToolPart, ChatRequestToolSetPart, IParsedChatRequestPart, chatAgentLeader, chatSubcommandLeader } from '../../../../common/requestParser/chatParserTypes.js'; import { ChatRequestParser } from '../../../../common/requestParser/chatRequestParser.js'; @@ -29,10 +32,12 @@ import { NativeEditContextRegistry } from '../../../../../../../editor/browser/c import { TextAreaEditContextRegistry } from '../../../../../../../editor/browser/controller/editContext/textArea/textAreaEditContextRegistry.js'; import { CancellationToken } from '../../../../../../../base/common/cancellation.js'; import { ThrottledDelayer } from '../../../../../../../base/common/async.js'; +import { IEditorService } from '../../../../../../services/editor/common/editorService.js'; const decorationDescription = 'chat'; const placeholderDecorationType = 'chat-session-detail'; const slashCommandTextDecorationType = 'chat-session-text'; +const clickableSlashPromptTextDecorationType = 'chat-session-clickable-text'; const variableTextDecorationType = 'chat-variable-text'; function agentAndCommandToKey(agent: IChatAgentData, subcommand: string | undefined): string { @@ -69,6 +74,8 @@ class InputEditorDecorations extends Disposable { public readonly id = 'inputEditorDecorations'; private readonly previouslyUsedAgents = new Set(); + private clickablePromptSlashCommand: { range: Range; uri: URI } | undefined; + private mouseDownPromptSlashCommand: { position: Position; uri: URI; range: Range } | undefined; private readonly viewModelDisposables = this._register(new MutableDisposable()); @@ -82,6 +89,7 @@ class InputEditorDecorations extends Disposable { @IChatAgentService private readonly chatAgentService: IChatAgentService, @ILabelService private readonly labelService: ILabelService, @IPromptsService private readonly promptsService: IPromptsService, + @IEditorService private readonly editorService: IEditorService, ) { super(); @@ -97,6 +105,38 @@ class InputEditorDecorations extends Disposable { this._register(this.widget.onDidSubmitAgent((e) => { this.previouslyUsedAgents.add(agentAndCommandToKey(e.agent, e.slashCommand?.name)); })); + this._register(this.widget.inputEditor.onMouseDown(e => { + this.mouseDownPromptSlashCommand = undefined; + + if (!e.event.leftButton || e.target.type !== MouseTargetType.CONTENT_TEXT || !e.target.position) { + return; + } + + const clickablePromptSlashCommand = this.clickablePromptSlashCommand; + if (!clickablePromptSlashCommand || !clickablePromptSlashCommand.range.containsPosition(e.target.position)) { + return; + } + + this.mouseDownPromptSlashCommand = { + position: Position.lift(e.target.position), + uri: clickablePromptSlashCommand.uri, + range: clickablePromptSlashCommand.range, + }; + })); + this._register(this.widget.inputEditor.onMouseUp(e => { + const mouseDownPromptSlashCommand = this.mouseDownPromptSlashCommand; + this.mouseDownPromptSlashCommand = undefined; + + if (!mouseDownPromptSlashCommand || e.target.type !== MouseTargetType.CONTENT_TEXT || !e.target.position) { + return; + } + + if (!mouseDownPromptSlashCommand.range.containsPosition(e.target.position) || !Position.equals(mouseDownPromptSlashCommand.position, e.target.position)) { + return; + } + + void this.editorService.openEditor({ resource: mouseDownPromptSlashCommand.uri }); + })); this._register(this.chatAgentService.onDidChangeAgents(() => this.triggerInputEditorDecorationsUpdate())); this._register(this.promptsService.onDidChangeSlashCommands(() => this.triggerInputEditorDecorationsUpdate())); this._register(autorun(reader => { @@ -128,6 +168,12 @@ class InputEditorDecorations extends Disposable { backgroundColor: themeColorFromId(chatSlashCommandBackground), borderRadius: '3px' })); + this._register(this.codeEditorService.registerDecorationType(decorationDescription, clickableSlashPromptTextDecorationType, { + color: themeColorFromId(chatSlashCommandForeground), + backgroundColor: themeColorFromId(chatSlashCommandBackground), + borderRadius: '3px', + cursor: 'pointer' + })); this._register(this.codeEditorService.registerDecorationType(decorationDescription, variableTextDecorationType, { color: themeColorFromId(chatSlashCommandForeground), backgroundColor: themeColorFromId(chatSlashCommandBackground), @@ -253,6 +299,8 @@ class InputEditorDecorations extends Disposable { } private async updateAsyncInputEditorDecorations(token: CancellationToken): Promise { + this.clickablePromptSlashCommand = undefined; + this.widget.inputEditor.setDecorationsByType(decorationDescription, clickableSlashPromptTextDecorationType, []); const parsedRequest = this.widget.parsedInput.parts; @@ -299,7 +347,21 @@ class InputEditorDecorations extends Disposable { } if (slashPromptPart && promptSlashCommand) { - textDecorations.push({ range: slashPromptPart.editorRange }); + this.clickablePromptSlashCommand = { + range: Range.lift(slashPromptPart.editorRange), + uri: promptSlashCommand.promptPath.uri, + }; + const promptHoverMessage = new MarkdownString(); + promptHoverMessage.appendText(localize( + 'chatInput.promptSlashCommand.open', + "Click to open {0}", + this.labelService.getUriLabel(promptSlashCommand.promptPath.uri, { relative: true }) + )); + const promptDecoration = { + range: slashPromptPart.editorRange, + hoverMessage: promptHoverMessage, + }; + this.widget.inputEditor.setDecorationsByType(decorationDescription, clickableSlashPromptTextDecorationType, [promptDecoration]); } this.widget.inputEditor.setDecorationsByType(decorationDescription, slashCommandTextDecorationType, textDecorations); From 6b80573ebb811ba94236930c9a68130d6d204c0d Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:01:39 -0700 Subject: [PATCH 02/24] customizations: harness follow up (#302878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: filter workspace directory picker by harness subpaths and refresh counts - Apply workspaceSubpaths filter in management editor's resolveTargetDirectoryWithPicker so 'New X (Workspace)' only offers directories relevant to the active harness (e.g. Claude → .claude/ only) - Refresh sidebar section counts when active harness changes so badge numbers reflect the filtered item set * fix: restrict extension sources to Local harness, hide Instructions for Claude - Only the Local harness includes PromptsStorage.extension in its sources — CLI and Claude don't consume extension-contributed customizations - Claude harness now hides the Instructions section since Claude uses CLAUDE.md rather than VS Code-style *.instructions.md files - Claude hiddenSections: [Agents, Hooks, Instructions] * feat: add setting to hide harness selector (on by default) - Add chat.customizations.harnessSelector.enabled setting (default: true) - When false, dropdown is hidden and harness is locked to 'Local', giving the same behavior as before the harness feature was introduced - Responds to live setting changes — toggling off immediately forces Local harness and rebuilds sections * fix: Claude shows Instructions (CLAUDE.md/AGENTS.md) but hides Prompts - Remove Instructions from Claude hiddenSections — Claude supports CLAUDE.md and AGENTS.md as instruction files - Add Prompts to Claude hiddenSections — Claude doesn't consume .prompt.md files - Claude hiddenSections: [Agents, Hooks, Prompts] * fix: Claude supports hooks — only hide Agents and Prompts * fix: filter workspace-local items by harness workspaceSubpaths When a restricted harness is active (e.g. Claude with subpaths=['.claude']), hide workspace-local items that aren't under a recognized sub-path. Previously only the creation picker was filtered — now the item list itself excludes files like .github/instructions/*.instructions.md when viewing through the Claude harness. * fix: address code review feedback - Fix subpath matching to use segment boundaries (matchesWorkspaceSubpath helper checks for // to avoid false positives like 'not.claude') - Update Claude harness JSDoc to reflect actual hook config locations (.claude/settings.json, not .claude/hooks/) - Clarify Sessions window comment for harness selector setting no-op --- .../aiCustomizationListWidget.ts | 19 ++++++- .../aiCustomizationManagementEditor.ts | 57 +++++++++++++++++-- .../customizationCreatorService.ts | 5 +- .../customizationHarnessService.ts | 11 ++-- .../contrib/chat/browser/chat.contribution.ts | 6 ++ .../contrib/chat/common/constants.ts | 1 + .../common/customizationHarnessService.ts | 20 ++++++- 7 files changed, 103 insertions(+), 16 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index c3a7eb81ba0..fb3f36dbd6e 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -51,7 +51,7 @@ import { parse as parseJSONC } from '../../../../../base/common/json.js'; import { Schemas } from '../../../../../base/common/network.js'; import { OS } from '../../../../../base/common/platform.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; -import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; +import { ICustomizationHarnessService, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; export { truncateToFirstSentence } from './aiCustomizationListWidgetUtils.js'; @@ -1112,6 +1112,23 @@ export class AICustomizationListWidget extends Disposable { items.length = 0; items.push(...filteredItems); + // Apply workspace subpath filter — when the active harness specifies + // workspaceSubpaths, hide workspace-local items that aren't under one + // of the recognized sub-paths (e.g. Claude only shows .claude/ items). + const descriptor = this.harnessService.getActiveDescriptor(); + const subpaths = descriptor.workspaceSubpaths; + if (subpaths) { + const projectRoot = this.workspaceService.getActiveProjectRoot(); + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]; + if (item.storage === PromptsStorage.local && projectRoot && isEqualOrParent(item.uri, projectRoot)) { + if (!matchesWorkspaceSubpath(item.uri.path, subpaths)) { + items.splice(i, 1); + } + } + } + } + // Sort items by name items.sort((a, b) => a.name.localeCompare(b.name)); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 0ee3bc0f260..b255c74830c 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -81,7 +81,8 @@ import { IWorkbenchMcpServer } from '../../../mcp/common/mcpTypes.js'; import { AgentPluginEditor } from '../agentPluginEditor/agentPluginEditor.js'; import { AgentPluginEditorInput } from '../agentPluginEditor/agentPluginEditorInput.js'; import { IAgentPluginItem } from '../agentPluginEditor/agentPluginItems.js'; -import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; +import { ICustomizationHarnessService, CustomizationHarness, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; +import { ChatConfiguration } from '../../common/constants.js'; const $ = DOM.$; @@ -462,15 +463,28 @@ export class AICustomizationManagementEditor extends EditorPane { })); } + /** + * Whether the harness selector UI is enabled. + * When disabled, the editor behaves as if "Local" is always selected. + */ + private get isHarnessSelectorEnabled(): boolean { + return this.configurationService.getValue(ChatConfiguration.ChatCustomizationHarnessSelectorEnabled) !== false; + } + /** * Rebuilds the visible sections list based on the active harness's * `hiddenSections`. If the current selection falls into a hidden * section, the first visible section is selected instead. */ private rebuildVisibleSections(): void { - const activeId = this.harnessService.activeHarness.get(); - const descriptor = this.harnessService.availableHarnesses.get().find(h => h.id === activeId); - const hidden = new Set(descriptor?.hiddenSections ?? []); + let hidden: Set; + if (this.isHarnessSelectorEnabled) { + const activeId = this.harnessService.activeHarness.get(); + const descriptor = this.harnessService.availableHarnesses.get().find(h => h.id === activeId); + hidden = new Set(descriptor?.hiddenSections ?? []); + } else { + hidden = new Set(); // Local harness has no hidden sections + } this.sections.length = 0; for (const s of this.allSections) { @@ -533,11 +547,27 @@ export class AICustomizationManagementEditor extends EditorPane { this.selectSection(e.elements[0].id); })); - // React to harness changes — rebuild visible sections + // React to harness changes — rebuild visible sections and refresh counts this.editorDisposables.add(autorun(reader => { this.harnessService.activeHarness.read(reader); this.rebuildVisibleSections(); this.updateHarnessDropdown(); + this.refreshAllPromptsSectionCounts(); + })); + + // When the harness selector setting is off, lock to Local harness. + // In Sessions (single CLI harness) the dropdown is already hidden and + // setActiveHarness(VSCode) is a safe no-op since the CLI harness + // remains active — filtering stays correct for that window. + if (!this.isHarnessSelectorEnabled) { + this.harnessService.setActiveHarness(CustomizationHarness.VSCode); + } + this.editorDisposables.add(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ChatConfiguration.ChatCustomizationHarnessSelectorEnabled)) { + if (!this.isHarnessSelectorEnabled) { + this.harnessService.setActiveHarness(CustomizationHarness.VSCode); + } + } })); // Folder picker (sessions window only) @@ -547,6 +577,9 @@ export class AICustomizationManagementEditor extends EditorPane { } private createHarnessDropdown(sidebarContent: HTMLElement): void { + if (!this.isHarnessSelectorEnabled) { + return; + } const harnesses = this.harnessService.availableHarnesses.get(); if (harnesses.length <= 1) { return; @@ -1053,6 +1086,8 @@ export class AICustomizationManagementEditor extends EditorPane { private async resolveTargetDirectoryWithPicker(type: PromptsType, target: 'workspace' | 'user'): Promise { const allFolders = await this.promptsService.getSourceFolders(type); const projectRoot = this.workspaceService.getActiveProjectRoot(); + const descriptor = this.harnessService.getActiveDescriptor(); + const subpaths = descriptor.workspaceSubpaths; // Partition folders by whether they're under the active project root. // The storage tags from getSourceFolders() are unreliable (tilde-expanded @@ -1061,7 +1096,17 @@ export class AICustomizationManagementEditor extends EditorPane { let matchingFolders; if (target === 'workspace') { matchingFolders = projectRoot - ? allFolders.filter(f => isEqualOrParent(f.uri, projectRoot)) + ? allFolders.filter(f => { + if (!isEqualOrParent(f.uri, projectRoot)) { + return false; + } + // When the active harness specifies workspaceSubpaths, only offer + // directories whose path includes one of those sub-paths. + if (subpaths) { + return matchesWorkspaceSubpath(f.uri.path, subpaths); + } + return true; + }) : []; } else { matchingFolders = projectRoot diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts index 1d22914d9c2..cec5c423ff0 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts @@ -15,7 +15,7 @@ import { isEqualOrParent } from '../../../../../base/common/resources.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; import { localize } from '../../../../../nls.js'; -import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; +import { ICustomizationHarnessService, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; /** * Service that opens an AI-guided chat session to help the user create @@ -131,8 +131,7 @@ export class CustomizationCreatorService { } seen.add(key); if (subpaths) { - const path = f.uri.path; - return subpaths.some(sp => path.includes(sp)); + return matchesWorkspaceSubpath(f.uri.path, subpaths); } return true; }) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts index d85c205b892..01849bd6628 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts @@ -26,12 +26,15 @@ class CustomizationHarnessService extends CustomizationHarnessServiceBase { @IPathService pathService: IPathService, ) { const userHome = pathService.userHome({ preferLocal: true }); - const extras = [PromptsStorage.extension]; + // Only the Local harness includes extension-contributed customizations. + // CLI and Claude harnesses don't consume extension contributions. + const localExtras = [PromptsStorage.extension]; + const restrictedExtras: readonly string[] = []; super( [ - createVSCodeHarnessDescriptor(extras), - createCliHarnessDescriptor(getCliUserRoots(userHome), extras), - createClaudeHarnessDescriptor(getClaudeUserRoots(userHome), extras), + createVSCodeHarnessDescriptor(localExtras), + createCliHarnessDescriptor(getCliUserRoots(userHome), restrictedExtras), + createClaudeHarnessDescriptor(getClaudeUserRoots(userHome), restrictedExtras), ], CustomizationHarness.VSCode, ); diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 5ac77f4c30e..d98fab4b022 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -1325,6 +1325,12 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.aiCustomizationMenu.enabled', "Controls whether the Chat Customizations editor is available in the Command Palette. When disabled, the Chat Customizations editor and related commands are hidden."), default: true, }, + [ChatConfiguration.ChatCustomizationHarnessSelectorEnabled]: { + type: 'boolean', + tags: ['preview'], + description: nls.localize('chat.customizations.harnessSelector.enabled', "Controls whether the harness selector (Local, Copilot CLI, Claude) is shown in the Chat Customizations editor sidebar. When disabled, the editor always shows all customizations without filtering."), + default: true, + }, } }); Registry.as(EditorExtensions.EditorPane).registerEditorPane( diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index ab58f99f3c0..a2303956550 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -54,6 +54,7 @@ export enum ChatConfiguration { ExplainChangesEnabled = 'chat.editing.explainChanges.enabled', GrowthNotificationEnabled = 'chat.growthNotification.enabled', ChatCustomizationMenuEnabled = 'chat.customizationsMenu.enabled', + ChatCustomizationHarnessSelectorEnabled = 'chat.customizations.harnessSelector.enabled', AutopilotEnabled = 'chat.autopilot.enabled', ImageCarouselEnabled = 'chat.imageCarousel.enabled', ArtifactsEnabled = 'chat.artifacts.enabled', diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index 3317bffd548..e7f89b05360 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -206,7 +206,9 @@ export function createCliHarnessDescriptor(cliUserRoots: readonly URI[], extras: /** * Creates a "Claude" harness descriptor. - * Claude does not support custom agents or hooks. + * Claude does not support custom agents or prompt files. + * It supports instructions (CLAUDE.md/AGENTS.md), skills (.claude/skills/), + * and hooks (configured in .claude/settings.json / .claude/settings.local.json). */ export function createClaudeHarnessDescriptor(claudeRoots: readonly URI[], extras: readonly string[]): IHarnessDescriptor { return createRestrictedHarnessDescriptor( @@ -215,13 +217,27 @@ export function createClaudeHarnessDescriptor(claudeRoots: readonly URI[], extra ThemeIcon.fromId(Codicon.claude.id), claudeRoots, extras, - [AICustomizationManagementSection.Agents, AICustomizationManagementSection.Hooks], + [AICustomizationManagementSection.Agents, AICustomizationManagementSection.Prompts], ['.claude'], ); } // #endregion +// #region Helpers + +/** + * Tests whether a file path belongs to one of the given workspace sub-paths. + * Matches on path segment boundaries to avoid false positives + * (e.g. `.claude` must appear as `/.claude/` in the path, not as part of + * a longer segment like `not.claude`). + */ +export function matchesWorkspaceSubpath(filePath: string, subpaths: readonly string[]): boolean { + return subpaths.some(sp => filePath.includes(`/${sp}/`) || filePath.endsWith(`/${sp}`)); +} + +// #endregion + // #region Base implementation /** From d488704d829cc1efb4919b42bac181014bf8940d Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:16:57 +0000 Subject: [PATCH 03/24] Support session pinning in VS Code workbench (#302853) Support session pinning in VS Code workbench: enable pin/unpin everywhere, always show pinned above cap Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bpasero <900690+bpasero@users.noreply.github.com> --- .../agentSessions/agentSessionsActions.ts | 4 - .../agentSessions/agentSessionsViewer.ts | 18 ++- .../agentSessionsDataSource.test.ts | 109 +++++++++++++++--- 3 files changed, 107 insertions(+), 24 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts index 2250b149a33..c7d0360d000 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts @@ -552,7 +552,6 @@ export class PinAgentSessionAction extends BaseAgentSessionAction { group: 'navigation', order: 0, when: ContextKeyExpr.and( - IsSessionsWindowContext, ChatContextKeys.isPinnedAgentSession.negate(), ChatContextKeys.isArchivedAgentSession.negate() ), @@ -561,7 +560,6 @@ export class PinAgentSessionAction extends BaseAgentSessionAction { group: '0_pin', order: 1, when: ContextKeyExpr.and( - IsSessionsWindowContext, ChatContextKeys.isPinnedAgentSession.negate(), ChatContextKeys.isArchivedAgentSession.negate() ), @@ -588,7 +586,6 @@ export class UnpinAgentSessionAction extends BaseAgentSessionAction { group: 'navigation', order: 0, when: ContextKeyExpr.and( - IsSessionsWindowContext, ChatContextKeys.isPinnedAgentSession, ChatContextKeys.isArchivedAgentSession.negate() ), @@ -597,7 +594,6 @@ export class UnpinAgentSessionAction extends BaseAgentSessionAction { group: '0_pin', order: 1, when: ContextKeyExpr.and( - IsSessionsWindowContext, ChatContextKeys.isPinnedAgentSession, ChatContextKeys.isArchivedAgentSession.negate() ), diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 3b68cdb37fe..6eed12abc64 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -852,14 +852,22 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou const firstArchivedIndex = sortedSessions.findIndex(session => session.isArchived()); const nonArchivedCount = firstArchivedIndex === -1 ? sortedSessions.length : firstArchivedIndex; + const nonArchivedSessions = sortedSessions.slice(0, nonArchivedCount); + const archivedSessions = sortedSessions.slice(nonArchivedCount); - const topSessions = sortedSessions.slice(0, Math.min(AgentSessionsDataSource.CAPPED_SESSIONS_LIMIT, nonArchivedCount)); - const othersSessions = sortedSessions.slice(topSessions.length); + // All pinned sessions are always visible + const pinnedSessions = nonArchivedSessions.filter(session => session.isPinned()); + const unpinnedSessions = nonArchivedSessions.filter(session => !session.isPinned()); - // Add top sessions directly (no section header) - result.push(...topSessions); + // Take up to N non-pinned sessions from the sorted order (preserves NeedsInput prioritization) + const topUnpinned = unpinnedSessions.slice(0, AgentSessionsDataSource.CAPPED_SESSIONS_LIMIT); + const remainingUnpinned = unpinnedSessions.slice(AgentSessionsDataSource.CAPPED_SESSIONS_LIMIT); - // Add "More" section for the rest + // Add pinned first, then top N non-pinned + result.push(...pinnedSessions, ...topUnpinned); + + // Add "More" section for the rest (remaining unpinned + archived) + const othersSessions = [...remainingUnpinned, ...archivedSessions]; if (othersSessions.length > 0) { result.push({ section: AgentSessionSection.More, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts index 44fca63d1d5..f5403958562 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts @@ -536,16 +536,18 @@ suite('AgentSessionsDataSource', () => { assert.strictEqual(archivedSection.sessions[0].label, 'Session archived-pinned'); }); - test('pinned sessions are not capped into More section with capped grouping', () => { + test('pinned sessions are always shown above the cap with capped grouping', () => { const now = Date.now(); const sessions = [ - // Two pinned sessions — sorted to top by time so they appear in the flat portion - createMockSession({ id: 'pinned1', isPinned: true, startTime: now }), - createMockSession({ id: 'pinned2', isPinned: true, startTime: now - ONE_DAY }), - // Additional unpinned sessions to exceed the cap and populate the More section + // Recent unpinned sessions fill the top 3 by time createMockSession({ id: 's1', startTime: now }), createMockSession({ id: 's2', startTime: now - ONE_DAY }), createMockSession({ id: 's3', startTime: now - 2 * ONE_DAY }), + // Unpinned overflow + createMockSession({ id: 's4', startTime: now - 3 * ONE_DAY }), + // Two pinned sessions with old timestamps — would fall outside top 3 by time alone + createMockSession({ id: 'pinned1', isPinned: true, startTime: now - 4 * ONE_DAY }), + createMockSession({ id: 'pinned2', isPinned: true, startTime: now - 5 * ONE_DAY }), ]; const filter = createMockFilter({ @@ -558,20 +560,97 @@ suite('AgentSessionsDataSource', () => { const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); const sections = getSectionsFromResult(result); + const topSessions = result.filter((r): r is IAgentSession => !isAgentSessionSection(r)); - // Capped grouping does not create a Pinned section — all sessions are - // sorted by time and the top N appear as flat items, the rest in More. - assert.strictEqual(sections.filter(s => s.section === AgentSessionSection.Pinned).length, 0); + // Pinned sessions first, then up to 3 non-pinned sessions + assert.deepStrictEqual(topSessions.map(s => s.label), [ + 'Session pinned1', + 'Session pinned2', + 'Session s1', + 'Session s2', + 'Session s3', + ]); + // Only unpinned overflow goes to More const moreSection = sections.find(s => s.section === AgentSessionSection.More); assert.ok(moreSection); - // Pinned sessions have recent timestamps so they land in the flat top portion, - // not in the More section - const moreLabels = moreSection.sessions.map(s => s.label); - for (const label of moreLabels) { - assert.notStrictEqual(label, 'Session pinned1'); - assert.notStrictEqual(label, 'Session pinned2'); - } + assert.deepStrictEqual(moreSection.sessions.map(s => s.label), [ + 'Session s4', + ]); + }); + + test('more pinned sessions than cap limit are all shown', () => { + const now = Date.now(); + const sessions = [ + createMockSession({ id: 'pinned1', isPinned: true, startTime: now }), + createMockSession({ id: 'pinned2', isPinned: true, startTime: now - ONE_DAY }), + createMockSession({ id: 'pinned3', isPinned: true, startTime: now - 2 * ONE_DAY }), + createMockSession({ id: 'pinned4', isPinned: true, startTime: now - 3 * ONE_DAY }), + // Unpinned session — still fits within the cap of 3 non-pinned + createMockSession({ id: 'unpinned1', startTime: now - 4 * ONE_DAY }), + ]; + + const filter = createMockFilter({ + groupBy: AgentSessionsGrouping.Capped, + excludeRead: false + }); + const sorter = createMockSorter(); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); + + const mockModel = createMockModel(sessions); + const result = Array.from(dataSource.getChildren(mockModel)); + const sections = getSectionsFromResult(result); + const topSessions = result.filter((r): r is IAgentSession => !isAgentSessionSection(r)); + + // All 4 pinned + 1 unpinned (fits within cap of 3 non-pinned) + assert.deepStrictEqual(topSessions.map(s => s.label), [ + 'Session pinned1', + 'Session pinned2', + 'Session pinned3', + 'Session pinned4', + 'Session unpinned1', + ]); + + // No More section needed since unpinned count (1) is within cap (3) + const moreSection = sections.find(s => s.section === AgentSessionSection.More); + assert.strictEqual(moreSection, undefined); + }); + + test('unpinned NeedsInput session appears in the non-pinned section below pinned', () => { + const now = Date.now(); + const sessions = [ + createMockSession({ id: 'needs-input', status: ChatSessionStatus.NeedsInput, startTime: now }), + createMockSession({ id: 'pinned1', isPinned: true, startTime: now }), + createMockSession({ id: 'pinned2', isPinned: true, startTime: now - ONE_DAY }), + createMockSession({ id: 'pinned3', isPinned: true, startTime: now - 2 * ONE_DAY }), + createMockSession({ id: 's1', startTime: now }), + ]; + + const filter = createMockFilter({ + groupBy: AgentSessionsGrouping.Capped, + excludeRead: false + }); + // Use real sorter to exercise NeedsInput prioritization in capped mode + const sorter = new AgentSessionsSorter(); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); + + const mockModel = createMockModel(sessions); + const result = Array.from(dataSource.getChildren(mockModel)); + const sections = getSectionsFromResult(result); + const topSessions = result.filter((r): r is IAgentSession => !isAgentSessionSection(r)); + + // Pinned sessions come first, then up to 3 non-pinned (NeedsInput + s1 both fit in cap) + assert.deepStrictEqual(topSessions.map(s => s.label), [ + 'Session pinned1', + 'Session pinned2', + 'Session pinned3', + 'Session needs-input', + 'Session s1', + ]); + + // All non-pinned fit within cap of 3, so no More section + const moreSection = sections.find(s => s.section === AgentSessionSection.More); + assert.strictEqual(moreSection, undefined); }); }); From dec2c92c4751e226d2189979893bdcc508b075b0 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 18 Mar 2026 14:27:49 -0400 Subject: [PATCH 04/24] Fix run_in_terminal timeout signaling and avoid foreground reuse after timeout (#302892) --- .../browser/tools/runInTerminalTool.ts | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) 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 e8fa387a450..0c4358f1c09 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -244,7 +244,7 @@ export async function createRunInTerminalToolData( }, timeout: { type: 'number', - description: 'An optional timeout in milliseconds. When provided, the tool will stop tracking the command after this duration and return the output collected so far. Be conservative with the timeout duration, give enough time that the command would complete on a low-end machine. Use 0 for no timeout. If it\'s not clear how long the command will take then use 0 to avoid prematurely terminating it, never guess too low.', + description: 'An optional timeout in milliseconds. When provided, the tool will stop tracking the command after this duration and return the output collected so far with a timeout indicator. Be conservative with the timeout duration, give enough time that the command would complete on a low-end machine. Use 0 for no timeout. If it\'s not clear how long the command will take then use 0 to avoid prematurely terminating it, never guess too low.', }, }, required: [ @@ -826,6 +826,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { let didTimeout = false; let didMoveToBackground = args.isBackground; let timeoutPromise: CancelablePromise | undefined; + let timeoutRacePromise: Promise<{ type: 'timeout' }> | undefined; let outputMonitor: OutputMonitor | undefined; let pollingResult: IPollingResult & { pollDurationMs: number } | undefined; const executeCancellation = store.add(new CancellationTokenSource(token)); @@ -836,12 +837,9 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const shouldEnforceTimeout = this._configurationService.getValue(TerminalChatAgentToolsSettingId.EnforceTimeoutFromModel) === true; if (shouldEnforceTimeout) { timeoutPromise = timeout(timeoutValue); - timeoutPromise.then(() => { - if (!executeCancellation.token.isCancellationRequested) { - didTimeout = true; - executeCancellation.cancel(); - } - }); + timeoutRacePromise = timeoutPromise.then( + () => ({ type: 'timeout' as const }) + ).catch(() => ({ type: 'timeout' as const })); } } @@ -951,10 +949,14 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { }; } else { // Foreground mode: race execution completion against continue in background - const raceResult = await Promise.race([ + const raceCandidates: Promise<{ type: 'completed'; result: ITerminalExecuteStrategyResult } | { type: 'background' } | { type: 'timeout' }>[] = [ executionPromise.then(result => ({ type: 'completed' as const, result })), continueInBackgroundPromise.then(() => ({ type: 'background' as const })) - ]); + ]; + if (timeoutRacePromise) { + raceCandidates.push(timeoutRacePromise); + } + const raceResult = await Promise.race(raceCandidates); if (raceResult.type === 'background') { // Moved to background - execution continues running, just return current output @@ -963,6 +965,18 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const backgroundOutput = execution.getOutput(); outputLineCount = backgroundOutput ? count(backgroundOutput.trim(), '\n') + 1 : 0; terminalResult = backgroundOutput; + } else if (raceResult.type === 'timeout') { + // Timeout reached - return partial output and keep terminal alive as background. + this._logService.debug(`RunInTerminalTool: Timeout reached, returning output collected so far`); + error = 'timeout'; + didTimeout = true; + didMoveToBackground = true; + toolTerminal.isBackground = true; + this._sessionTerminalAssociations.delete(chatSessionResource); + await this._associateProcessIdWithSession(toolTerminal.instance, chatSessionResource, termId, toolTerminal.shellIntegrationQuality, true); + const timeoutOutput = execution.getOutput(); + outputLineCount = timeoutOutput ? count(timeoutOutput.trim(), '\n') + 1 : 0; + terminalResult = timeoutOutput ?? ''; } else { const executeResult = raceResult.result; // Reset user input state after command execution completes @@ -1026,6 +1040,9 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { if (didTimeout && e instanceof CancellationError) { this._logService.debug(`RunInTerminalTool: Timeout reached, returning output collected so far`); error = 'timeout'; + didMoveToBackground = true; + toolTerminal.isBackground = true; + this._sessionTerminalAssociations.delete(chatSessionResource); const timeoutOutput = getOutput(toolTerminal.instance, undefined); outputLineCount = timeoutOutput ? count(timeoutOutput.trim(), '\n') + 1 : 0; terminalResult = timeoutOutput ?? ''; @@ -1107,6 +1124,9 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { if (didMoveToBackground && !args.isBackground) { resultText.push(`Note: This terminal execution was moved to the background using the ID ${termId}\n`); } + if (didTimeout && timeoutValue !== undefined && timeoutValue > 0) { + resultText.push(`Note: Command timed out after ${timeoutValue}ms. Output collected so far is shown below and the command may still be running in terminal ID ${termId}.\n\n`); + } let outputAnalyzerMessage: string | undefined; for (const analyzer of this._outputAnalyzers) { const message = await analyzer.analyze({ exitCode, exitResult: terminalResult, commandLine: command }); @@ -1131,6 +1151,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { exitCode: exitCode, id: termId, cwd: endCwd?.toString(), + timedOut: didTimeout || undefined, + timeoutMs: didTimeout ? timeoutValue : undefined, }, toolResultDetails: isError ? { input: command, From 2c045d1130cce43fadbaec4541962b75deea151d Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 18 Mar 2026 14:28:44 -0400 Subject: [PATCH 05/24] make questions collapsible (#302870) --- .../chatQuestionCarouselPart.ts | 58 +++++++++++++- .../media/chatQuestionCarousel.css | 36 +++++++++ .../chatQuestionCarouselData.ts | 1 + .../chatQuestionCarouselPart.test.ts | 76 +++++++++++++++++++ 4 files changed, 168 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts index 704052557be..2105d647822 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -11,6 +11,7 @@ import { IMarkdownString, MarkdownString, isMarkdownString } from '../../../../. import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { isMacintosh } from '../../../../../../base/common/platform.js'; +import { generateUuid } from '../../../../../../base/common/uuid.js'; import { hasKey } from '../../../../../../base/common/types.js'; import { localize } from '../../../../../../nls.js'; import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js'; @@ -49,13 +50,16 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent private _currentIndex = 0; private readonly _answers = new Map(); + private _isCollapsed = false; private _questionContainer: HTMLElement | undefined; + private _headerActionsContainer: HTMLElement | undefined; private _closeButtonContainer: HTMLElement | undefined; private _footerRow: HTMLElement | undefined; private _stepIndicator: HTMLElement | undefined; private _submitHint: HTMLElement | undefined; private _submitButton: Button | undefined; + private _collapseButton: Button | undefined; private _prevButton: Button | undefined; private _nextButton: Button | undefined; private _skipAllButton: Button | undefined; @@ -92,6 +96,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent super(); this.domNode = dom.$('.chat-question-carousel-container'); + this.domNode.id = generateUuid(); this._inChatQuestionCarouselContextKey = ChatContextKeys.inChatQuestionCarousel.bindTo(this._contextKeyService); const focusTracker = this._register(dom.trackFocus(this.domNode)); this._register(focusTracker.onDidFocus(() => this._inChatQuestionCarouselContextKey.set(true))); @@ -110,6 +115,10 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._currentIndex = Math.max(0, Math.min(carousel.draftCurrentIndex, carousel.questions.length - 1)); } + if (typeof carousel.draftCollapsed === 'boolean') { + this._isCollapsed = carousel.draftCollapsed; + } + if (carousel.draftAnswers) { for (const [key, value] of Object.entries(carousel.draftAnswers)) { this._answers.set(key, value); @@ -141,6 +150,13 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // Question container this._questionContainer = dom.$('.chat-question-carousel-content'); this.domNode.append(this._questionContainer); + this._headerActionsContainer = dom.$('.chat-question-header-actions'); + + const collapseToggleTitle = localize('chat.questionCarousel.collapseTitle', 'Collapse Questions'); + const collapseButton = interactiveStore.add(new Button(this._headerActionsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); + collapseButton.element.classList.add('chat-question-collapse-toggle'); + collapseButton.element.setAttribute('aria-label', collapseToggleTitle); + this._collapseButton = collapseButton; // Close/skip button (X) - placed in header row, only shown when allowSkip is true if (carousel.allowSkip) { @@ -155,6 +171,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } // Register event listeners + interactiveStore.add(collapseButton.onDidClick(() => this.toggleCollapsed())); + if (this._skipAllButton) { interactiveStore.add(this._skipAllButton.onDidClick(() => this.ignore())); } @@ -224,6 +242,31 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this.carousel.draftAnswers = Object.fromEntries(this._answers.entries()); this.carousel.draftCurrentIndex = this._currentIndex; + this.carousel.draftCollapsed = this._isCollapsed; + } + + private toggleCollapsed(): void { + this._isCollapsed = !this._isCollapsed; + this.persistDraftState(); + this.updateCollapsedPresentation(); + this._onDidChangeHeight.fire(); + } + + private updateCollapsedPresentation(): void { + this.domNode.classList.toggle('chat-question-carousel-collapsed', this._isCollapsed); + + if (this._collapseButton) { + const collapsed = this._isCollapsed; + const buttonTitle = collapsed + ? localize('chat.questionCarousel.expandTitle', 'Expand Questions') + : localize('chat.questionCarousel.collapseTitle', 'Collapse Questions'); + const contentId = this.domNode.id; + this._collapseButton.label = collapsed ? `$(${Codicon.chevronUp.id})` : `$(${Codicon.chevronDown.id})`; + this._collapseButton.element.setAttribute('aria-label', buttonTitle); + this._collapseButton.element.setAttribute('aria-expanded', String(!collapsed)); + this._collapseButton.element.setAttribute('aria-controls', contentId); + this._collapseButton.setTitle(buttonTitle); + } } /** @@ -335,7 +378,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._submitButton = undefined; this._skipAllButton = undefined; this._questionContainer = undefined; + this._headerActionsContainer = undefined; this._closeButtonContainer = undefined; + this._collapseButton = undefined; this._footerRow = undefined; this._stepIndicator = undefined; this._submitHint = undefined; @@ -609,9 +654,15 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent headerRow.appendChild(titleRow); - // Always keep the close button in the title row so it does not overlap content. - if (this._closeButtonContainer) { - titleRow.appendChild(this._closeButtonContainer); + if (this._headerActionsContainer) { + dom.clearNode(this._headerActionsContainer); + if (this._closeButtonContainer) { + this._headerActionsContainer.appendChild(this._closeButtonContainer); + } + if (this._collapseButton) { + this._headerActionsContainer.appendChild(this._collapseButton.element); + } + titleRow.appendChild(this._headerActionsContainer); } this._questionContainer.appendChild(headerRow); @@ -680,6 +731,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // Update aria-label to reflect the current question this._updateAriaLabel(); + this.updateCollapsedPresentation(); // In screen reader mode, focus the container and announce the question // This must happen after all render calls to avoid focus being stolen diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 45e5b303c2e..3ae3f7dccea 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -99,6 +99,13 @@ } } + .chat-question-header-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + } + .chat-question-close-container { flex-shrink: 0; @@ -117,6 +124,35 @@ background: var(--vscode-toolbar-hoverBackground) !important; } } + + .monaco-button.chat-question-collapse-toggle { + min-width: 22px; + width: 22px; + height: 22px; + padding: 0; + border: none !important; + box-shadow: none !important; + background: transparent !important; + color: var(--vscode-icon-foreground) !important; + } + + .monaco-button.chat-question-collapse-toggle:hover:not(.disabled) { + background: var(--vscode-toolbar-hoverBackground) !important; + } + } +} + +.interactive-session .chat-question-carousel-container.chat-question-carousel-collapsed { + .chat-question-carousel-content { + .chat-question-description, + .chat-question-input-scrollable, + .chat-question-validation-message { + display: none; + } + } + + .chat-question-footer-row { + display: none; } } diff --git a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts index 5feb507b755..7cc5a5425ff 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts @@ -18,6 +18,7 @@ export class ChatQuestionCarouselData implements IChatQuestionCarousel { public readonly completion = new DeferredPromise<{ answers: IChatQuestionAnswers | undefined }>(); public draftAnswers: IChatQuestionAnswers | undefined; public draftCurrentIndex: number | undefined; + public draftCollapsed: boolean | undefined; constructor( public questions: IChatQuestion[], diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts index 7bc6a6c0412..6362d8764f2 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts @@ -149,6 +149,82 @@ suite('ChatQuestionCarouselPart', () => { const directChildCloseContainer = widget.domNode.querySelector(':scope > .chat-question-close-container'); assert.strictEqual(directChildCloseContainer, null, 'close button container should not be positioned as a direct child of the carousel container'); }); + + test('renders collapse button in title row even when skip is disabled', () => { + const carousel = createMockCarousel([ + { id: 'q1', type: 'text', title: 'Question 1' } + ], false); + createWidget(carousel); + + const titleRow = widget.domNode.querySelector('.chat-question-title-row'); + assert.ok(titleRow, 'title row should exist'); + + const collapseButton = titleRow?.querySelector('.chat-question-collapse-toggle'); + assert.ok(collapseButton, 'collapse button should be rendered even when skip is disabled'); + }); + + test('renders collapse button to the right of close button', () => { + const carousel = createMockCarousel([ + { id: 'q1', type: 'text', title: 'Question 1' }, + { id: 'q2', type: 'text', title: 'Question 2' } + ], true); + createWidget(carousel); + + const actionsContainer = widget.domNode.querySelector('.chat-question-header-actions'); + assert.ok(actionsContainer, 'actions container should exist'); + if (!actionsContainer) { + return; + } + + const actionButtons = Array.from(actionsContainer.querySelectorAll('.monaco-button')); + const closeIndex = actionButtons.findIndex(button => button.classList.contains('chat-question-close')); + const collapseIndex = actionButtons.findIndex(button => button.classList.contains('chat-question-collapse-toggle')); + + assert.ok(closeIndex >= 0, 'close button should exist'); + assert.ok(collapseIndex >= 0, 'collapse button should exist'); + assert.ok(collapseIndex > closeIndex, 'collapse button should be positioned to the right of close button'); + }); + + test('toggles collapsed state and updates aria-expanded', () => { + const carousel = createMockCarousel([ + { id: 'q1', type: 'text', title: 'Question 1' }, + { id: 'q2', type: 'text', title: 'Question 2' } + ], true); + createWidget(carousel); + + const collapseButton = widget.domNode.querySelector('.chat-question-collapse-toggle') as HTMLElement; + assert.ok(collapseButton, 'collapse button should exist'); + assert.strictEqual(collapseButton.getAttribute('aria-expanded'), 'true'); + + collapseButton.click(); + assert.ok(widget.domNode.classList.contains('chat-question-carousel-collapsed'), 'widget should enter collapsed state'); + assert.strictEqual(collapseButton.getAttribute('aria-expanded'), 'false'); + const collapsedSummary = widget.domNode.querySelector('.chat-question-collapsed-summary'); + assert.strictEqual(collapsedSummary, null, 'collapsed mode should not render an additional summary section'); + + const titleRow = widget.domNode.querySelector('.chat-question-title-row'); + assert.ok(titleRow, 'header should remain visible when collapsed'); + + const inputScrollable = widget.domNode.querySelector('.chat-question-input-scrollable'); + assert.ok(inputScrollable, 'input section exists in DOM but is hidden while collapsed'); + + collapseButton.click(); + assert.ok(!widget.domNode.classList.contains('chat-question-carousel-collapsed'), 'widget should exit collapsed state'); + assert.strictEqual(collapseButton.getAttribute('aria-expanded'), 'true'); + }); + + test('restores draft collapsed state from carousel data', () => { + const carousel = new ChatQuestionCarouselData([ + { id: 'q1', type: 'text', title: 'Question 1' }, + { id: 'q2', type: 'text', title: 'Question 2' } + ], true); + carousel.draftCollapsed = true; + createWidget(carousel); + + assert.ok(widget.domNode.classList.contains('chat-question-carousel-collapsed'), 'widget should restore collapsed draft state'); + const collapseButton = widget.domNode.querySelector('.chat-question-collapse-toggle'); + assert.strictEqual(collapseButton?.getAttribute('aria-expanded'), 'false'); + }); }); suite('Question Types', () => { From a185beaf10f4b3cc0ba04648a490484195fd3608 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 18 Mar 2026 11:33:10 -0700 Subject: [PATCH 06/24] Fix duplicate workbench contrib warning and noisy agenthost errors (#302911) * Fix duplicate workbench contrib warning and noisy agenthost errors * Trim --- .../electron-main/electronAgentHostStarter.ts | 18 +++++++- .../agentHost.contribution.ts | 45 ------------------- src/vs/workbench/workbench.desktop.main.ts | 1 - 3 files changed, 17 insertions(+), 47 deletions(-) delete mode 100644 src/vs/workbench/contrib/chat/electron-browser/agentHost.contribution.ts diff --git a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts index 0af8235aeba..5abe4cd03aa 100644 --- a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts +++ b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts @@ -72,7 +72,12 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt const store = new DisposableStore(); store.add(client); - store.add(this.utilityProcess.onStderr(data => this._logService.error(`[AgentHost:stderr] ${data}`))); + store.add(this.utilityProcess.onStderr(data => { + if (this._isExpectedStderr(data)) { + return; + } + this._logService.error(`[AgentHost:stderr] ${data}`); + })); store.add(toDisposable(() => { this.utilityProcess?.kill(); this.utilityProcess?.dispose(); @@ -103,4 +108,15 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt e.sender.postMessage('vscode:createAgentHostMessageChannelResult', nonce, [port]); } + + private static readonly _expectedStderrPatterns = [ + 'Most NODE_OPTIONs are not supported in packaged apps', + 'Debugger listening on ws://', + 'For help, see: https://nodejs.org/en/docs/inspector', + 'ExperimentalWarning: SQLite is an experimental feature', + ]; + + private _isExpectedStderr(data: string): boolean { + return ElectronAgentHostStarter._expectedStderrPatterns.some(pattern => data.includes(pattern)); + } } diff --git a/src/vs/workbench/contrib/chat/electron-browser/agentHost.contribution.ts b/src/vs/workbench/contrib/chat/electron-browser/agentHost.contribution.ts deleted file mode 100644 index a877a5b975c..00000000000 --- a/src/vs/workbench/contrib/chat/electron-browser/agentHost.contribution.ts +++ /dev/null @@ -1,45 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { generateUuid } from '../../../../base/common/uuid.js'; -import { URI } from '../../../../base/common/uri.js'; -import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; -import { WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; -import { IViewsService } from '../../../services/views/common/viewsService.js'; -import { IEditorService } from '../../../services/editor/common/editorService.js'; -import { ChatViewId } from '../browser/chat.js'; -import { ChatEditorInput } from '../browser/widgetHosts/editor/chatEditorInput.js'; -import { ChatViewPane } from '../browser/widgetHosts/viewPane/chatViewPane.js'; -import { AgentSessionProviders } from '../browser/agentSessions/agentSessions.js'; -import { AgentHostContribution } from '../browser/agentSessions/agentHost/agentHostChatContribution.js'; - -registerWorkbenchContribution2(AgentHostContribution.ID, AgentHostContribution, WorkbenchPhase.AfterRestored); - -// Register command for opening a new Agent Host session from the session type picker -CommandsRegistry.registerCommand( - `workbench.action.chat.openNewChatSessionInPlace.${AgentSessionProviders.AgentHostCopilot}`, - async (accessor, chatSessionPosition: string) => { - const viewsService = accessor.get(IViewsService); - const resource = URI.from({ - scheme: AgentSessionProviders.AgentHostCopilot, - path: `/untitled-${generateUuid()}`, - }); - - if (chatSessionPosition === 'editor') { - const editorService = accessor.get(IEditorService); - await editorService.openEditor({ - resource, - options: { - override: ChatEditorInput.EditorID, - pinned: true, - }, - }); - } else { - const view = await viewsService.openView(ChatViewId) as ChatViewPane; - await view.loadSession(resource); - view.focus(); - } - } -); diff --git a/src/vs/workbench/workbench.desktop.main.ts b/src/vs/workbench/workbench.desktop.main.ts index 36414b341ff..6c887e147dd 100644 --- a/src/vs/workbench/workbench.desktop.main.ts +++ b/src/vs/workbench/workbench.desktop.main.ts @@ -179,7 +179,6 @@ import './contrib/remoteTunnel/electron-browser/remoteTunnel.contribution.js'; // Chat import './contrib/chat/electron-browser/chat.contribution.js'; -import './contrib/chat/electron-browser/agentHost.contribution.js'; // Encryption import './contrib/encryption/electron-browser/encryption.contribution.js'; From 9de7db1a5c6a1196504dc85ace3a219c4e1c537c Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Wed, 18 Mar 2026 11:42:27 -0700 Subject: [PATCH 07/24] Sanity tests improvements (#302917) --- build/azure-pipelines/common/sanity-tests.yml | 23 ++++++++++++-- test/sanity/src/context.ts | 21 +++++++++---- test/sanity/src/uiTest.ts | 31 +++++++++++++------ 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/build/azure-pipelines/common/sanity-tests.yml b/build/azure-pipelines/common/sanity-tests.yml index ce6d95dd7e5..fdf6b2cd3dd 100644 --- a/build/azure-pipelines/common/sanity-tests.yml +++ b/build/azure-pipelines/common/sanity-tests.yml @@ -33,9 +33,9 @@ jobs: outputs: - output: pipelineArtifact targetPath: $(SCREENSHOTS_DIR) - artifactName: screenshots-${{ parameters.name }} + artifactName: screenshots-${{ parameters.name }}-$(System.JobAttempt) displayName: Publish Screenshots - condition: succeededOrFailed() + condition: and(succeededOrFailed(), eq(variables.HAS_SCREENSHOTS, 'true')) continueOnError: true sbomEnabled: false variables: @@ -171,6 +171,25 @@ jobs: condition: and(succeeded(), ne(variables.DOCKER_CACHE_HIT, 'true')) displayName: Save Docker Image + - ${{ if eq(parameters.os, 'windows') }}: + - script: | + @echo off + dir /b "$(SCREENSHOTS_DIR)" 2>nul | findstr . >nul + if %errorlevel%==0 ( + echo ##vso[task.setvariable variable=HAS_SCREENSHOTS]true + ) + exit /b 0 + displayName: Check Screenshots + condition: succeededOrFailed() + + - ${{ else }}: + - bash: | + if [ -n "$(ls -A "$(SCREENSHOTS_DIR)" 2>/dev/null)" ]; then + echo "##vso[task.setvariable variable=HAS_SCREENSHOTS]true" + fi + displayName: Check Screenshots + condition: succeededOrFailed() + - task: PublishTestResults@2 inputs: testResultsFormat: JUnit diff --git a/test/sanity/src/context.ts b/test/sanity/src/context.ts index 62eee018d06..632dae2a534 100644 --- a/test/sanity/src/context.ts +++ b/test/sanity/src/context.ts @@ -141,10 +141,19 @@ export class TestContext { public error(message: string): never { const line = `[${new Date().toISOString()}] ERROR: ${message}`; this.consoleOutputs.push(line); - console.error(line); + console.error(`##vso[task.logissue type=error]${line}`); throw new Error(message); } + /** + * Logs a warning message with a timestamp. + */ + public warn(message: string) { + const line = `[${new Date().toISOString()}] WARNING: ${message}`; + this.consoleOutputs.push(line); + console.warn(`##vso[task.logissue type=warning]${line}`); + } + /** * Creates a new temporary directory and returns its path. */ @@ -220,7 +229,7 @@ export class TestContext { fs.rmSync(dir, { recursive: true, force: true }); this.log(`Deleted temp directory: ${dir}`); } catch (error) { - this.log(`Failed to delete temp directory: ${dir}: ${error}`); + this.warn(`Failed to delete temp directory: ${dir}: ${error}`); } } this.tempDirs.clear(); @@ -229,7 +238,7 @@ export class TestContext { try { this.deleteWslDir(dir); } catch (error) { - this.log(`Failed to delete WSL temp directory: ${dir}: ${error}`); + this.warn(`Failed to delete WSL temp directory: ${dir}: ${error}`); } } this.wslTempDirs.clear(); @@ -247,7 +256,7 @@ export class TestContext { for (let attempt = 0; attempt < maxRetries; attempt++) { if (attempt > 0) { const delay = Math.pow(2, attempt - 1) * 1000; - this.log(`Retrying fetch (attempt ${attempt + 1}/${maxRetries}) after ${delay}ms`); + this.warn(`Retrying fetch (attempt ${attempt + 1}/${maxRetries}) after ${delay}ms`); await new Promise(resolve => setTimeout(resolve, delay)); } @@ -266,7 +275,7 @@ export class TestContext { return response as Response & { body: NodeJS.ReadableStream }; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); - this.log(`Fetch attempt ${attempt + 1} failed: ${lastError.message}`); + this.warn(`Fetch attempt ${attempt + 1} failed: ${lastError.message}`); } } @@ -1091,7 +1100,7 @@ export class TestContext { await page.screenshot({ path: screenshotPath, fullPage: true }); this.log(`Screenshot saved to: ${screenshotPath}`); } catch (e) { - this.log(`Failed to capture screenshot: ${e instanceof Error ? e.message : String(e)}`); + this.warn(`Failed to capture screenshot: ${e instanceof Error ? e.message : String(e)}`); } } diff --git a/test/sanity/src/uiTest.ts b/test/sanity/src/uiTest.ts index 65f8b14a49e..96733481e96 100644 --- a/test/sanity/src/uiTest.ts +++ b/test/sanity/src/uiTest.ts @@ -138,7 +138,7 @@ export class UITest { const extensionItem = page.locator('.extension-list-item').getByText(/^GitHub Pull Requests$/); const messageContainer = page.locator('.extensions-viewlet .message-container:not(.hidden)').first(); - for (let attempt = 0; attempt < 3; attempt++) { + for (let attempt = 0; attempt < 5; attempt++) { const result = await Promise.race([ extensionItem.waitFor().then(() => 'found' as const), messageContainer.waitFor().then(() => 'message' as const), @@ -149,20 +149,33 @@ export class UITest { } const message = await messageContainer.locator('.message').innerText(); - this.context.log(`Marketplace message: ${message} (attempt ${attempt + 1}/3), clicking Refresh`); + this.context.log(`Marketplace message: ${message} (attempt ${attempt + 1}/5), clicking Refresh`); await page.getByRole('button', { name: 'Refresh' }).click(); - await messageContainer.waitFor({ state: 'hidden', timeout: 30_000 }); + await page.waitForTimeout(5_000); } await extensionItem.waitFor(); - this.context.log('Clicking Install on the first extension in the list'); - const installButton = page.locator('.extension-action:not(.disabled)', { hasText: /Install/ }).first(); - await installButton.waitFor(); - await installButton.click(); + for (let attempt = 0; attempt < 3; attempt++) { + try { + this.context.log(`Clicking Install on the first extension in the list (attempt ${attempt + 1}/3)`); + const installButton = page.locator('.extension-action:not(.disabled)', { hasText: /Install/ }).first(); + await installButton.click(); - this.context.log('Waiting for extension to be installed'); - await page.getByRole('button', { name: 'Uninstall' }).first().waitFor({ timeout: 5 * 60_000 }); + this.context.log('Waiting for extension to be installed'); + const uninstallButton = page.getByRole('button', { name: 'Uninstall' }).first(); + const installed = await uninstallButton.waitFor({ timeout: 5 * 60_000 }).then(() => true, () => false); + if (installed) { + return; + } + } catch (error) { + this.context.log(`Extension install attempt ${attempt + 1}/3 failed: ${error instanceof Error ? error.message : String(error)}`); + } + + this.context.log('Extension install may have failed, retrying'); + } + + throw new Error('Failed to install extension after 3 attempts'); } /** From 18cfaef76ddf6cb35c8ab5da6d5eee447aa503d7 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:46:34 -0700 Subject: [PATCH 08/24] Agent Debug: Enable Claude Code session url and filter untitled sessions (#302903) * Claude and filter * feedback updates --- .../browser/chatDebug/chatDebugHomeView.ts | 15 ++++++-- .../chat/common/chatDebugServiceImpl.ts | 1 + .../test/common/chatDebugServiceImpl.test.ts | 34 +++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts index 8874ddebeaa..1dc8b8b231e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts @@ -17,7 +17,7 @@ import { defaultButtonStyles } from '../../../../../platform/theme/browser/defau import { IChatDebugService } from '../../common/chatDebugService.js'; import { IChatService } from '../../common/chatService/chatService.js'; import { AGENT_DEBUG_LOG_ENABLED_SETTING } from '../../common/promptSyntax/promptTypes.js'; -import { LocalChatSessionUri } from '../../common/model/chatUri.js'; +import { getChatSessionType, isUntitledChatSession, LocalChatSessionUri } from '../../common/model/chatUri.js'; import { IChatWidgetService } from '../chat.js'; import { IPreferencesService } from '../../../../services/preferences/common/preferences.js'; @@ -88,7 +88,12 @@ export class ChatDebugHomeView extends Disposable { // List sessions that have debug event data. // Use the debug service as the source of truth — it includes sessions // whose chat models may have been archived (e.g. when a new chat was started). - const sessionResources = [...this.chatDebugService.getSessionResources()].reverse(); + const cliSessionTypes = new Set(['copilotcli', 'claude-code']); + const sessionResources = [...this.chatDebugService.getSessionResources()].reverse() + // Hide untitled bootstrap sessions for CLI session types (e.g. copilotcli, claude-code). + // These are transient sessions created during async session setup that only contain + // a single "Load Hooks" event and would confuse users. + .filter(r => !cliSessionTypes.has(getChatSessionType(r)) || !isUntitledChatSession(r)); // Sort: active session first if (activeSessionResource) { @@ -122,10 +127,14 @@ export class ChatDebugHomeView extends Disposable { sessionTitle = localize('chatDebug.newSession', "New Chat"); } else if (importedTitle) { sessionTitle = localize('chatDebug.importedSession', "Imported: {0}", importedTitle); - } else if (sessionResource.scheme === 'copilotcli') { + } else if (getChatSessionType(sessionResource) === 'copilotcli') { const pathId = sessionResource.path.replace(/^\//, '').split('-')[0]; const shortId = pathId || sessionResource.authority || sessionResource.toString(); sessionTitle = localize('chatDebug.copilotCliSessionWithId', "Copilot CLI: {0}", shortId); + } else if (getChatSessionType(sessionResource) === 'claude-code') { + const pathId = sessionResource.path.replace(/^\//, '').split('-')[0]; + const shortId = pathId || sessionResource.authority || sessionResource.toString(); + sessionTitle = localize('chatDebug.claudeCodeSessionWithId', "Claude Code: {0}", shortId); } else { sessionTitle = localize('chatDebug.newSession', "New Chat"); } diff --git a/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts index 00ff71eda6a..60d45fe1f8d 100644 --- a/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts @@ -121,6 +121,7 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic private static readonly _debugEligibleSchemes = new Set([ LocalChatSessionUri.scheme, // vscode-chat-session (local sessions) 'copilotcli', // Copilot CLI background sessions + 'claude-code', // Claude Code CLI sessions ]); private _isDebugEligibleSession(sessionResource: URI): boolean { diff --git a/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts b/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts index 386c7a916cf..62e41c7fcb8 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts @@ -23,6 +23,7 @@ suite('ChatDebugServiceImpl', () => { const sessionGeneric = URI.parse('vscode-chat-session://local/session'); const nonLocalSession = URI.parse('some-other-scheme://authority/session-1'); const copilotCliSession = URI.parse('copilotcli:/test-session-id'); + const claudeCodeSession = URI.parse('claude-code:/test-session-id'); setup(() => { service = disposables.add(new ChatDebugServiceImpl()); @@ -168,6 +169,16 @@ suite('ChatDebugServiceImpl', () => { assert.strictEqual(firedEvents.length, 1); assert.strictEqual(service.getEvents(copilotCliSession).length, 1); }); + + test('should log events for claude-code sessions', () => { + const firedEvents: IChatDebugEvent[] = []; + disposables.add(service.onDidAddEvent(e => firedEvents.push(e))); + + service.log(claudeCodeSession, 'claude-event', 'details'); + + assert.strictEqual(firedEvents.length, 1); + assert.strictEqual(service.getEvents(claudeCodeSession).length, 1); + }); }); suite('getSessionResources', () => { @@ -492,6 +503,29 @@ suite('ChatDebugServiceImpl', () => { assert.ok(service.getEvents(copilotCliSession).length > 0); }); + test('should invoke providers for claude-code sessions', async () => { + let providerCalled = false; + + const provider: IChatDebugLogProvider = { + provideChatDebugLog: async () => { + providerCalled = true; + return [{ + kind: 'generic', + sessionResource: claudeCodeSession, + created: new Date(), + name: 'claude-provider-event', + level: ChatDebugLogLevel.Info, + }]; + }, + }; + + disposables.add(service.registerProvider(provider)); + await service.invokeProviders(claudeCodeSession); + + assert.strictEqual(providerCalled, true); + assert.ok(service.getEvents(claudeCodeSession).length > 0); + }); + test('newly registered provider should be invoked for active sessions', async () => { // Start an invocation before the provider is registered const firstProvider: IChatDebugLogProvider = { From a9a0540bd6fc36b052b1407a0c337912ff3b6294 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 18 Mar 2026 19:56:13 +0100 Subject: [PATCH 09/24] update distro (#302905) --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index e30e79e0f4f..2e7577e48b3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.113.0", - "distro": "c056f4dce23e765e319d32692385fa0192028f7b", + "distro": "f7f14fdd95367f272a8a1fc24811b3b55bdd0fe3", "author": { "name": "Microsoft Corporation" }, @@ -249,4 +249,4 @@ "optionalDependencies": { "windows-foreground-love": "0.6.1" } -} +} \ No newline at end of file From 5b771fc25ecd4fa8e91d01ec7e9f3066a5730f05 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 18 Mar 2026 12:10:30 -0700 Subject: [PATCH 10/24] Also handle copilot native deps in remote server build (#302603) * Remote server copilot build fixes * Don't strip copilot from stable builds * Handle all platforms for copilot --- build/gulpfile.reh.ts | 12 ++++- build/gulpfile.vscode.ts | 71 ++----------------------- build/lib/copilot.ts | 110 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 69 deletions(-) create mode 100644 build/lib/copilot.ts diff --git a/build/gulpfile.reh.ts b/build/gulpfile.reh.ts index 0ad08ab6fbe..f3ba46d9f89 100644 --- a/build/gulpfile.reh.ts +++ b/build/gulpfile.reh.ts @@ -34,6 +34,7 @@ import * as cp from 'child_process'; import log from 'fancy-log'; import buildfile from './buildfile.ts'; import { fetchUrls, fetchGithub } from './lib/fetch.ts'; +import { getCopilotExcludeFilter, copyCopilotNativeDeps } from './lib/copilot.ts'; import jsonEditor from 'gulp-json-editor'; @@ -343,6 +344,7 @@ function packageTask(type: string, platform: string, arch: string, sourceFolderN .pipe(filter(['**', '!**/package-lock.json', '!**/*.{js,css}.map'])) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, '.moduleignore'))) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, `.moduleignore.${process.platform}`))) + .pipe(filter(getCopilotExcludeFilter(platform, arch))) .pipe(jsFilter) .pipe(util.stripSourceMappingURL()) .pipe(jsFilter.restore); @@ -461,6 +463,13 @@ function patchWin32DependenciesTask(destinationFolderName: string) { }; } +function copyCopilotNativeDepsTaskREH(platform: string, arch: string, destinationFolderName: string) { + return async () => { + const nodeModulesDir = path.join(BUILD_ROOT, destinationFolderName, 'node_modules'); + copyCopilotNativeDeps(platform, arch, nodeModulesDir); + }; +} + /** * @param product The parsed product.json file contents */ @@ -509,7 +518,8 @@ function tweakProductForServerWeb(product: typeof import('../product.json')) { compileNativeExtensionsBuildTask, gulp.task(`node-${platform}-${arch}`) as task.Task, util.rimraf(path.join(BUILD_ROOT, destinationFolderName)), - packageTask(type, platform, arch, sourceFolderName, destinationFolderName) + packageTask(type, platform, arch, sourceFolderName, destinationFolderName), + copyCopilotNativeDepsTaskREH(platform, arch, destinationFolderName) ]; if (platform === 'win32') { diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index 35f0d93a6e9..336a8947fbb 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -31,6 +31,7 @@ import minimist from 'minimist'; import { compileBuildWithoutManglingTask, compileBuildWithManglingTask } from './gulpfile.compile.ts'; import { compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileAllExtensionsBuildTask, compileExtensionMediaBuildTask, cleanExtensionsBuildTask } from './gulpfile.extensions.ts'; import { copyCodiconsTask } from './lib/compilation.ts'; +import { getCopilotExcludeFilter, copyCopilotNativeDeps } from './lib/copilot.ts'; import type { EmbeddedProductInfo } from './lib/embeddedType.ts'; import { useEsbuildTranspile } from './buildConfig.ts'; import { promisify } from 'util'; @@ -318,38 +319,6 @@ function computeChecksum(filename: string): string { return hash; } -const copilotPlatforms = [ - 'darwin-arm64', 'darwin-x64', - 'linux-arm64', 'linux-x64', - 'win32-arm64', 'win32-x64', -]; - -/** - * Returns a glob filter that strips @github/copilot platform packages and - * prebuilt native modules for architectures other than the build target. - * On stable builds, all copilot SDK dependencies are stripped entirely. - */ -function getCopilotExcludeFilter(platform: string, arch: string, quality: string | undefined): string[] { - const targetPlatformArch = `${platform}-${arch}`; - const nonTargetPlatforms = copilotPlatforms.filter(p => p !== targetPlatformArch); - - // Strip wrong-architecture @github/copilot-{platform} packages. - // All copilot prebuilds are stripped by .moduleignore; VS Code's own - // node-pty is copied into the prebuilds location by a post-packaging task. - const excludes = nonTargetPlatforms.map(p => `!**/node_modules/@github/copilot-${p}/**`); - - // Strip agent host SDK dependencies entirely from stable builds - if (quality === 'stable') { - excludes.push( - '!**/node_modules/@github/copilot/**', - '!**/node_modules/@github/copilot-sdk/**', - '!**/node_modules/@github/copilot-*/**', - ); - } - - return ['**', ...excludes]; -} - function packageTask(platform: string, arch: string, sourceFolderName: string, destinationFolderName: string, _opts?: { stats?: boolean }) { const destination = path.join(path.dirname(root), destinationFolderName); platform = platform || process.platform; @@ -469,7 +438,7 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d .pipe(filter(depFilterPattern)) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, '.moduleignore'))) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, `.moduleignore.${process.platform}`))) - .pipe(filter(getCopilotExcludeFilter(platform, arch, quality))) + .pipe(filter(getCopilotExcludeFilter(platform, arch))) .pipe(jsFilter) .pipe(util.rewriteSourceMappingURL(sourceMappingURLBase)) .pipe(jsFilter.restore) @@ -713,27 +682,10 @@ function patchWin32DependenciesTask(destinationFolderName: string) { }; } -/** - * Copies VS Code's own node-pty binaries into the copilot SDK's - * expected locations so the copilot CLI subprocess can find them at runtime. - * The copilot-bundled prebuilds are stripped by .moduleignore; - * this replaces them with the same binaries VS Code already ships, avoiding - * new system dependency requirements. - * - * node-pty: `prebuilds/{platform}-{arch}/` (pty.node + spawn-helper) - */ function copyCopilotNativeDepsTask(platform: string, arch: string, destinationFolderName: string) { const outputDir = path.join(path.dirname(root), destinationFolderName); return async () => { - const quality = (product as { quality?: string }).quality; - - // On stable builds the copilot SDK is stripped entirely -- nothing to copy into. - if (quality === 'stable') { - console.log(`[copyCopilotNativeDeps] Skipping -- stable build`); - return; - } - // On Windows with win32VersionedUpdate, app resources live under a // commit-hash prefix: {output}/{commitHash}/resources/app/ const versionedResourcesFolder = util.getVersionedResourcesFolder(platform, commit!); @@ -741,24 +693,7 @@ function copyCopilotNativeDepsTask(platform: string, arch: string, destinationFo ? path.join(outputDir, `${product.nameLong}.app`, 'Contents', 'Resources', 'app') : path.join(outputDir, versionedResourcesFolder, 'resources', 'app'); - // Source and destination are both in node_modules/, which exists as a real - // directory on disk on all platforms after packaging. - const nodeModulesDir = path.join(appBase, 'node_modules'); - const copilotBase = path.join(nodeModulesDir, '@github', 'copilot'); - const platformArch = `${platform === 'win32' ? 'win32' : platform}-${arch}`; - - const nodePtySource = path.join(nodeModulesDir, 'node-pty', 'build', 'Release'); - - // Fail-fast: source binaries must exist on non-stable builds. - if (!fs.existsSync(nodePtySource)) { - throw new Error(`[copyCopilotNativeDeps] node-pty source not found at ${nodePtySource}`); - } - - // Copy node-pty (pty.node + spawn-helper) into copilot prebuilds - const copilotPrebuildsDir = path.join(copilotBase, 'prebuilds', platformArch); - fs.mkdirSync(copilotPrebuildsDir, { recursive: true }); - fs.cpSync(nodePtySource, copilotPrebuildsDir, { recursive: true }); - console.log(`[copyCopilotNativeDeps] Copied node-pty from ${nodePtySource} to ${copilotPrebuildsDir}`); + copyCopilotNativeDeps(platform, arch, path.join(appBase, 'node_modules')); }; } diff --git a/build/lib/copilot.ts b/build/lib/copilot.ts new file mode 100644 index 00000000000..f182c9829a9 --- /dev/null +++ b/build/lib/copilot.ts @@ -0,0 +1,110 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * The platforms that @github/copilot ships platform-specific packages for. + * These are the `@github/copilot-{platform}` optional dependency packages. + */ +export const copilotPlatforms = [ + 'darwin-arm64', 'darwin-x64', + 'linux-arm64', 'linux-x64', + 'win32-arm64', 'win32-x64', +]; + +/** + * Converts VS Code build platform/arch to the values that Node.js reports + * at runtime via `process.platform` and `process.arch`. + * + * The copilot SDK's `loadNativeModule` looks up native binaries under + * `prebuilds/${process.platform}-${process.arch}/`, so the directory names + * must match these runtime values exactly. + */ +function toNodePlatformArch(platform: string, arch: string): { nodePlatform: string; nodeArch: string } { + // alpine is musl-linux; Node still reports process.platform === 'linux' + let nodePlatform = platform === 'alpine' ? 'linux' : platform; + let nodeArch = arch; + + if (arch === 'armhf') { + // VS Code build uses 'armhf'; Node reports process.arch === 'arm' + nodeArch = 'arm'; + } else if (arch === 'alpine') { + // Legacy: { platform: 'linux', arch: 'alpine' } means alpine-x64 + nodePlatform = 'linux'; + nodeArch = 'x64'; + } + + return { nodePlatform, nodeArch }; +} + +/** + * Returns a glob filter that strips @github/copilot platform packages + * for architectures other than the build target. + * + * For platforms the copilot SDK doesn't natively support (e.g. alpine, armhf), + * ALL platform packages are stripped - that's fine because the SDK doesn't ship + * binaries for those platforms anyway, and we replace them with VS Code's own. + */ +export function getCopilotExcludeFilter(platform: string, arch: string): string[] { + const { nodePlatform, nodeArch } = toNodePlatformArch(platform, arch); + const targetPlatformArch = `${nodePlatform}-${nodeArch}`; + const nonTargetPlatforms = copilotPlatforms.filter(p => p !== targetPlatformArch); + + // Strip wrong-architecture @github/copilot-{platform} packages. + // All copilot prebuilds are stripped by .moduleignore; VS Code's own + // node-pty is copied into the prebuilds location by a post-packaging task. + const excludes = nonTargetPlatforms.map(p => `!**/node_modules/@github/copilot-${p}/**`); + + return ['**', ...excludes]; +} + +/** + * Copies VS Code's own node-pty binaries into the copilot SDK's + * expected locations so the copilot CLI subprocess can find them at runtime. + * The copilot-bundled prebuilds are stripped by .moduleignore; + * this replaces them with the same binaries VS Code already ships, avoiding + * new system dependency requirements. + * + * This works even for platforms the copilot SDK doesn't natively support + * (e.g. alpine, armhf) because the SDK's native module loader simply + * looks for `prebuilds/{process.platform}-{process.arch}/pty.node` - it + * doesn't validate the platform against a supported list. + * + * Failures are logged but do not throw, to avoid breaking the build on + * platforms where something unexpected happens. + * + * @param nodeModulesDir Absolute path to the node_modules directory that + * contains both the source binaries (node-pty) and the copilot SDK + * target directories. + */ +export function copyCopilotNativeDeps(platform: string, arch: string, nodeModulesDir: string): void { + const { nodePlatform, nodeArch } = toNodePlatformArch(platform, arch); + const platformArch = `${nodePlatform}-${nodeArch}`; + + const copilotBase = path.join(nodeModulesDir, '@github', 'copilot'); + if (!fs.existsSync(copilotBase)) { + console.warn(`[copyCopilotNativeDeps] @github/copilot not found at ${copilotBase}, skipping`); + return; + } + + const nodePtySource = path.join(nodeModulesDir, 'node-pty', 'build', 'Release'); + if (!fs.existsSync(nodePtySource)) { + console.warn(`[copyCopilotNativeDeps] node-pty source not found at ${nodePtySource}, skipping`); + return; + } + + try { + // Copy node-pty (pty.node + spawn-helper on Unix, conpty.node + conpty/ on Windows) + // into copilot prebuilds so the SDK finds them via loadNativeModule. + const copilotPrebuildsDir = path.join(copilotBase, 'prebuilds', platformArch); + fs.mkdirSync(copilotPrebuildsDir, { recursive: true }); + fs.cpSync(nodePtySource, copilotPrebuildsDir, { recursive: true }); + console.log(`[copyCopilotNativeDeps] Copied node-pty from ${nodePtySource} to ${copilotPrebuildsDir}`); + } catch (err) { + console.warn(`[copyCopilotNativeDeps] Failed to copy node-pty for ${platformArch}: ${err}`); + } +} From 7ad886481c813614e140e3294112a56188787d2f Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 18 Mar 2026 20:20:34 +0100 Subject: [PATCH 11/24] Improve sessions workspace picker spacing and color consistency (#302883) * Adjust margins for project picker action labels in chat UI * Adjust margin for project picker action label dropdown icon * Adjust margin-top for project picker action label dropdown icon * Update action label color to use icon foreground in chat picker --- .../contrib/chat/browser/media/chatWelcomePart.css | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css index a3316e7d903..18e02c11f2e 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css @@ -194,7 +194,13 @@ .sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label .codicon { font-size: 18px; - margin-right: 6px; + margin-right: 2px; +} + +.sessions-chat-picker-slot.sessions-chat-project-picker .action-label .codicon-chevron-down { + margin-right: 0; + position: relative; + top: 1px; } .sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label .sessions-chat-dropdown-label { @@ -220,7 +226,7 @@ padding: 3px 3px 3px 6px; background-color: transparent; border: none; - color: var(--vscode-descriptionForeground); + color: var(--vscode-icon-foreground); font-size: 13px; cursor: pointer; white-space: nowrap; @@ -242,7 +248,7 @@ .sessions-chat-picker-slot.disabled .action-label:hover { background-color: transparent; - color: var(--vscode-descriptionForeground); + color: var(--vscode-icon-foreground); } .sessions-chat-picker-slot.loading .action-label { @@ -279,7 +285,7 @@ .sessions-chat-picker-slot .action-label .codicon-chevron-down { font-size: 12px; - margin-left: 2px; + margin-left: 6px; } .sessions-chat-picker-slot .action-label .chat-session-option-label { From 915adb258fda22a577220b4594dce56187db9aa3 Mon Sep 17 00:00:00 2001 From: "vs-code-engineering[bot]" <122617954+vs-code-engineering[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 19:39:25 +0000 Subject: [PATCH 12/24] Update distro commit (main) (#302939) Update distro commit to f7f14fdd Co-authored-by: vs-code-engineering[bot] <122617954+vs-code-engineering[bot]@users.noreply.github.com> Co-authored-by: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> From 2687d4ce4628f8b8ebe0403959ebd533da11ceab Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Wed, 18 Mar 2026 12:48:09 -0700 Subject: [PATCH 13/24] Refactor browser chat integration to use contributions (#302936) * Refactor browser chat integration to use contributions * feedback --- .../browserElements/common/browserElements.ts | 119 +++++ .../electron-browser/browserEditor.ts | 464 +++--------------- .../browserEditorChatIntegration.ts | 385 +++++++++++++++ .../browserView.contribution.ts | 1 + .../electron-browser/browserViewActions.ts | 70 +-- 5 files changed, 586 insertions(+), 453 deletions(-) create mode 100644 src/vs/workbench/contrib/browserView/electron-browser/browserEditorChatIntegration.ts diff --git a/src/vs/platform/browserElements/common/browserElements.ts b/src/vs/platform/browserElements/common/browserElements.ts index 29735127134..7e8d1beac19 100644 --- a/src/vs/platform/browserElements/common/browserElements.ts +++ b/src/vs/platform/browserElements/common/browserElements.ts @@ -81,3 +81,122 @@ export function getDisplayNameFromOuterHTML(outerHTML: string): string { const className = classMatch ? `.${classMatch[1].replace(/\s+/g, '.')}` : ''; return `${tagName}${id}${className}`; } + +/** + * Format an array of element ancestors into a CSS-selector-like path string. + */ +export function formatElementPath(ancestors: readonly IElementAncestor[] | undefined): string | undefined { + if (!ancestors || ancestors.length === 0) { + return undefined; + } + + return ancestors + .map(ancestor => { + const classes = ancestor.classNames?.length ? `.${ancestor.classNames.join('.')}` : ''; + const id = ancestor.id ? `#${ancestor.id}` : ''; + return `${ancestor.tagName}${id}${classes}`; + }) + .join(' > '); +} + +/** + * Collapse margin-top/right/bottom/left or padding-top/right/bottom/left + * into a single shorthand value, removing the individual entries from the map. + */ +function createBoxShorthand(entries: Map, propertyName: 'margin' | 'padding'): string | undefined { + const topKey = `${propertyName}-top`; + const rightKey = `${propertyName}-right`; + const bottomKey = `${propertyName}-bottom`; + const leftKey = `${propertyName}-left`; + + const top = entries.get(topKey); + const right = entries.get(rightKey); + const bottom = entries.get(bottomKey); + const left = entries.get(leftKey); + + if (top === undefined || right === undefined || bottom === undefined || left === undefined) { + return undefined; + } + + entries.delete(topKey); + entries.delete(rightKey); + entries.delete(bottomKey); + entries.delete(leftKey); + + return `${top} ${right} ${bottom} ${left}`; +} + +/** + * Format a key-value record into a markdown-style list, + * collapsing margin/padding into shorthand values. + */ +export function formatElementMap(entries: Readonly> | undefined): string | undefined { + if (!entries || Object.keys(entries).length === 0) { + return undefined; + } + + const normalizedEntries = new Map(Object.entries(entries)); + const lines: string[] = []; + + const marginShorthand = createBoxShorthand(normalizedEntries, 'margin'); + if (marginShorthand) { + lines.push(`- margin: ${marginShorthand}`); + } + + const paddingShorthand = createBoxShorthand(normalizedEntries, 'padding'); + if (paddingShorthand) { + lines.push(`- padding: ${paddingShorthand}`); + } + + for (const [name, value] of Array.from(normalizedEntries.entries()).sort(([a], [b]) => a.localeCompare(b))) { + lines.push(`- ${name}: ${value}`); + } + + return lines.join('\n'); +} + +/** + * Build a structured text representation of element data for use as chat context. + */ +export function createElementContextValue(elementData: IElementData, displayName: string, attachCss: boolean): string { + const sections: string[] = []; + sections.push('Attached Element Context from Integrated Browser'); + sections.push(`Element: ${displayName}`); + + const htmlPath = formatElementPath(elementData.ancestors); + if (htmlPath) { + sections.push(`HTML Path:\n${htmlPath}`); + } + + const attributeTable = formatElementMap(elementData.attributes); + if (attributeTable) { + sections.push(`Attributes:\n${attributeTable}`); + } + + if (attachCss) { + const computedStyleTable = formatElementMap(elementData.computedStyles); + if (computedStyleTable) { + sections.push(`Computed Styles:\n${computedStyleTable}`); + } + } + + if (elementData.dimensions) { + const { top, left, width, height } = elementData.dimensions; + sections.push( + `Dimensions:\n- top: ${Math.round(top)}px\n- left: ${Math.round(left)}px\n- width: ${Math.round(width)}px\n- height: ${Math.round(height)}px` + ); + } + + const innerText = elementData.innerText?.trim(); + if (innerText) { + sections.push(`Inner Text:\n\`\`\`text\n${innerText}\n\`\`\``); + } + + sections.push(`Outer HTML:\n\`\`\`html\n${elementData.outerHTML}\n\`\`\``); + + if (attachCss) { + sections.push(`Full Computed CSS:\n\`\`\`css\n${elementData.computedStyle}\n\`\`\``); + } + + return sections.join('\n\n'); +} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 78bf737d962..9c32141a514 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -9,16 +9,15 @@ import { $, addDisposableListener, Dimension, EventType, IDomPosition, registerE import { Button, ButtonBar } from '../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; import { RawContextKey, IContextKey, IContextKeyService, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IInstantiationService, IConstructorSignature, BrandedService } from '../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { AUX_WINDOW_GROUP, IEditorService } from '../../../services/editor/common/editorService.js'; import { EditorPane } from '../../../browser/parts/editor/editorPane.js'; import { IEditorOpenContext } from '../../../common/editor.js'; import { BrowserEditorInput } from '../common/browserEditorInput.js'; -import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; import { IBrowserEditorViewState, IBrowserViewModel @@ -42,21 +41,16 @@ import { Lazy } from '../../../../base/common/lazy.js'; import { WorkbenchHoverDelegate } from '../../../../platform/hover/browser/hover.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; -import { IBrowserElementsService } from '../../../services/browserElements/browser/browserElementsService.js'; -import { IChatWidgetService } from '../../chat/browser/chat.js'; import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; import { BrowserFindWidget, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE } from './browserFindWidget.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { SiteInfoWidget } from './siteInfoWidget.js'; -import { IChatRequestVariableEntry } from '../../chat/common/attachments/chatVariableEntries.js'; -import { IElementAncestor, IElementData, IBrowserTargetLocator, getDisplayNameFromOuterHTML } from '../../../../platform/browserElements/common/browserElements.js'; import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; import { URI } from '../../../../base/common/uri.js'; import { ChatConfiguration } from '../../chat/common/constants.js'; -import { Event } from '../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; @@ -67,7 +61,6 @@ export const CONTEXT_BROWSER_STORAGE_SCOPE = new RawContextKey('browserS export const CONTEXT_BROWSER_HAS_URL = new RawContextKey('browserHasUrl', false, localize('browser.hasUrl', "Whether the browser has a URL loaded")); export const CONTEXT_BROWSER_HAS_ERROR = new RawContextKey('browserHasError', false, localize('browser.hasError', "Whether the browser has a load error")); export const CONTEXT_BROWSER_DEVTOOLS_OPEN = new RawContextKey('browserDevToolsOpen', false, localize('browser.devToolsOpen', "Whether developer tools are open for the current browser view")); -export const CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE = new RawContextKey('browserElementSelectionActive', false, localize('browser.elementSelectionActive', "Whether element selection is currently active")); export const CONTEXT_BROWSER_CAN_ZOOM_IN = new RawContextKey('browserCanZoomIn', true, localize('browser.canZoomIn', "Whether the browser can zoom in further")); export const CONTEXT_BROWSER_CAN_ZOOM_OUT = new RawContextKey('browserCanZoomOut', true, localize('browser.canZoomOut', "Whether the browser can zoom out further")); @@ -90,6 +83,41 @@ function watchForAgentSharingContextChanges(contextKeyService: IContextKeyServic */ const originalHtmlElementFocus = HTMLElement.prototype.focus; + +/** + * Base class for browser editor services that track the model lifecycle. + * + * Subclasses implement {@link subscribeToModel} which is called whenever a new model is set. + * A {@link DisposableStore} is provided that is automatically cleared when the model + * changes or the editor input is cleared. + */ +export abstract class BrowserEditorContribution extends Disposable { + private readonly _modelStore = this._register(new DisposableStore()); + + constructor(protected readonly editor: BrowserEditor) { + super(); + this._register(editor.onDidChangeModel(model => { + this._modelStore.clear(); + if (model) { + this.subscribeToModel(model, this._modelStore); + } else { + this.clear(); + } + })); + } + + /** + * Called whenever the editor model changes to update state. + */ + protected subscribeToModel(_model: IBrowserViewModel, _store: DisposableStore): void { } + + /** + * Called when the model is cleared to reset state. + */ + clear(): void { } +} + + /** * Transient zoom-level indicator that briefly appears inside the URL bar on zoom changes. * All DOM construction, state, and auto-hide logic are self-contained here. @@ -137,8 +165,7 @@ class BrowserNavigationBar extends Disposable { editor: BrowserEditor, container: HTMLElement, instantiationService: IInstantiationService, - scopedContextKeyService: IContextKeyService, - configurationService: IConfigurationService + scopedContextKeyService: IContextKeyService ) { super(); @@ -371,6 +398,28 @@ class BrowserNavigationBar extends Disposable { } export class BrowserEditor extends EditorPane { + + // -- Contribution registry -------------------------------------------- + + private static readonly _contributions: IConstructorSignature[] = []; + static registerContribution(ctor: { new(editor: BrowserEditor, ...services: Services): BrowserEditorContribution }): void { + BrowserEditor._contributions.push(ctor as IConstructorSignature); + } + + private readonly _contributionInstances = new Map, BrowserEditorContribution>(); + getContribution(ctor: { new(editor: BrowserEditor, ...services: Services): T }): T | undefined { + return this._contributionInstances.get(ctor as IConstructorSignature) as T | undefined; + } + + // -- Model lifecycle ------------------------------------------------ + + private _model: IBrowserViewModel | undefined; + get model(): IBrowserViewModel | undefined { return this._model; } + private readonly _onDidChangeModel = this._register(new Emitter()); + readonly onDidChangeModel = this._onDidChangeModel.event; + + // -- State ---------------------------------------------------------- + private _overlayVisible = false; private _editorVisible = false; private _currentKeyDownEvent: IBrowserViewKeyDownEvent | undefined; @@ -378,6 +427,7 @@ export class BrowserEditor extends EditorPane { private _navigationBar!: BrowserNavigationBar; private _browserContainerWrapper!: HTMLElement; private _browserContainer!: HTMLElement; + get browserContainer(): HTMLElement { return this._browserContainer; } private _placeholderScreenshot!: HTMLElement; private _overlayPauseContainer!: HTMLElement; private _overlayPauseHeading!: HTMLElement; @@ -392,15 +442,11 @@ export class BrowserEditor extends EditorPane { private _hasUrlContext!: IContextKey; private _hasErrorContext!: IContextKey; private _devToolsOpenContext!: IContextKey; - private _elementSelectionActiveContext!: IContextKey; private _canZoomInContext!: IContextKey; private _canZoomOutContext!: IContextKey; - private _model: IBrowserViewModel | undefined; private readonly _inputDisposables = this._register(new DisposableStore()); private overlayManager: BrowserOverlayManager | undefined; - private _elementSelectionCts: CancellationTokenSource | undefined; - private _consoleSessionCts: CancellationTokenSource | undefined; private _screenshotTimeout: ReturnType | undefined; private readonly _certActionButton = this._register(new MutableDisposable()); @@ -414,9 +460,6 @@ export class BrowserEditor extends EditorPane { @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IEditorService private readonly editorService: IEditorService, - @IBrowserElementsService private readonly browserElementsService: IBrowserElementsService, - @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, - @IConfigurationService private readonly configurationService: IConfigurationService, @ILayoutService private readonly layoutService: ILayoutService, @IBrowserZoomService private readonly browserZoomService: IBrowserZoomService, @IAccessibilityService private readonly accessibilityService: IAccessibilityService @@ -438,13 +481,23 @@ export class BrowserEditor extends EditorPane { this._hasUrlContext = CONTEXT_BROWSER_HAS_URL.bindTo(contextKeyService); this._hasErrorContext = CONTEXT_BROWSER_HAS_ERROR.bindTo(contextKeyService); this._devToolsOpenContext = CONTEXT_BROWSER_DEVTOOLS_OPEN.bindTo(contextKeyService); - this._elementSelectionActiveContext = CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE.bindTo(contextKeyService); this._canZoomInContext = CONTEXT_BROWSER_CAN_ZOOM_IN.bindTo(contextKeyService); this._canZoomOutContext = CONTEXT_BROWSER_CAN_ZOOM_OUT.bindTo(contextKeyService); // Currently this is always true since it is scoped to the editor container CONTEXT_BROWSER_FOCUSED.bindTo(contextKeyService); + // Create a scoped instantiation service so contributions get the scoped context key service + const scopedInstantiationService = this._register(this.instantiationService.createChild( + new ServiceCollection([IContextKeyService, contextKeyService]) + )); + + // Instantiate all registered contributions + for (const ctor of BrowserEditor._contributions) { + const instance = this._register(scopedInstantiationService.createInstance(ctor, this)); + this._contributionInstances.set(ctor, instance); + } + // Create root container const root = $('.browser-root'); parent.appendChild(root); @@ -453,7 +506,7 @@ export class BrowserEditor extends EditorPane { const toolbar = $('.browser-toolbar'); // Create navigation bar widget with scoped context - this._navigationBar = this._register(new BrowserNavigationBar(this, toolbar, this.instantiationService, contextKeyService, this.configurationService)); + this._navigationBar = this._register(new BrowserNavigationBar(this, toolbar, this.instantiationService, contextKeyService)); root.appendChild(toolbar); @@ -541,11 +594,14 @@ export class BrowserEditor extends EditorPane { // Resolve the browser view model from the input const model = await input.resolve(); - this._model = model; + if (token.isCancellationRequested || this.input !== input) { return; } + this._model = model; + this._onDidChangeModel.fire(model); + this._storageScopeContext.set(this._model.storageScope); this._devToolsOpenContext.set(this._model.isDevToolsOpen); this.updateZoomContext(); @@ -554,11 +610,6 @@ export class BrowserEditor extends EditorPane { // Update find widget with new model this._findWidget.rawValue?.setModel(this._model); - // Clean up on input disposal - this._inputDisposables.add(input.onWillDispose(() => { - this._model = undefined; - })); - // Listen for sharing state changes on the model this._inputDisposables.add(this._model.onDidChangeSharedWithAgent(() => { this._updateSharingState(false); @@ -607,13 +658,6 @@ export class BrowserEditor extends EditorPane { // Update navigation bar and context keys from model this.updateNavigationState(navEvent); - - // Ensure a console session is active while a page URL is loaded. - if (navEvent.url) { - this.startConsoleSession(); - } else { - this.stopConsoleSession(); - } })); this._inputDisposables.add(this._model.onDidChangeLoadingState(() => { @@ -674,11 +718,6 @@ export class BrowserEditor extends EditorPane { this.layout(); this.updateVisibility(); this.doScreenshot(); - - // Start console log capture session if a URL is loaded - if (this._model.url) { - this.startConsoleSession(); - } } protected override setEditorVisible(visible: boolean): void { @@ -689,7 +728,7 @@ export class BrowserEditor extends EditorPane { /** * Make the browser container the active element without moving focus from the browser view. */ - private ensureBrowserFocus(): void { + ensureBrowserFocus(): void { originalHtmlElementFocus.call(this._browserContainer); } @@ -1043,312 +1082,6 @@ export class BrowserEditor extends EditorPane { this._findWidget.rawValue?.find(true); } - /** - * Start element selection in the browser view, wait for a user selection, and add it to chat. - */ - async addElementToChat(): Promise { - // If selection is already active, cancel it - if (this._elementSelectionCts) { - this._elementSelectionCts.dispose(true); - this._elementSelectionCts = undefined; - this._elementSelectionActiveContext.set(false); - return; - } - - // Start new selection - const cts = new CancellationTokenSource(); - this._elementSelectionCts = cts; - this._elementSelectionActiveContext.set(true); - - type IntegratedBrowserAddElementToChatStartEvent = {}; - - type IntegratedBrowserAddElementToChatStartClassification = { - owner: 'jruales'; - comment: 'The user initiated an Add Element to Chat action in Integrated Browser.'; - }; - - this.telemetryService.publicLog2('integratedBrowser.addElementToChat.start', {}); - - try { - // Get the resource URI for this editor - const resourceUri = this.input?.resource; - if (!resourceUri) { - throw new Error('No resource URI found'); - } - - // Make the browser the focused view - this.ensureBrowserFocus(); - - // Create a locator - for integrated browser, use the URI scheme to identify - // Browser view URIs have a special scheme we can match against - const locator: IBrowserTargetLocator = { browserViewId: BrowserViewUri.getId(this.input.resource) }; - - // Start debug session for integrated browser - await this.browserElementsService.startDebugSession(cts.token, locator); - - // Get the browser container bounds - const { width, height } = this._browserContainer.getBoundingClientRect(); - - // Get element data from user selection - const elementData = await this.browserElementsService.getElementData({ x: 0, y: 0, width, height }, cts.token, locator); - if (!elementData) { - throw new Error('Element data not found'); - } - - const { attachCss, attachImages } = await this.attachElementDataToChat(elementData); - - type IntegratedBrowserAddElementToChatAddedEvent = { - attachCss: boolean; - attachImages: boolean; - }; - - type IntegratedBrowserAddElementToChatAddedClassification = { - attachCss: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether chat.sendElementsToChat.attachCSS was enabled.' }; - attachImages: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether chat.sendElementsToChat.attachImages was enabled.' }; - owner: 'jruales'; - comment: 'An element was successfully added to chat from Integrated Browser.'; - }; - - this.telemetryService.publicLog2('integratedBrowser.addElementToChat.added', { - attachCss, - attachImages - }); - - } catch (error) { - if (!cts.token.isCancellationRequested) { - this.logService.error('BrowserEditor.addElementToChat: Failed to select element', error); - } - } finally { - cts.dispose(); - if (this._elementSelectionCts === cts) { - this._elementSelectionCts = undefined; - this._elementSelectionActiveContext.set(false); - } - } - } - - /** - * Grab the current console logs from the active console session and attach them to chat. - */ - async addConsoleLogsToChat(): Promise { - const resourceUri = this.input?.resource; - if (!resourceUri) { - return; - } - - const locator: IBrowserTargetLocator = { browserViewId: BrowserViewUri.getId(resourceUri) }; - - try { - const logs = await this.browserElementsService.getConsoleLogs(locator); - if (!logs) { - return; - } - - const toAttach: IChatRequestVariableEntry[] = []; - toAttach.push({ - id: 'console-logs-' + Date.now(), - name: localize('consoleLogs', 'Console Logs'), - fullName: localize('consoleLogs', 'Console Logs'), - value: logs, - modelDescription: 'Console logs captured from Integrated Browser.', - kind: 'element', - icon: ThemeIcon.fromId(Codicon.terminal.id), - }); - - const widget = await this.chatWidgetService.revealWidget() ?? this.chatWidgetService.lastFocusedWidget; - widget?.attachmentModel?.addContext(...toAttach); - } catch (error) { - this.logService.error('BrowserEditor.addConsoleLogsToChat: Failed to get console logs', error); - } - } - - /** - * Start a console session to capture logs from the browser view. - */ - private startConsoleSession(): void { - // Don't restart if already running - if (this._consoleSessionCts) { - return; - } - - const resourceUri = this.input?.resource; - if (!resourceUri || !this._model?.url) { - return; - } - - const cts = new CancellationTokenSource(); - this._consoleSessionCts = cts; - const locator: IBrowserTargetLocator = { browserViewId: BrowserViewUri.getId(resourceUri) }; - - this.browserElementsService.startConsoleSession(cts.token, locator).catch(error => { - if (!cts.token.isCancellationRequested) { - this.logService.error('BrowserEditor: Failed to start console session', error); - } - }); - } - - /** - * Stop the active console session. - */ - private stopConsoleSession(): void { - if (this._consoleSessionCts) { - this._consoleSessionCts.dispose(true); - this._consoleSessionCts = undefined; - } - } - - private createElementContextValue(elementData: IElementData, displayName: string, attachCss: boolean): string { - const sections: string[] = []; - sections.push('Attached Element Context from Integrated Browser'); - sections.push(`Element: ${displayName}`); - - const htmlPath = this.formatElementPath(elementData.ancestors); - if (htmlPath) { - sections.push(`HTML Path:\n${htmlPath}`); - } - - const attributeTable = this.formatElementMap(elementData.attributes); - if (attributeTable) { - sections.push(`Attributes:\n${attributeTable}`); - } - - if (attachCss) { - const computedStyleTable = this.formatElementMap(elementData.computedStyles); - if (computedStyleTable) { - sections.push(`Computed Styles:\n${computedStyleTable}`); - } - } - - if (elementData.dimensions) { - const { top, left, width, height } = elementData.dimensions; - sections.push( - `Dimensions:\n- top: ${Math.round(top)}px\n- left: ${Math.round(left)}px\n- width: ${Math.round(width)}px\n- height: ${Math.round(height)}px` - ); - } - - const innerText = elementData.innerText?.trim(); - if (innerText) { - sections.push(`Inner Text:\n\`\`\`text\n${innerText}\n\`\`\``); - } - - sections.push(`Outer HTML:\n\`\`\`html\n${elementData.outerHTML}\n\`\`\``); - - if (attachCss) { - sections.push(`Full Computed CSS:\n\`\`\`css\n${elementData.computedStyle}\n\`\`\``); - } - - return sections.join('\n\n'); - } - - private async attachElementDataToChat(elementData: IElementData): Promise<{ attachCss: boolean; attachImages: boolean }> { - const bounds = elementData.bounds; - const toAttach: IChatRequestVariableEntry[] = []; - - const displayName = getDisplayNameFromOuterHTML(elementData.outerHTML); - const attachCss = this.configurationService.getValue('chat.sendElementsToChat.attachCSS'); - const value = this.createElementContextValue(elementData, displayName, attachCss); - - toAttach.push({ - id: 'element-' + Date.now(), - name: displayName, - fullName: displayName, - value: value, - modelDescription: attachCss - ? 'Structured browser element context with HTML path, attributes, and computed styles.' - : 'Structured browser element context with HTML path and attributes.', - kind: 'element', - icon: ThemeIcon.fromId(Codicon.layout.id), - ancestors: elementData.ancestors, - attributes: elementData.attributes, - computedStyles: attachCss ? elementData.computedStyles : undefined, - dimensions: elementData.dimensions, - innerText: elementData.innerText, - }); - - const attachImages = this.configurationService.getValue('chat.sendElementsToChat.attachImages'); - if (attachImages && this._model) { - const screenshotBuffer = await this._model.captureScreenshot({ - quality: 90, - rect: bounds - }); - - toAttach.push({ - id: 'element-screenshot-' + Date.now(), - name: 'Element Screenshot', - fullName: 'Element Screenshot', - kind: 'image', - value: screenshotBuffer.buffer - }); - } - - const widget = await this.chatWidgetService.revealWidget() ?? this.chatWidgetService.lastFocusedWidget; - widget?.attachmentModel?.addContext(...toAttach); - - return { attachCss, attachImages }; - } - - private formatElementPath(ancestors: readonly IElementAncestor[] | undefined): string | undefined { - if (!ancestors || ancestors.length === 0) { - return undefined; - } - - return ancestors - .map(ancestor => { - const classes = ancestor.classNames?.length ? `.${ancestor.classNames.join('.')}` : ''; - const id = ancestor.id ? `#${ancestor.id}` : ''; - return `${ancestor.tagName}${id}${classes}`; - }) - .join(' > '); - } - - private formatElementMap(entries: Readonly> | undefined): string | undefined { - if (!entries || Object.keys(entries).length === 0) { - return undefined; - } - - const normalizedEntries = new Map(Object.entries(entries)); - const lines: string[] = []; - - const marginShorthand = this.createBoxShorthand(normalizedEntries, 'margin'); - if (marginShorthand) { - lines.push(`- margin: ${marginShorthand}`); - } - - const paddingShorthand = this.createBoxShorthand(normalizedEntries, 'padding'); - if (paddingShorthand) { - lines.push(`- padding: ${paddingShorthand}`); - } - - for (const [name, value] of Array.from(normalizedEntries.entries()).sort(([a], [b]) => a.localeCompare(b))) { - lines.push(`- ${name}: ${value}`); - } - - return lines.join('\n'); - } - - private createBoxShorthand(entries: Map, propertyName: 'margin' | 'padding'): string | undefined { - const topKey = `${propertyName}-top`; - const rightKey = `${propertyName}-right`; - const bottomKey = `${propertyName}-bottom`; - const leftKey = `${propertyName}-left`; - - const top = entries.get(topKey); - const right = entries.get(rightKey); - const bottom = entries.get(bottomKey); - const left = entries.get(leftKey); - - if (top === undefined || right === undefined || bottom === undefined || left === undefined) { - return undefined; - } - - entries.delete(topKey); - entries.delete(rightKey); - entries.delete(bottomKey); - entries.delete(leftKey); - - return `${top} ${right} ${bottom} ${left}`; - } - /** * Update navigation state and context keys */ @@ -1445,34 +1178,6 @@ export class BrowserEditor extends EditorPane { this._currentKeyDownEvent = keyEvent; try { - const isEnterKey = - keyEvent.code === 'Enter' || - keyEvent.code === 'NumpadEnter' || - keyEvent.key === 'Enter' || - keyEvent.key === 'Return'; - if (this._elementSelectionCts && isEnterKey) { - const cts = this._elementSelectionCts; - const resourceUri = this.input?.resource; - if (!resourceUri) { - return; - } - - const locator: IBrowserTargetLocator = { browserViewId: BrowserViewUri.getId(resourceUri) }; - const { width, height } = this._browserContainer.getBoundingClientRect(); - const elementData = await this.browserElementsService.getFocusedElementData({ x: 0, y: 0, width, height }, cts.token, locator); - if (!elementData) { - return; - } - - await this.attachElementDataToChat(elementData); - cts.dispose(); - if (this._elementSelectionCts === cts) { - this._elementSelectionCts = undefined; - this._elementSelectionActiveContext.set(false); - } - return; - } - const syntheticEvent = new KeyboardEvent('keydown', keyEvent); const standardEvent = new StandardKeyboardEvent(syntheticEvent); @@ -1530,15 +1235,6 @@ export class BrowserEditor extends EditorPane { override clearInput(): void { this._inputDisposables.clear(); - // Cancel any active element selection - if (this._elementSelectionCts) { - this._elementSelectionCts.dispose(true); - this._elementSelectionCts = undefined; - } - - // Cancel any active console session - this.stopConsoleSession(); - // Cancel any scheduled screenshots this.cancelScheduledScreenshot(); @@ -1548,6 +1244,7 @@ export class BrowserEditor extends EditorPane { void this._model?.setVisible(false); this._model = undefined; + this._onDidChangeModel.fire(undefined); this._canGoBackContext.reset(); this._canGoForwardContext.reset(); @@ -1555,7 +1252,6 @@ export class BrowserEditor extends EditorPane { this._hasErrorContext.reset(); this._storageScopeContext.reset(); this._devToolsOpenContext.reset(); - this._elementSelectionActiveContext.reset(); this._canZoomInContext.reset(); this._canZoomOutContext.reset(); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditorChatIntegration.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditorChatIntegration.ts new file mode 100644 index 00000000000..bbca28ba10d --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditorChatIntegration.ts @@ -0,0 +1,385 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { IContextKey, IContextKeyService, ContextKeyExpr, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { Action2, registerAction2, MenuId } from '../../../../platform/actions/common/actions.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { KeyMod, KeyCode } from '../../../../base/common/keyCodes.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Event } from '../../../../base/common/event.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IBrowserElementsService } from '../../../services/browserElements/browser/browserElementsService.js'; +import { IChatWidgetService } from '../../chat/browser/chat.js'; +import { IChatRequestVariableEntry } from '../../chat/common/attachments/chatVariableEntries.js'; +import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; +import { IElementData, IBrowserTargetLocator, getDisplayNameFromOuterHTML, createElementContextValue } from '../../../../platform/browserElements/common/browserElements.js'; +import { BrowserViewCommandId } from '../../../../platform/browserView/common/browserView.js'; +import { IBrowserViewModel } from '../../browserView/common/browserView.js'; +import { BrowserEditorInput } from '../common/browserEditorInput.js'; +import { BrowserEditor, BrowserEditorContribution, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_FOCUSED } from './browserEditor.js'; + +// Context key expression to check if browser editor is active +const BROWSER_EDITOR_ACTIVE = ContextKeyExpr.equals('activeEditor', BrowserEditorInput.EDITOR_ID); +const BrowserCategory = localize2('browserCategory', "Browser"); + +export const CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE = new RawContextKey('browserElementSelectionActive', false, localize('browser.elementSelectionActive', "Whether element selection is currently active")); + +/** + * Contribution that manages element selection, element attachment to chat, + * console session lifecycle, and console log attachment to chat. + */ +export class BrowserEditorChatIntegration extends BrowserEditorContribution { + private _elementSelectionCts: CancellationTokenSource | undefined; + private readonly _elementSelectionActiveContext: IContextKey; + + constructor( + editor: BrowserEditor, + @IContextKeyService contextKeyService: IContextKeyService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + @ILogService private readonly logService: ILogService, + @IBrowserElementsService private readonly browserElementsService: IBrowserElementsService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IConfigurationService private readonly configurationService: IConfigurationService, + ) { + super(editor); + this._elementSelectionActiveContext = CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE.bindTo(contextKeyService); + } + + protected override subscribeToModel(model: IBrowserViewModel, store: DisposableStore): void { + // Start console session when a page URL is loaded + if (model.url) { + store.add(this._startConsoleSession(model.id)); + } else { + store.add(Event.once(Event.filter(model.onDidNavigate, e => !!e.url))(() => { + store.add(this._startConsoleSession(model.id)); + })); + } + } + + // -- Element Selection ---------------------------------------------- + + /** + * Start element selection in the browser view, wait for a user selection, and add it to chat. + */ + async addElementToChat(): Promise { + // If selection is already active, cancel it + if (this._elementSelectionCts) { + this._elementSelectionCts.dispose(true); + this._elementSelectionCts = undefined; + this._elementSelectionActiveContext.set(false); + return; + } + + // Start new selection + const cts = new CancellationTokenSource(); + this._elementSelectionCts = cts; + this._elementSelectionActiveContext.set(true); + + type IntegratedBrowserAddElementToChatStartEvent = {}; + + type IntegratedBrowserAddElementToChatStartClassification = { + owner: 'jruales'; + comment: 'The user initiated an Add Element to Chat action in Integrated Browser.'; + }; + + this.telemetryService.publicLog2('integratedBrowser.addElementToChat.start', {}); + + try { + const browserViewId = this.editor.model?.id; + if (!browserViewId) { + throw new Error('No browser view ID found'); + } + + // Make the browser the focused view + this.editor.ensureBrowserFocus(); + + const locator: IBrowserTargetLocator = { browserViewId }; + + // Start debug session for integrated browser + await this.browserElementsService.startDebugSession(cts.token, locator); + + // Get the browser container bounds + const { width, height } = this.editor.browserContainer.getBoundingClientRect(); + + // Get element data from user selection + const elementData = await this.browserElementsService.getElementData({ x: 0, y: 0, width, height }, cts.token, locator); + if (!elementData) { + throw new Error('Element data not found'); + } + + const { attachCss, attachImages } = await this._attachElementDataToChat(elementData); + + type IntegratedBrowserAddElementToChatAddedEvent = { + attachCss: boolean; + attachImages: boolean; + }; + + type IntegratedBrowserAddElementToChatAddedClassification = { + attachCss: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether chat.sendElementsToChat.attachCSS was enabled.' }; + attachImages: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether chat.sendElementsToChat.attachImages was enabled.' }; + owner: 'jruales'; + comment: 'An element was successfully added to chat from Integrated Browser.'; + }; + + this.telemetryService.publicLog2('integratedBrowser.addElementToChat.added', { + attachCss, + attachImages + }); + + } catch (error) { + if (!cts.token.isCancellationRequested) { + this.logService.error('BrowserEditor.addElementToChat: Failed to select element', error); + } + } finally { + cts.dispose(); + if (this._elementSelectionCts === cts) { + this._elementSelectionCts = undefined; + this._elementSelectionActiveContext.set(false); + } + } + } + + /** + * Accept the currently focused element during element selection and attach it to chat. + */ + async addFocusedElementToChat(): Promise { + if (!this._elementSelectionCts) { + return; + } + + const cts = this._elementSelectionCts; + const browserViewId = this.editor.model?.id; + if (!browserViewId) { + return; + } + + const locator: IBrowserTargetLocator = { browserViewId }; + const { width, height } = this.editor.browserContainer.getBoundingClientRect(); + const elementData = await this.browserElementsService.getFocusedElementData({ x: 0, y: 0, width, height }, cts.token, locator); + if (!elementData) { + return; + } + + await this._attachElementDataToChat(elementData); + cts.dispose(); + if (this._elementSelectionCts === cts) { + this._elementSelectionCts = undefined; + this._elementSelectionActiveContext.set(false); + } + } + + override clear(): void { + if (this._elementSelectionCts) { + this._elementSelectionCts.dispose(true); + this._elementSelectionCts = undefined; + } + this._elementSelectionActiveContext.reset(); + } + + private async _attachElementDataToChat(elementData: IElementData): Promise<{ attachCss: boolean; attachImages: boolean }> { + const bounds = elementData.bounds; + const toAttach: IChatRequestVariableEntry[] = []; + + const displayName = getDisplayNameFromOuterHTML(elementData.outerHTML); + const attachCss = this.configurationService.getValue('chat.sendElementsToChat.attachCSS'); + const value = createElementContextValue(elementData, displayName, attachCss); + + toAttach.push({ + id: 'element-' + Date.now(), + name: displayName, + fullName: displayName, + value: value, + modelDescription: attachCss + ? 'Structured browser element context with HTML path, attributes, and computed styles.' + : 'Structured browser element context with HTML path and attributes.', + kind: 'element', + icon: ThemeIcon.fromId(Codicon.layout.id), + ancestors: elementData.ancestors, + attributes: elementData.attributes, + computedStyles: attachCss ? elementData.computedStyles : undefined, + dimensions: elementData.dimensions, + innerText: elementData.innerText, + }); + + const attachImages = this.configurationService.getValue('chat.sendElementsToChat.attachImages'); + const model = this.editor.model; + if (attachImages && model) { + const screenshotBuffer = await model.captureScreenshot({ + quality: 90, + rect: bounds + }); + + toAttach.push({ + id: 'element-screenshot-' + Date.now(), + name: 'Element Screenshot', + fullName: 'Element Screenshot', + kind: 'image', + value: screenshotBuffer.buffer + }); + } + + const widget = await this.chatWidgetService.revealWidget() ?? this.chatWidgetService.lastFocusedWidget; + widget?.attachmentModel?.addContext(...toAttach); + + return { attachCss, attachImages }; + } + + // -- Console Logs --------------------------------------------------- + + /** + * Grab the current console logs from the active console session and attach them to chat. + */ + async addConsoleLogsToChat(): Promise { + const browserViewId = this.editor.model?.id; + if (!browserViewId) { + return; + } + + const locator: IBrowserTargetLocator = { browserViewId }; + + try { + const logs = await this.browserElementsService.getConsoleLogs(locator); + if (!logs) { + return; + } + + const toAttach: IChatRequestVariableEntry[] = []; + toAttach.push({ + id: 'console-logs-' + Date.now(), + name: localize('consoleLogs', 'Console Logs'), + fullName: localize('consoleLogs', 'Console Logs'), + value: logs, + modelDescription: 'Console logs captured from Integrated Browser.', + kind: 'element', + icon: ThemeIcon.fromId(Codicon.terminal.id), + }); + + const widget = await this.chatWidgetService.revealWidget() ?? this.chatWidgetService.lastFocusedWidget; + widget?.attachmentModel?.addContext(...toAttach); + } catch (error) { + this.logService.error('BrowserEditor.addConsoleLogsToChat: Failed to get console logs', error); + } + } + + private _startConsoleSession(browserViewId: string): IDisposable { + const cts = new CancellationTokenSource(); + const locator: IBrowserTargetLocator = { browserViewId }; + + this.browserElementsService.startConsoleSession(cts.token, locator).catch(error => { + if (!cts.token.isCancellationRequested) { + this.logService.error('BrowserEditor: Failed to start console session', error); + } + }); + + return toDisposable(() => { + cts.dispose(true); + }); + } +} + +// Register the contribution +BrowserEditor.registerContribution(BrowserEditorChatIntegration); + +// -- Actions ------------------------------------------------------------ + +class AddElementToChatAction extends Action2 { + static readonly ID = BrowserViewCommandId.AddElementToChat; + + constructor() { + const enabled = ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('config.chat.sendElementsToChat.enabled', true)); + super({ + id: AddElementToChatAction.ID, + title: localize2('browser.addElementToChatAction', 'Add Element to Chat'), + category: BrowserCategory, + icon: Codicon.inspect, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate(), enabled), + toggled: CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, + menu: { + id: MenuId.BrowserActionsToolbar, + group: 'actions', + order: 1, + when: enabled + }, + keybinding: [{ + weight: KeybindingWeight.WorkbenchContrib + 50, // Priority over terminal + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyC, + }, { + when: CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyCode.Escape + }] + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + await browserEditor.getContribution(BrowserEditorChatIntegration)?.addElementToChat(); + } + } +} + +class AddConsoleLogsToChatAction extends Action2 { + static readonly ID = BrowserViewCommandId.AddConsoleLogsToChat; + + constructor() { + const enabled = ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('config.chat.sendElementsToChat.enabled', true)); + super({ + id: AddConsoleLogsToChatAction.ID, + title: localize2('browser.addConsoleLogsToChatAction', 'Add Console Logs to Chat'), + category: BrowserCategory, + icon: Codicon.output, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate(), enabled), + menu: { + id: MenuId.BrowserActionsToolbar, + group: 'actions', + order: 2, + when: enabled + } + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + await browserEditor.getContribution(BrowserEditorChatIntegration)?.addConsoleLogsToChat(); + } + } +} + +class AddFocusedElementToChatAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.browser.addFocusedElementToChat', + title: localize2('browser.addFocusedElementToChat', 'Add Focused Element to Chat'), + f1: false, + precondition: CONTEXT_BROWSER_FOCUSED, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib + 50, + primary: KeyCode.Enter, + when: CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const browserEditor = accessor.get(IEditorService).activeEditorPane; + if (browserEditor instanceof BrowserEditor) { + await browserEditor.getContribution(BrowserEditorChatIntegration)?.addFocusedElementToChat(); + } + } +} + +registerAction2(AddElementToChatAction); +registerAction2(AddConsoleLogsToChatAction); +registerAction2(AddFocusedElementToChatAction); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts index c616b961209..935d4be771d 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -40,6 +40,7 @@ import { logBrowserOpen } from '../../../../platform/browserView/common/browserV // Register actions and browser tools import './browserViewActions.js'; +import './browserEditorChatIntegration.js'; import './tools/browserTools.contribution.js'; Registry.as(EditorExtensions.EditorPane).registerEditorPane( diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts index c28b2b634d1..ecd9d6bcd9a 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -11,12 +11,11 @@ import { KeybindingWeight } from '../../../../platform/keybinding/common/keybind import { KeyMod, KeyCode } from '../../../../base/common/keyCodes.js'; import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../services/editor/common/editorService.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { BrowserEditor, CONTEXT_BROWSER_CAN_GO_BACK, CONTEXT_BROWSER_CAN_GO_FORWARD, CONTEXT_BROWSER_CAN_ZOOM_IN, CONTEXT_BROWSER_CAN_ZOOM_OUT, CONTEXT_BROWSER_DEVTOOLS_OPEN, CONTEXT_BROWSER_FOCUSED, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_STORAGE_SCOPE, CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE } from './browserEditor.js'; +import { BrowserEditor, CONTEXT_BROWSER_CAN_GO_BACK, CONTEXT_BROWSER_CAN_GO_FORWARD, CONTEXT_BROWSER_CAN_ZOOM_IN, CONTEXT_BROWSER_CAN_ZOOM_OUT, CONTEXT_BROWSER_DEVTOOLS_OPEN, CONTEXT_BROWSER_FOCUSED, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_STORAGE_SCOPE, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE } from './browserEditor.js'; import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { IBrowserViewWorkbenchService } from '../common/browserView.js'; import { BrowserViewCommandId, BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js'; -import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IPreferencesService } from '../../../services/preferences/common/preferences.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; @@ -261,71 +260,6 @@ class FocusUrlInputAction extends Action2 { } } -class AddElementToChatAction extends Action2 { - static readonly ID = BrowserViewCommandId.AddElementToChat; - - constructor() { - const enabled = ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('config.chat.sendElementsToChat.enabled', true)); - super({ - id: AddElementToChatAction.ID, - title: localize2('browser.addElementToChatAction', 'Add Element to Chat'), - category: BrowserCategory, - icon: Codicon.inspect, - f1: true, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate(), enabled), - toggled: CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, - menu: { - id: MenuId.BrowserActionsToolbar, - group: 'actions', - order: 1, - when: enabled - }, - keybinding: [{ - weight: KeybindingWeight.WorkbenchContrib + 50, // Priority over terminal - primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyC, - }, { - when: CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, - weight: KeybindingWeight.WorkbenchContrib, - primary: KeyCode.Escape - }] - }); - } - - async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { - if (browserEditor instanceof BrowserEditor) { - await browserEditor.addElementToChat(); - } - } -} - -class AddConsoleLogsToChatAction extends Action2 { - static readonly ID = BrowserViewCommandId.AddConsoleLogsToChat; - - constructor() { - const enabled = ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('config.chat.sendElementsToChat.enabled', true)); - super({ - id: AddConsoleLogsToChatAction.ID, - title: localize2('browser.addConsoleLogsToChatAction', 'Add Console Logs to Chat'), - category: BrowserCategory, - icon: Codicon.output, - f1: true, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate(), enabled), - menu: { - id: MenuId.BrowserActionsToolbar, - group: 'actions', - order: 2, - when: enabled - } - }); - } - - async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { - if (browserEditor instanceof BrowserEditor) { - await browserEditor.addConsoleLogsToChat(); - } - } -} - class ToggleDevToolsAction extends Action2 { static readonly ID = BrowserViewCommandId.ToggleDevTools; @@ -727,8 +661,6 @@ registerAction2(GoForwardAction); registerAction2(ReloadAction); registerAction2(HardReloadAction); registerAction2(FocusUrlInputAction); -registerAction2(AddElementToChatAction); -registerAction2(AddConsoleLogsToChatAction); registerAction2(ToggleDevToolsAction); registerAction2(OpenInExternalBrowserAction); registerAction2(ClearGlobalBrowserStorageAction); From 8e5c8886fcab53686d0b6c3915b79efec06a814f Mon Sep 17 00:00:00 2001 From: Paul Date: Wed, 18 Mar 2026 13:07:03 -0700 Subject: [PATCH 14/24] Add hooks execution logging to details panel (#302932) --- .../common/extensionsApiProposals.ts | 2 +- .../api/browser/mainThreadChatDebug.ts | 59 +++++++- .../workbench/api/common/extHost.api.impl.ts | 2 + .../workbench/api/common/extHost.protocol.ts | 14 +- .../workbench/api/common/extHostChatDebug.ts | 19 ++- src/vs/workbench/api/common/extHostTypes.ts | 22 +++ .../chat/browser/actions/chatContext.ts | 35 +---- .../browser/chatDebug/chatDebugDetailPanel.ts | 11 ++ .../chatDebug/chatDebugHookContentRenderer.ts | 135 ++++++++++++++++++ .../contrib/chat/common/chatDebugService.ts | 30 +++- .../tools/builtinTools/listDebugEventsTool.ts | 98 ------------- .../resolveDebugEventDetailsTool.ts | 77 +++++++--- .../chat/common/tools/builtinTools/tools.ts | 11 -- src/vscode-dts/vscode.proposed.chatDebug.d.ts | 68 ++++++++- 14 files changed, 412 insertions(+), 171 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHookContentRenderer.ts delete mode 100644 src/vs/workbench/contrib/chat/common/tools/builtinTools/listDebugEventsTool.ts diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index d62aa30b912..2088c731643 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -48,7 +48,7 @@ const _allApiProposals = { }, chatDebug: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatDebug.d.ts', - version: 3 + version: 4 }, chatHooks: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatHooks.d.ts', diff --git a/src/vs/workbench/api/browser/mainThreadChatDebug.ts b/src/vs/workbench/api/browser/mainThreadChatDebug.ts index 8daa3270c4a..c28ac56be4d 100644 --- a/src/vs/workbench/api/browser/mainThreadChatDebug.ts +++ b/src/vs/workbench/api/browser/mainThreadChatDebug.ts @@ -6,10 +6,10 @@ import { Disposable, DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { ChatDebugLogLevel, IChatDebugEvent, IChatDebugService } from '../../contrib/chat/common/chatDebugService.js'; +import { ChatDebugHookResult, ChatDebugLogLevel, IChatDebugEvent, IChatDebugResolvedEventContent, IChatDebugService } from '../../contrib/chat/common/chatDebugService.js'; import { IChatService } from '../../contrib/chat/common/chatService/chatService.js'; import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; -import { ExtHostChatDebugShape, ExtHostContext, IChatDebugEventDto, MainContext, MainThreadChatDebugShape } from '../common/extHost.protocol.js'; +import { ExtHostChatDebugShape, ExtHostContext, IChatDebugEventDto, IChatDebugResolvedEventContentDto, MainContext, MainThreadChatDebugShape } from '../common/extHost.protocol.js'; import { Proxied } from '../../services/extensions/common/proxyIdentifier.js'; @extHostNamedCustomer(MainContext.MainThreadChatDebug) @@ -51,7 +51,8 @@ export class MainThreadChatDebug extends Disposable implements MainThreadChatDeb return dtos?.map(dto => this._reviveEvent(dto, sessionResource)); }, resolveChatDebugLogEvent: async (eventId, token) => { - return this._proxy.$resolveChatDebugLogEvent(handle, eventId, token); + const dto = await this._proxy.$resolveChatDebugLogEvent(handle, eventId, token); + return dto ? this._reviveResolvedContent(dto) : undefined; }, provideChatDebugLogExport: async (sessionResource, token) => { // Gather core events and session title to pass to the extension. @@ -185,4 +186,56 @@ export class MainThreadChatDebug extends Disposable implements MainThreadChatDeb }; } } + + private _reviveResolvedContent(dto: IChatDebugResolvedEventContentDto): IChatDebugResolvedEventContent { + switch (dto.kind) { + case 'text': + return { kind: 'text', value: dto.value }; + case 'message': + return { + kind: 'message', + type: dto.type, + message: dto.message, + sections: dto.sections, + }; + case 'toolCall': + return { + kind: 'toolCall', + toolName: dto.toolName, + result: dto.result, + durationInMillis: dto.durationInMillis, + input: dto.input, + output: dto.output, + }; + case 'modelTurn': + return { + kind: 'modelTurn', + requestName: dto.requestName, + model: dto.model, + status: dto.status, + durationInMillis: dto.durationInMillis, + inputTokens: dto.inputTokens, + outputTokens: dto.outputTokens, + cachedTokens: dto.cachedTokens, + totalTokens: dto.totalTokens, + errorMessage: dto.errorMessage, + sections: dto.sections, + }; + case 'hook': + return { + kind: 'hook', + hookType: dto.hookType, + command: dto.command, + result: dto.result === 'success' ? ChatDebugHookResult.Success + : dto.result === 'error' ? ChatDebugHookResult.Error + : dto.result === 'nonBlockingError' ? ChatDebugHookResult.NonBlockingError + : undefined, + durationInMillis: dto.durationInMillis, + input: dto.input, + output: dto.output, + exitCode: dto.exitCode, + errorMessage: dto.errorMessage, + }; + } + } } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 2d2d7f8fce7..e42e0f30b2c 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -2127,6 +2127,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I ChatSessionStatus: extHostTypes.ChatSessionStatus, ChatDebugLogLevel: extHostTypes.ChatDebugLogLevel, ChatDebugToolCallResult: extHostTypes.ChatDebugToolCallResult, + ChatDebugHookResult: extHostTypes.ChatDebugHookResult, ChatDebugToolCallEvent: extHostTypes.ChatDebugToolCallEvent, ChatDebugModelTurnEvent: extHostTypes.ChatDebugModelTurnEvent, ChatDebugGenericEvent: extHostTypes.ChatDebugGenericEvent, @@ -2139,6 +2140,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I ChatDebugEventMessageContent: extHostTypes.ChatDebugEventMessageContent, ChatDebugEventToolCallContent: extHostTypes.ChatDebugEventToolCallContent, ChatDebugEventModelTurnContent: extHostTypes.ChatDebugEventModelTurnContent, + ChatDebugEventHookContent: extHostTypes.ChatDebugEventHookContent, ChatRequestEditorData: extHostTypes.ChatRequestEditorData, ChatRequestNotebookData: extHostTypes.ChatRequestNotebookData, ChatReferenceBinaryData: extHostTypes.ChatReferenceBinaryData, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index ca2e16b8a65..96d734e6d0f 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1523,7 +1523,19 @@ export interface IChatDebugEventModelTurnContentDto { readonly sections?: readonly IChatDebugMessageSectionDto[]; } -export type IChatDebugResolvedEventContentDto = IChatDebugEventTextContentDto | IChatDebugEventMessageContentDto | IChatDebugEventToolCallContentDto | IChatDebugEventModelTurnContentDto; +export interface IChatDebugEventHookContentDto { + readonly kind: 'hook'; + readonly hookType: string; + readonly command?: string; + readonly result?: 'success' | 'error' | 'nonBlockingError'; + readonly durationInMillis?: number; + readonly input?: string; + readonly output?: string; + readonly exitCode?: number; + readonly errorMessage?: string; +} + +export type IChatDebugResolvedEventContentDto = IChatDebugEventTextContentDto | IChatDebugEventMessageContentDto | IChatDebugEventToolCallContentDto | IChatDebugEventModelTurnContentDto | IChatDebugEventHookContentDto; export interface ExtHostChatDebugShape { $provideChatDebugLog(handle: number, sessionResource: UriComponents, token: CancellationToken): Promise; diff --git a/src/vs/workbench/api/common/extHostChatDebug.ts b/src/vs/workbench/api/common/extHostChatDebug.ts index 44cccc60a61..8e84cccaf35 100644 --- a/src/vs/workbench/api/common/extHostChatDebug.ts +++ b/src/vs/workbench/api/common/extHostChatDebug.ts @@ -10,7 +10,7 @@ import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; import { ExtHostChatDebugShape, IChatDebugEventDto, IChatDebugResolvedEventContentDto, MainContext, MainThreadChatDebugShape } from './extHost.protocol.js'; -import { ChatDebugGenericEvent, ChatDebugLogLevel, ChatDebugMessageContentType, ChatDebugMessageSection, ChatDebugModelTurnEvent, ChatDebugSubagentInvocationEvent, ChatDebugSubagentStatus, ChatDebugToolCallEvent, ChatDebugToolCallResult, ChatDebugUserMessageEvent, ChatDebugAgentResponseEvent } from './extHostTypes.js'; +import { ChatDebugGenericEvent, ChatDebugHookResult, ChatDebugLogLevel, ChatDebugMessageContentType, ChatDebugMessageSection, ChatDebugModelTurnEvent, ChatDebugSubagentInvocationEvent, ChatDebugSubagentStatus, ChatDebugToolCallEvent, ChatDebugToolCallResult, ChatDebugUserMessageEvent, ChatDebugAgentResponseEvent, ChatDebugEventHookContent } from './extHostTypes.js'; import { IExtHostRpcService } from './extHostRpcService.js'; export class ExtHostChatDebug extends Disposable implements ExtHostChatDebugShape { @@ -293,6 +293,23 @@ export class ExtHostChatDebug extends Disposable implements ExtHostChatDebugShap sections: mt.sections?.map(s => ({ name: s.name, content: s.content })), }; } + case 'hookContent': { + const hk = result as unknown as ChatDebugEventHookContent; + return { + kind: 'hook', + hookType: hk.hookType, + command: hk.command, + result: hk.result === ChatDebugHookResult.Success ? 'success' + : hk.result === ChatDebugHookResult.Error ? 'error' + : hk.result === ChatDebugHookResult.NonBlockingError ? 'nonBlockingError' + : undefined, + durationInMillis: hk.durationInMillis, + input: hk.input, + output: hk.output, + exitCode: hk.exitCode, + errorMessage: hk.errorMessage, + }; + } default: return undefined; } diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index e5b07617802..25860457deb 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3579,6 +3579,12 @@ export enum ChatDebugToolCallResult { Error = 1 } +export enum ChatDebugHookResult { + Success = 0, + Error = 1, + NonBlockingError = 2 +} + export class ChatDebugToolCallEvent { readonly _kind = 'toolCall'; id?: string; @@ -3756,6 +3762,22 @@ export class ChatDebugEventModelTurnContent { } } +export class ChatDebugEventHookContent { + readonly _kind = 'hookContent'; + hookType: string; + command?: string; + result?: ChatDebugHookResult; + durationInMillis?: number; + input?: string; + output?: string; + exitCode?: number; + errorMessage?: string; + + constructor(hookType: string) { + this.hookType = hookType; + } +} + export class ChatSessionChangedFile { constructor(public readonly modifiedUri: vscode.Uri, public readonly insertions: number, public readonly deletions: number, public readonly originalUri?: vscode.Uri) { } } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts index e4aeffebcaf..d3cf66b0f03 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts @@ -21,19 +21,16 @@ import { FileEditorInput } from '../../../files/browser/editors/fileEditorInput. import { NotebookEditorInput } from '../../../notebook/common/notebookEditorInput.js'; import { IChatContextPickService, IChatContextValueItem, IChatContextPickerItem, IChatContextPickerPickItem, IChatContextPicker } from '../attachments/chatContextPickService.js'; import { IChatRequestToolEntry, IChatRequestToolSetEntry, IChatRequestVariableEntry, IImageVariableEntry, toToolSetVariableEntry, toToolVariableEntry } from '../../common/attachments/chatVariableEntries.js'; -import { ILanguageModelToolsService, isToolSet, ToolDataSource } from '../../common/tools/languageModelToolsService.js'; -import { IChatWidget, IChatWidgetService } from '../chat.js'; +import { isToolSet, ToolDataSource } from '../../common/tools/languageModelToolsService.js'; +import { IChatWidget } from '../chat.js'; import { imageToHash, isImage } from '../widget/input/editor/chatPasteProviders.js'; import { convertBufferToScreenshotVariable } from '../attachments/chatScreenshotContext.js'; import { ChatInstructionsPickerPick } from '../promptSyntax/attachInstructionsAction.js'; import { createDebugEventsAttachment } from '../chatDebug/chatDebugAttachment.js'; import { IChatDebugService } from '../../common/chatDebugService.js'; -import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { ITerminalService } from '../../../terminal/browser/terminal.js'; import { URI } from '../../../../../base/common/uri.js'; import { ITerminalCommand, TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; -import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; /** * Command ID that extensions can call to enable debug tools for the current @@ -49,37 +46,9 @@ export class ChatContextContributions extends Disposable implements IWorkbenchCo constructor( @IInstantiationService instantiationService: IInstantiationService, @IChatContextPickService contextPickService: IChatContextPickService, - @IChatDebugService chatDebugService: IChatDebugService, - @IContextKeyService contextKeyService: IContextKeyService, - @ILanguageModelToolsService languageModelToolsService: ILanguageModelToolsService, - @IChatWidgetService chatWidgetService: IChatWidgetService, ) { super(); - // Bind at the global context key service level so the tools service can evaluate it. - // Widget-scoped keys are not reliably visible to singleton services during async request processing. - const hasDebugToolsKey = ChatContextKeys.chatSessionHasDebugTools.bindTo(contextKeyService); - this._store.add(chatWidgetService.onDidChangeFocusedSession(() => { - const sessionResource = chatWidgetService.lastFocusedWidget?.viewModel?.sessionResource; - hasDebugToolsKey.set(!!sessionResource && chatDebugService.hasAttachedDebugData(sessionResource)); - languageModelToolsService.flushToolUpdates(); - })); - this._store.add(chatDebugService.onDidAttachDebugData(sessionResource => { - const focusedSession = chatWidgetService.lastFocusedWidget?.viewModel?.sessionResource; - if (focusedSession && focusedSession.toString() === sessionResource.toString()) { - hasDebugToolsKey.set(true); - languageModelToolsService.flushToolUpdates(); - } - })); - - // Register a command that extensions can call to enable debug tools - // for the current session. This sets the context key AND flushes the - // tools service synchronously so the change is visible immediately. - this._store.add(CommandsRegistry.registerCommand(EnableChatDebugToolsCommandId, () => { - hasDebugToolsKey.set(true); - languageModelToolsService.flushToolUpdates(); - })); - // ############################################################################################### // // Default context picks/values which are "native" to chat. This is NOT the complete list diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugDetailPanel.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugDetailPanel.ts index 2548e901e7f..cfdcc601178 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugDetailPanel.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugDetailPanel.ts @@ -27,6 +27,7 @@ import { renderCustomizationDiscoveryContent, fileListToPlainText } from './chat import { renderUserMessageContent, renderAgentResponseContent, messageEventToPlainText, renderResolvedMessageContent, resolvedMessageToPlainText } from './chatDebugMessageContentRenderer.js'; import { renderToolCallContent, toolCallContentToPlainText } from './chatDebugToolCallContentRenderer.js'; import { renderModelTurnContent, modelTurnContentToPlainText } from './chatDebugModelTurnContentRenderer.js'; +import { renderHookContent, hookContentToPlainText } from './chatDebugHookContentRenderer.js'; const $ = DOM.$; @@ -205,6 +206,16 @@ export class ChatDebugDetailPanel extends Disposable { } this.detailDisposables.add(contentDisposables); this.contentContainer.appendChild(contentEl); + } else if (resolved && resolved.kind === 'hook') { + this.currentDetailText = hookContentToPlainText(resolved); + const { element: contentEl, disposables: contentDisposables } = await renderHookContent(resolved, this.languageService, this.clipboardService, this.scrollable); + if (this.currentDetailEventId !== event.id) { + // Another event was selected while we were rendering + contentDisposables.dispose(); + return; + } + this.detailDisposables.add(contentDisposables); + this.contentContainer.appendChild(contentEl); } else if (event.kind === 'userMessage') { this.currentDetailText = messageEventToPlainText(event); const { element: contentEl, disposables: contentDisposables } = await renderUserMessageContent(event, this.languageService, this.clipboardService, this.scrollable); diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHookContentRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHookContentRenderer.ts new file mode 100644 index 00000000000..1bdc7b22b6d --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHookContentRenderer.ts @@ -0,0 +1,135 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { ChatDebugHookResult, IChatDebugEventHookContent } from '../../common/chatDebugService.js'; +import { renderSection, tokenizeContent } from './chatDebugToolCallContentRenderer.js'; + +const $ = DOM.$; + +/** + * Render a resolved hook execution content with structured sections for + * hook type, command, result, duration, input, and output. + * When JSON is detected in input/output, renders it with syntax highlighting. + */ +export async function renderHookContent(content: IChatDebugEventHookContent, languageService: ILanguageService, clipboardService?: IClipboardService, scrollable?: { scanDomNode(): void }): Promise<{ element: HTMLElement; disposables: DisposableStore }> { + const disposables = new DisposableStore(); + const container = $('div.chat-debug-message-content'); + container.tabIndex = 0; + + // Header: hook type + DOM.append(container, $('div.chat-debug-message-content-title', undefined, content.hookType)); + + // Status summary line + const statusParts: string[] = []; + if (content.result !== undefined) { + statusParts.push(formatHookResult(content.result)); + } + if (content.exitCode !== undefined) { + statusParts.push(localize('chatDebug.hook.exitCode', "Exit Code: {0}", content.exitCode)); + } + if (content.durationInMillis !== undefined) { + statusParts.push(localize('chatDebug.hook.duration', "{0}ms", content.durationInMillis)); + } + if (statusParts.length > 0) { + DOM.append(container, $('div.chat-debug-message-content-summary', undefined, statusParts.join(' \u00b7 '))); + } + + // Build collapsible sections for command, input, output, and error + const sectionsContainer = DOM.append(container, $('div.chat-debug-message-sections')); + + if (content.command) { + const { plainText, tokenizedHtml } = await tokenizeContent(content.command, languageService); + renderSection(sectionsContainer, localize('chatDebug.hook.command', "Command"), plainText, tokenizedHtml, disposables, false, clipboardService, scrollable); + } + + if (content.input) { + const { plainText, tokenizedHtml } = await tokenizeContent(content.input, languageService); + renderSection(sectionsContainer, localize('chatDebug.hook.input', "Input"), plainText, tokenizedHtml, disposables, false, clipboardService, scrollable); + } + + if (content.output) { + const { plainText, tokenizedHtml } = await tokenizeContent(content.output, languageService); + renderSection(sectionsContainer, localize('chatDebug.hook.output', "Output"), plainText, tokenizedHtml, disposables, false, clipboardService, scrollable); + } + + if (content.errorMessage) { + const { plainText, tokenizedHtml } = await tokenizeContent(content.errorMessage, languageService); + renderSection(sectionsContainer, localize('chatDebug.hook.error', "Error"), plainText, tokenizedHtml, disposables, false, clipboardService, scrollable); + } + + return { element: container, disposables }; +} + +function formatHookResult(result: ChatDebugHookResult): string { + switch (result) { + case ChatDebugHookResult.Success: + return localize('chatDebug.hook.result.success', "Success"); + case ChatDebugHookResult.Error: + return localize('chatDebug.hook.result.error', "Error"); + case ChatDebugHookResult.NonBlockingError: + return localize('chatDebug.hook.result.nonBlockingError', "Non-blocking Error"); + default: + return String(result); + } +} + +/** + * Convert a resolved hook content to plain text for clipboard / editor output. + */ +export function hookContentToPlainText(content: IChatDebugEventHookContent): string { + const lines: string[] = []; + lines.push(localize('chatDebug.hook.typeLabel', "Hook Type: {0}", content.hookType)); + + if (content.result !== undefined) { + lines.push(localize('chatDebug.hook.resultLabel', "Result: {0}", formatHookResult(content.result))); + } + if (content.exitCode !== undefined) { + lines.push(localize('chatDebug.hook.exitCodeLabel', "Exit Code: {0}", content.exitCode)); + } + if (content.durationInMillis !== undefined) { + lines.push(localize('chatDebug.hook.durationLabel', "Duration: {0}ms", content.durationInMillis)); + } + + if (content.command) { + lines.push(''); + lines.push(`[${localize('chatDebug.hook.command', "Command")}]`); + lines.push(content.command); + } + + if (content.input) { + lines.push(''); + lines.push(`[${localize('chatDebug.hook.input', "Input")}]`); + try { + const parsed = JSON.parse(content.input); + lines.push(JSON.stringify(parsed, null, 2)); + } catch { + lines.push(content.input); + } + } + + if (content.output) { + lines.push(''); + lines.push(`[${localize('chatDebug.hook.output', "Output")}]`); + try { + const parsed = JSON.parse(content.output); + lines.push(JSON.stringify(parsed, null, 2)); + } catch { + lines.push(content.output); + } + } + + if (content.errorMessage) { + lines.push(''); + lines.push(`[${localize('chatDebug.hook.error', "Error")}]`); + lines.push(content.errorMessage); + } + + return lines.join('\n'); +} diff --git a/src/vs/workbench/contrib/chat/common/chatDebugService.ts b/src/vs/workbench/contrib/chat/common/chatDebugService.ts index d97213d3f3e..e54c6abc168 100644 --- a/src/vs/workbench/contrib/chat/common/chatDebugService.ts +++ b/src/vs/workbench/contrib/chat/common/chatDebugService.ts @@ -19,6 +19,18 @@ export enum ChatDebugLogLevel { Error = 3 } +/** + * The result of a hook execution. + */ +export enum ChatDebugHookResult { + /** The hook executed successfully (exit code 0). */ + Success = 0, + /** The hook returned a blocking error (exit code 2). */ + Error = 1, + /** The hook returned a non-blocking warning (other non-zero exit codes). */ + NonBlockingError = 2 +} + /** * Common properties shared by all chat debug event types. */ @@ -331,10 +343,26 @@ export interface IChatDebugEventModelTurnContent { readonly sections?: readonly IChatDebugMessageSection[]; } +/** + * Structured hook execution content for a resolved debug event. + * Contains the hook type, command, input, output, and result for rich rendering. + */ +export interface IChatDebugEventHookContent { + readonly kind: 'hook'; + readonly hookType: string; + readonly command?: string; + readonly result?: ChatDebugHookResult; + readonly durationInMillis?: number; + readonly input?: string; + readonly output?: string; + readonly exitCode?: number; + readonly errorMessage?: string; +} + /** * Union of all resolved event content types. */ -export type IChatDebugResolvedEventContent = IChatDebugEventTextContent | IChatDebugEventFileListContent | IChatDebugEventMessageContent | IChatDebugEventToolCallContent | IChatDebugEventModelTurnContent; +export type IChatDebugResolvedEventContent = IChatDebugEventTextContent | IChatDebugEventFileListContent | IChatDebugEventMessageContent | IChatDebugEventToolCallContent | IChatDebugEventModelTurnContent | IChatDebugEventHookContent; /** * Provider interface for debug events. diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/listDebugEventsTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/listDebugEventsTool.ts deleted file mode 100644 index 37baf2839d4..00000000000 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/listDebugEventsTool.ts +++ /dev/null @@ -1,98 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CancellationToken } from '../../../../../../base/common/cancellation.js'; -import { localize } from '../../../../../../nls.js'; -import { ChatContextKeys } from '../../actions/chatContextKeys.js'; -import { IChatDebugEvent, IChatDebugService } from '../../chatDebugService.js'; -import { formatDebugEventsForContext, debugEventKindDescriptions, filterDebugEvents, debugEventFilterDescription } from '../../chatDebugEvents.js'; -import { CountTokensCallback, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolProgress } from '../languageModelToolsService.js'; - -export const ListDebugEventsToolId = 'vscode_listDebugEvents_internal'; - -export const ListDebugEventsToolData: IToolData = { - id: ListDebugEventsToolId, - toolReferenceName: 'listDebugEvents', - displayName: localize('listDebugEvents.displayName', "List Debug Events"), - when: ChatContextKeys.chatSessionHasDebugTools, - canBeReferencedInPrompt: false, - modelDescription: 'Lists debug event summaries for the current chat session. Returns a compact log of events including timestamps, event IDs, and brief descriptions. Use this tool FIRST to get an overview of what happened, then call resolveDebugEventDetails on specific event IDs to get full details.\n\n' - + 'Event types:\n' - + Object.values(debugEventKindDescriptions).join('\n'), - source: ToolDataSource.Internal, - inputSchema: { - type: 'object', - properties: { - kind: { - type: 'string', - description: 'Filter by event kind: ' + Object.keys(debugEventKindDescriptions).join(', ') + '.', - }, - filter: { - type: 'string', - description: debugEventFilterDescription, - }, - limit: { - type: 'number', - description: 'Return only the N most recent matching events.', - }, - }, - }, -}; - -export class ListDebugEventsTool implements IToolImpl { - constructor( - @IChatDebugService private readonly chatDebugService: IChatDebugService, - ) { } - - async prepareToolInvocation(_context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { - return { - invocationMessage: localize('listDebugEvents.invocationMessage', 'Listing debug events'), - pastTenseMessage: localize('listDebugEvents.pastTenseMessage', 'Listed debug events'), - }; - } - - async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { - const sessionResource = invocation.context?.sessionResource; - if (!sessionResource) { - return { - content: [{ kind: 'text', value: 'Error: no chat session context available.' }], - }; - } - - // Ensure providers have been invoked so we have all events - if (!this.chatDebugService.hasInvokedProviders(sessionResource)) { - await this.chatDebugService.invokeProviders(sessionResource); - } - - let events: readonly IChatDebugEvent[] = this.chatDebugService.getEvents(sessionResource); - if (events.length === 0) { - return { - content: [{ kind: 'text', value: 'No debug events found for this conversation.' }], - }; - } - - events = filterDebugEvents(events, { - kind: typeof invocation.parameters['kind'] === 'string' ? invocation.parameters['kind'] : undefined, - filter: typeof invocation.parameters['filter'] === 'string' ? invocation.parameters['filter'].toLowerCase() : undefined, - limit: typeof invocation.parameters['limit'] === 'number' ? invocation.parameters['limit'] : undefined, - }); - - if (events.length === 0) { - return { - content: [{ kind: 'text', value: 'No debug events matched the filter criteria.' }], - }; - } - - const summary = formatDebugEventsForContext(events); - return { - content: [{ - kind: 'text', - value: 'Debug event log for this conversation. Each line is a summary — call resolveDebugEventDetails with the event ID (shown as [id=...]) to get full details.\n\n' - + 'IMPORTANT: Do NOT mention event IDs or tool resolution steps in your response to the user.\n\n' - + summary, - }], - }; - } -} diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/resolveDebugEventDetailsTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/resolveDebugEventDetailsTool.ts index 24bfed2d557..6fda3019827 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/resolveDebugEventDetailsTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/resolveDebugEventDetailsTool.ts @@ -6,7 +6,7 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { localize } from '../../../../../../nls.js'; import { ChatContextKeys } from '../../actions/chatContextKeys.js'; -import { IChatDebugEvent, IChatDebugResolvedEventContent, IChatDebugService } from '../../chatDebugService.js'; +import { ChatDebugHookResult, IChatDebugEvent, IChatDebugResolvedEventContent, IChatDebugService } from '../../chatDebugService.js'; import { CountTokensCallback, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolProgress } from '../languageModelToolsService.js'; export const ResolveDebugEventDetailsToolId = 'vscode_resolveDebugEventDetails_internal'; @@ -36,20 +36,27 @@ function formatResolvedContent(content: IChatDebugResolvedEventContent): string case 'text': return content.value; case 'fileList': { - const lines: string[] = [`File list (${content.discoveryType}):`]; + const lines: string[] = [localize('formatResolvedContent.fileList', "File list ({0}):", content.discoveryType)]; if (content.sourceFolders) { for (const folder of content.sourceFolders) { - lines.push(` Source folder: ${folder.uri.toString()} (${folder.storage})`); + lines.push(localize('formatResolvedContent.sourceFolder', " Source folder: {0} ({1})", folder.uri.toString(), folder.storage)); } } for (const file of content.files) { - const status = file.status === 'loaded' ? 'loaded' : `skipped${file.skipReason ? `: ${file.skipReason}` : ''}`; + const status = file.status === 'loaded' + ? localize('formatResolvedContent.loaded', "loaded") + : file.skipReason + ? localize('formatResolvedContent.skippedWithReason', "skipped: {0}", file.skipReason) + : localize('formatResolvedContent.skipped', "skipped"); lines.push(` ${file.uri.toString()} [${status}]`); } return lines.join('\n'); } case 'message': { - const lines: string[] = [`${content.type === 'user' ? 'User' : 'Agent'} message: ${content.message}`]; + const messageType = content.type === 'user' + ? localize('formatResolvedContent.userMessage', "User message: {0}", content.message) + : localize('formatResolvedContent.agentMessage', "Agent message: {0}", content.message); + const lines: string[] = [messageType]; for (const section of content.sections) { lines.push(`--- ${section.name} ---`); lines.push(section.content); @@ -57,37 +64,37 @@ function formatResolvedContent(content: IChatDebugResolvedEventContent): string return lines.join('\n'); } case 'toolCall': { - const lines: string[] = [`Tool call: ${content.toolName}`]; + const lines: string[] = [localize('formatResolvedContent.toolCall', "Tool call: {0}", content.toolName)]; if (content.result) { - lines.push(`Result: ${content.result}`); + lines.push(localize('formatResolvedContent.result', "Result: {0}", content.result)); } if (content.durationInMillis !== undefined) { - lines.push(`Duration: ${content.durationInMillis}ms`); + lines.push(localize('formatResolvedContent.duration', "Duration: {0}ms", content.durationInMillis)); } if (content.input) { - lines.push(`Input:\n${content.input}`); + lines.push(localize('formatResolvedContent.input', "Input:") + '\n' + content.input); } if (content.output) { - lines.push(`Output:\n${content.output}`); + lines.push(localize('formatResolvedContent.output', "Output:") + '\n' + content.output); } return lines.join('\n'); } case 'modelTurn': { - const lines: string[] = [`Model turn: ${content.requestName}`]; + const lines: string[] = [localize('formatResolvedContent.modelTurn', "Model turn: {0}", content.requestName)]; if (content.model) { - lines.push(`Model: ${content.model}`); + lines.push(localize('formatResolvedContent.model', "Model: {0}", content.model)); } if (content.status) { - lines.push(`Status: ${content.status}`); + lines.push(localize('formatResolvedContent.status', "Status: {0}", content.status)); } if (content.durationInMillis !== undefined) { - lines.push(`Duration: ${content.durationInMillis}ms`); + lines.push(localize('formatResolvedContent.duration', "Duration: {0}ms", content.durationInMillis)); } if (content.inputTokens !== undefined || content.outputTokens !== undefined) { - lines.push(`Tokens: input=${content.inputTokens ?? '?'}, output=${content.outputTokens ?? '?'}, cached=${content.cachedTokens ?? '?'}, total=${content.totalTokens ?? '?'}`); + lines.push(localize('formatResolvedContent.tokens', "Tokens: input={0}, output={1}, cached={2}, total={3}", content.inputTokens ?? '?', content.outputTokens ?? '?', content.cachedTokens ?? '?', content.totalTokens ?? '?')); } if (content.errorMessage) { - lines.push(`Error: ${content.errorMessage}`); + lines.push(localize('formatResolvedContent.error', "Error: {0}", content.errorMessage)); } if (content.sections) { for (const section of content.sections) { @@ -97,6 +104,36 @@ function formatResolvedContent(content: IChatDebugResolvedEventContent): string } return lines.join('\n'); } + case 'hook': { + const lines: string[] = [localize('formatResolvedContent.hook', "Hook: {0}", content.hookType)]; + if (content.command) { + lines.push(localize('formatResolvedContent.command', "Command: {0}", content.command)); + } + if (content.result !== undefined) { + const resultText = content.result === ChatDebugHookResult.Success + ? localize('formatResolvedContent.hookResult.success', "Success") + : content.result === ChatDebugHookResult.Error + ? localize('formatResolvedContent.hookResult.error', "Error") + : localize('formatResolvedContent.hookResult.nonBlockingError', "Non-blocking Error"); + lines.push(localize('formatResolvedContent.result', "Result: {0}", resultText)); + } + if (content.exitCode !== undefined) { + lines.push(localize('formatResolvedContent.exitCode', "Exit Code: {0}", content.exitCode)); + } + if (content.durationInMillis !== undefined) { + lines.push(localize('formatResolvedContent.duration', "Duration: {0}ms", content.durationInMillis)); + } + if (content.input) { + lines.push(localize('formatResolvedContent.input', "Input:") + '\n' + content.input); + } + if (content.output) { + lines.push(localize('formatResolvedContent.output', "Output:") + '\n' + content.output); + } + if (content.errorMessage) { + lines.push(localize('formatResolvedContent.error', "Error: {0}", content.errorMessage)); + } + return lines.join('\n'); + } default: { const _: never = content; return JSON.stringify(_); @@ -156,28 +193,28 @@ export class ResolveDebugEventDetailsTool implements IToolImpl { const eventId = invocation.parameters['eventId']; if (typeof eventId !== 'string' || !eventId) { return { - content: [{ kind: 'text', value: 'Error: eventId parameter is required.' }], + content: [{ kind: 'text', value: localize('resolveDebugEventDetails.errorEventIdRequired', "Error: eventId parameter is required.") }], }; } const sessionResource = invocation.context?.sessionResource; if (!sessionResource) { return { - content: [{ kind: 'text', value: 'Error: no chat session context available.' }], + content: [{ kind: 'text', value: localize('resolveDebugEventDetails.errorNoSession', "Error: no chat session context available.") }], }; } const sessionEvents = this.chatDebugService.getEvents(sessionResource); if (!sessionEvents.some(e => e.id === eventId)) { return { - content: [{ kind: 'text', value: `No event with ID "${eventId}" found in the current session.` }], + content: [{ kind: 'text', value: localize('resolveDebugEventDetails.errorEventNotFound', "No event with ID \"{0}\" found in the current session.", eventId) }], }; } const resolved = await this.chatDebugService.resolveEvent(eventId); if (!resolved) { return { - content: [{ kind: 'text', value: `No details found for event ID: ${eventId}` }], + content: [{ kind: 'text', value: localize('resolveDebugEventDetails.errorNoDetails', "No details found for event ID: {0}", eventId) }], }; } diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts index e9e106f9c38..ce3c0a9b8df 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts @@ -13,8 +13,6 @@ import { AskQuestionsTool, AskQuestionsToolData } from './askQuestionsTool.js'; import { ConfirmationTool, ConfirmationToolData, ConfirmationToolWithOptionsData, ModifiedFilesConfirmationTool, ModifiedFilesConfirmationToolData } from './confirmationTool.js'; import { EditTool, EditToolData } from './editFileTool.js'; import { createManageTodoListToolData, ManageTodoListTool } from './manageTodoListTool.js'; -import { ResolveDebugEventDetailsTool, ResolveDebugEventDetailsToolData } from './resolveDebugEventDetailsTool.js'; -import { ListDebugEventsTool, ListDebugEventsToolData } from './listDebugEventsTool.js'; import { RunSubagentTool } from './runSubagentTool.js'; import { SetArtifactsTool, SetArtifactsToolData } from './setArtifactsTool.js'; import { TaskCompleteTool, TaskCompleteToolData } from './taskCompleteTool.js'; @@ -70,15 +68,6 @@ export class BuiltinToolsContribution extends Disposable implements IWorkbenchCo } })); - const resolveDebugEventDetailsTool = instantiationService.createInstance(ResolveDebugEventDetailsTool); - this._register(toolsService.registerTool(ResolveDebugEventDetailsToolData, resolveDebugEventDetailsTool)); - this._register(toolsService.readToolSet.addTool(ResolveDebugEventDetailsToolData)); - - const listDebugEventsTool = instantiationService.createInstance(ListDebugEventsTool); - this._register(toolsService.registerTool(ListDebugEventsToolData, listDebugEventsTool)); - this._register(toolsService.readToolSet.addTool(ListDebugEventsToolData)); - - const runSubagentTool = this._register(instantiationService.createInstance(RunSubagentTool)); let runSubagentRegistration: IDisposable | undefined; diff --git a/src/vscode-dts/vscode.proposed.chatDebug.d.ts b/src/vscode-dts/vscode.proposed.chatDebug.d.ts index ed91aa806a1..06729600819 100644 --- a/src/vscode-dts/vscode.proposed.chatDebug.d.ts +++ b/src/vscode-dts/vscode.proposed.chatDebug.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 3 +// version: 4 declare module 'vscode' { /** @@ -595,13 +595,77 @@ declare module 'vscode' { constructor(requestName: string); } + /** + * Structured hook execution content for a resolved chat debug event, + * containing the hook type, command, input, output, and result for rich rendering. + */ + export class ChatDebugEventHookContent { + /** + * The type of hook that was executed (e.g., "PreToolUse", "PostToolUse", "Stop"). + */ + hookType: string; + + /** + * The shell command that was executed. + */ + command?: string; + + /** + * The outcome of the hook execution. + */ + result?: ChatDebugHookResult; + + /** + * How long the hook took to complete, in milliseconds. + */ + durationInMillis?: number; + + /** + * The serialized JSON input passed to the hook via stdin. + */ + input?: string; + + /** + * The serialized output (stdout/stderr) returned by the hook. + */ + output?: string; + + /** + * An error message, if the hook failed. + */ + errorMessage?: string; + + /** + * The raw exit code from the hook process, if it failed. + */ + exitCode?: number; + + /** + * Create a new ChatDebugEventHookContent. + * @param hookType The type of hook that was executed. + */ + constructor(hookType: string); + } + + /** + * The result of a hook execution. + */ + export enum ChatDebugHookResult { + /** The hook executed successfully (exit code 0). */ + Success = 0, + /** The hook returned a blocking error (exit code 2). */ + Error = 1, + /** The hook returned a non-blocking warning (other non-zero exit codes). */ + NonBlockingError = 2 + } + /** * Union of all resolved event content types. * Extensions may also return {@link ChatDebugUserMessageEvent} or * {@link ChatDebugAgentResponseEvent} from resolve, which will be * automatically converted to structured message content. */ - export type ChatDebugResolvedEventContent = ChatDebugEventTextContent | ChatDebugEventMessageContent | ChatDebugEventToolCallContent | ChatDebugEventModelTurnContent | ChatDebugUserMessageEvent | ChatDebugAgentResponseEvent; + export type ChatDebugResolvedEventContent = ChatDebugEventTextContent | ChatDebugEventMessageContent | ChatDebugEventToolCallContent | ChatDebugEventModelTurnContent | ChatDebugEventHookContent | ChatDebugUserMessageEvent | ChatDebugAgentResponseEvent; /** * Union of all chat debug event types. Each type is a class, From 60d6da96141f5dbca86536529780ca648166aedb Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 18 Mar 2026 13:09:11 -0700 Subject: [PATCH 15/24] agentHost: upstream reducer logic from AHP Goes with https://github.com/microsoft/agent-host-protocol/pull/11 --- scripts/sync-agent-host-protocol.ts | 101 +--- .../platform/agentHost/common/agentService.ts | 7 +- .../state/protocol/action-origin.generated.ts | 114 ++++ .../common/state/protocol/actions.ts | 113 ++-- .../common/state/protocol/commands.ts | 24 +- .../agentHost/common/state/protocol/errors.ts | 2 +- .../common/state/protocol/messages.ts | 2 +- .../common/state/protocol/notifications.ts | 15 +- .../common/state/protocol/reducers.ts | 491 ++++++++++++++++++ .../agentHost/common/state/protocol/state.ts | 149 +++--- .../common/state/protocol/version/registry.ts | 62 ++- .../agentHost/common/state/sessionActions.ts | 40 +- .../agentHost/common/state/sessionReducers.ts | 454 +--------------- .../agentHost/common/state/sessionState.ts | 30 +- .../agentHost/node/agentEventMapper.ts | 51 +- .../platform/agentHost/node/agentService.ts | 4 +- .../agentHost/node/agentSideEffects.ts | 18 +- .../agentHost/node/copilot/copilotAgent.ts | 9 +- .../agentHost/node/sessionStateManager.ts | 18 +- .../test/node/agentEventMapper.test.ts | 3 +- .../agentHost/test/node/agentService.test.ts | 8 +- .../test/node/agentSideEffects.test.ts | 32 +- .../platform/agentHost/test/node/mockAgent.ts | 5 +- .../test/node/protocolServerHandler.test.ts | 26 +- .../test/node/sessionStateManager.test.ts | 46 +- .../agentHost/agentHostSessionHandler.ts | 24 +- .../agentHost/stateToProgressAdapter.ts | 20 +- .../agentHostChatContribution.test.ts | 6 +- .../stateToProgressAdapter.test.ts | 38 +- 29 files changed, 974 insertions(+), 938 deletions(-) create mode 100644 src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts create mode 100644 src/vs/platform/agentHost/common/state/protocol/reducers.ts diff --git a/scripts/sync-agent-host-protocol.ts b/scripts/sync-agent-host-protocol.ts index 750e55686af..02469d17051 100644 --- a/scripts/sync-agent-host-protocol.ts +++ b/scripts/sync-agent-host-protocol.ts @@ -9,12 +9,10 @@ // npx tsx scripts/sync-agent-host-protocol.ts // // Transformations applied: -// 1. Converts `const enum` to `const` object + string literal union (VS Code -// tsconfig uses `preserveConstEnums` which makes `const enum` nominal). -// 2. Converts 2-space indentation to tabs. -// 3. Merges duplicate imports from the same module. -// 4. Formats with the project's tsfmt.json settings. -// 5. Adds Microsoft copyright header. +// 1. Converts 2-space indentation to tabs. +// 2. Merges duplicate imports from the same module. +// 3. Formats with the project's tsfmt.json settings. +// 4. Adds Microsoft copyright header. // // URI stays as `string` (the protocol's canonical representation). VS Code code // should call `URI.parse()` at point-of-use where a URI class is needed. @@ -70,6 +68,8 @@ const BANNER = '// allow-any-unicode-comment-file\n// DO NOT EDIT -- auto-genera const FILES: { src: string; dest: string }[] = [ { src: 'state.ts', dest: 'state.ts' }, { src: 'actions.ts', dest: 'actions.ts' }, + { src: 'action-origin.generated.ts', dest: 'action-origin.generated.ts' }, + { src: 'reducers.ts', dest: 'reducers.ts' }, { src: 'commands.ts', dest: 'commands.ts' }, { src: 'errors.ts', dest: 'errors.ts' }, { src: 'notifications.ts', dest: 'notifications.ts' }, @@ -168,99 +168,14 @@ function mergeDuplicateImports(content: string): string { }).join('\n'); } -// Global enum definitions collected from all files before per-file processing -let globalEnumDefs = new Map>(); -function collectAllEnumDefs(): void { - globalEnumDefs = new Map(); - for (const file of FILES) { - const srcPath = path.join(TYPES_DIR, file.src); - if (!fs.existsSync(srcPath)) { - continue; - } - const content = fs.readFileSync(srcPath, 'utf-8'); - content.replace( - /export const enum (\w+) \{([^}]+)\}/g, - (_match, name: string, body: string) => { - const members = new Map(); - for (const line of body.split('\n')) { - const memberMatch = line.match(/^\s*(\w+)\s*=\s*'([^']+)'/); - if (memberMatch) { - members.set(memberMatch[1], memberMatch[2]); - } - } - if (members.size > 0) { - globalEnumDefs.set(name, members); - } - return _match; - } - ); - } -} -/** - * Converts `const enum Foo { A = 'a', B = 'b' }` into: - * ``` - * export const Foo = { A: 'a', B: 'b' } as const; - * export type Foo = typeof Foo[keyof typeof Foo]; - * ``` - * Then replaces `Foo.A` in type positions with the string literal `'a'`, - * using the global enum definitions collected from all protocol files. - */ -function convertConstEnums(content: string): string { - // Replace the const enum declarations in this file - content = content.replace( - /export const enum (\w+) \{([^}]+)\}/g, - (_match, name: string) => { - const members = globalEnumDefs.get(name); - if (!members) { - return _match; - } - const objEntries = [...members.entries()].map(([k, v]) => ` ${k}: '${v}'`).join(',\n'); - return `export const ${name} = {\n${objEntries},\n} as const;\nexport type ${name} = typeof ${name}[keyof typeof ${name}];`; - } - ); - // Replace Enum.Member references with their resolved string literals - for (const [enumName, members] of globalEnumDefs) { - for (const [memberName, value] of members) { - const ref = `${enumName}.${memberName}`; - content = content.split(ref).join(`'${value}'`); - } - } - - // Remove value imports of enums that are no longer referenced as values - content = content.replace( - /import \{([^}]+)\} from '([^']+)';/g, - (_match, names: string, from: string) => { - const parts = names.split(',').map((s: string) => s.trim()).filter((s: string) => s.length > 0); - const remaining = parts.filter((name: string) => { - if (!globalEnumDefs.has(name)) { - return true; - } - const uses = content.split(name).length - 1; - return uses > 1; - }); - if (remaining.length === 0) { - return ''; - } - if (remaining.length === parts.length) { - return _match; - } - return `import { ${remaining.join(', ')} } from '${from}';`; - } - ); - - return content; -} function processFile(src: string, dest: string, commitHash: string): void { let content = fs.readFileSync(src, 'utf-8'); content = stripExistingHeader(content); - // Convert `const enum` to plain `const` object + string literal union - content = convertConstEnums(content); - // Merge duplicate imports from the same module content = mergeDuplicateImports(content); @@ -297,10 +212,6 @@ function main() { console.log(` Dest: ${DEST_DIR}`); console.log(); - // Collect all enum definitions across all protocol files - collectAllEnumDefs(); - console.log(` Collected ${globalEnumDefs.size} const enums`); - // Copy protocol files for (const file of FILES) { const srcPath = path.join(TYPES_DIR, file.src); diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 9907d4e4da8..63c2c6a93e4 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -8,6 +8,7 @@ import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { IActionEnvelope, INotification, ISessionAction } from './state/sessionActions.js'; import type { IBrowseDirectoryResult, IStateSnapshot } from './state/sessionProtocol.js'; +import { AttachmentType, PermissionKind, type PolicyState } from './state/sessionState.js'; // IPC contract between the renderer and the agent host utility process. // Defines all serializable event types, the IAgent provider interface, @@ -52,7 +53,7 @@ export interface IAgentCreateSessionConfig { /** Serializable attachment passed alongside a message to the agent host. */ export interface IAgentAttachment { - readonly type: 'file' | 'directory' | 'selection'; + readonly type: AttachmentType; readonly path: string; readonly displayName?: string; /** For selections: the selected text. */ @@ -74,7 +75,7 @@ export interface IAgentModelInfo { readonly supportsReasoningEffort: boolean; readonly supportedReasoningEfforts?: readonly string[]; readonly defaultReasoningEffort?: string; - readonly policyState?: 'enabled' | 'disabled' | 'unconfigured'; + readonly policyState?: PolicyState; readonly billingMultiplier?: number; } @@ -190,7 +191,7 @@ export interface IAgentPermissionRequestEvent extends IAgentProgressEventBase { /** Unique ID for correlating the response. */ readonly requestId: string; /** The kind of permission being requested. */ - readonly permissionKind: 'shell' | 'write' | 'mcp' | 'read' | 'url'; + readonly permissionKind: PermissionKind; /** The tool call ID that triggered this permission request. */ readonly toolCallId?: string; /** File path involved (for read/write). */ diff --git a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts new file mode 100644 index 00000000000..8dfc004dcbd --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts @@ -0,0 +1,114 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts +// Synced from agent-host-protocol @ 3116861 + +// Generated from types/actions.ts — do not edit +// Run `npm run generate` to regenerate. + +import { ActionType, type IStateAction, type IRootAgentsChangedAction, type IRootActiveSessionsChangedAction, type ISessionReadyAction, type ISessionCreationFailedAction, type ISessionTurnStartedAction, type ISessionDeltaAction, type ISessionResponsePartAction, type ISessionToolCallStartAction, type ISessionToolCallDeltaAction, type ISessionToolCallReadyAction, type ISessionToolCallConfirmedAction, type ISessionToolCallCompleteAction, type ISessionToolCallResultConfirmedAction, type ISessionPermissionRequestAction, type ISessionPermissionResolvedAction, type ISessionTurnCompleteAction, type ISessionTurnCancelledAction, type ISessionErrorAction, type ISessionTitleChangedAction, type ISessionUsageAction, type ISessionReasoningAction, type ISessionModelChangedAction, type ISessionServerToolsChangedAction, type ISessionActiveClientChangedAction, type ISessionActiveClientToolsChangedAction } from './actions.js'; + + +// ─── Root vs Session Action Unions ─────────────────────────────────────────── + +/** Union of all root-scoped actions. */ +export type IRootAction = + | IRootAgentsChangedAction + | IRootActiveSessionsChangedAction + ; + +/** Union of all session-scoped actions. */ +export type ISessionAction = + | ISessionReadyAction + | ISessionCreationFailedAction + | ISessionTurnStartedAction + | ISessionDeltaAction + | ISessionResponsePartAction + | ISessionToolCallStartAction + | ISessionToolCallDeltaAction + | ISessionToolCallReadyAction + | ISessionToolCallConfirmedAction + | ISessionToolCallCompleteAction + | ISessionToolCallResultConfirmedAction + | ISessionPermissionRequestAction + | ISessionPermissionResolvedAction + | ISessionTurnCompleteAction + | ISessionTurnCancelledAction + | ISessionErrorAction + | ISessionTitleChangedAction + | ISessionUsageAction + | ISessionReasoningAction + | ISessionModelChangedAction + | ISessionServerToolsChangedAction + | ISessionActiveClientChangedAction + | ISessionActiveClientToolsChangedAction + ; + +/** Union of session actions that clients may dispatch. */ +export type IClientSessionAction = + | ISessionTurnStartedAction + | ISessionToolCallConfirmedAction + | ISessionToolCallCompleteAction + | ISessionToolCallResultConfirmedAction + | ISessionPermissionResolvedAction + | ISessionTurnCancelledAction + | ISessionModelChangedAction + | ISessionActiveClientChangedAction + | ISessionActiveClientToolsChangedAction + ; + +/** Union of session actions that only the server may produce. */ +export type IServerSessionAction = + | ISessionReadyAction + | ISessionCreationFailedAction + | ISessionDeltaAction + | ISessionResponsePartAction + | ISessionToolCallStartAction + | ISessionToolCallDeltaAction + | ISessionToolCallReadyAction + | ISessionPermissionRequestAction + | ISessionTurnCompleteAction + | ISessionErrorAction + | ISessionTitleChangedAction + | ISessionUsageAction + | ISessionReasoningAction + | ISessionServerToolsChangedAction + ; + +// ─── Client-Dispatchable Map ───────────────────────────────────────────────── + +/** + * Exhaustive map indicating which action types may be dispatched by clients. + * Adding a new action to IStateAction without adding it here is a compile error. + */ +export const IS_CLIENT_DISPATCHABLE: { readonly [K in IStateAction['type']]: boolean } = { + [ActionType.RootAgentsChanged]: false, + [ActionType.RootActiveSessionsChanged]: false, + [ActionType.SessionReady]: false, + [ActionType.SessionCreationFailed]: false, + [ActionType.SessionTurnStarted]: true, + [ActionType.SessionDelta]: false, + [ActionType.SessionResponsePart]: false, + [ActionType.SessionToolCallStart]: false, + [ActionType.SessionToolCallDelta]: false, + [ActionType.SessionToolCallReady]: false, + [ActionType.SessionToolCallConfirmed]: true, + [ActionType.SessionToolCallComplete]: true, + [ActionType.SessionToolCallResultConfirmed]: true, + [ActionType.SessionPermissionRequest]: false, + [ActionType.SessionPermissionResolved]: true, + [ActionType.SessionTurnComplete]: false, + [ActionType.SessionTurnCancelled]: true, + [ActionType.SessionError]: false, + [ActionType.SessionTitleChanged]: false, + [ActionType.SessionUsage]: false, + [ActionType.SessionReasoning]: false, + [ActionType.SessionModelChanged]: true, + [ActionType.SessionServerToolsChanged]: false, + [ActionType.SessionActiveClientChanged]: true, + [ActionType.SessionActiveClientToolsChanged]: true, +}; diff --git a/src/vs/platform/agentHost/common/state/protocol/actions.ts b/src/vs/platform/agentHost/common/state/protocol/actions.ts index 157daf6b119..40f4b2734f4 100644 --- a/src/vs/platform/agentHost/common/state/protocol/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/actions.ts @@ -5,9 +5,9 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ a566419 +// Synced from agent-host-protocol @ 3116861 -import { ToolCallConfirmationReason, type URI, type StringOrMarkdown, type IAgentInfo, type IErrorInfo, type IUserMessage, type IResponsePart, type IToolCallResult, type IToolDefinition, type ISessionActiveClient, type IUsageInfo, type IPermissionRequest } from './state.js'; +import { ToolCallConfirmationReason, ToolCallCancellationReason, type URI, type StringOrMarkdown, type IAgentInfo, type IErrorInfo, type IUserMessage, type IResponsePart, type IToolCallResult, type IToolDefinition, type ISessionActiveClient, type IUsageInfo, type IPermissionRequest } from './state.js'; // ─── Action Type Enum ──────────────────────────────────────────────────────── @@ -17,34 +17,33 @@ import { ToolCallConfirmationReason, type URI, type StringOrMarkdown, type IAgen * * @category Actions */ -export const ActionType = { - RootAgentsChanged: 'root/agentsChanged', - RootActiveSessionsChanged: 'root/activeSessionsChanged', - SessionReady: 'session/ready', - SessionCreationFailed: 'session/creationFailed', - SessionTurnStarted: 'session/turnStarted', - SessionDelta: 'session/delta', - SessionResponsePart: 'session/responsePart', - SessionToolCallStart: 'session/toolCallStart', - SessionToolCallDelta: 'session/toolCallDelta', - SessionToolCallReady: 'session/toolCallReady', - SessionToolCallConfirmed: 'session/toolCallConfirmed', - SessionToolCallComplete: 'session/toolCallComplete', - SessionToolCallResultConfirmed: 'session/toolCallResultConfirmed', - SessionPermissionRequest: 'session/permissionRequest', - SessionPermissionResolved: 'session/permissionResolved', - SessionTurnComplete: 'session/turnComplete', - SessionTurnCancelled: 'session/turnCancelled', - SessionError: 'session/error', - SessionTitleChanged: 'session/titleChanged', - SessionUsage: 'session/usage', - SessionReasoning: 'session/reasoning', - SessionModelChanged: 'session/modelChanged', - SessionServerToolsChanged: 'session/serverToolsChanged', - SessionActiveClientChanged: 'session/activeClientChanged', - SessionActiveClientToolsChanged: 'session/activeClientToolsChanged', -} as const; -export type ActionType = typeof ActionType[keyof typeof ActionType]; +export const enum ActionType { + RootAgentsChanged = 'root/agentsChanged', + RootActiveSessionsChanged = 'root/activeSessionsChanged', + SessionReady = 'session/ready', + SessionCreationFailed = 'session/creationFailed', + SessionTurnStarted = 'session/turnStarted', + SessionDelta = 'session/delta', + SessionResponsePart = 'session/responsePart', + SessionToolCallStart = 'session/toolCallStart', + SessionToolCallDelta = 'session/toolCallDelta', + SessionToolCallReady = 'session/toolCallReady', + SessionToolCallConfirmed = 'session/toolCallConfirmed', + SessionToolCallComplete = 'session/toolCallComplete', + SessionToolCallResultConfirmed = 'session/toolCallResultConfirmed', + SessionPermissionRequest = 'session/permissionRequest', + SessionPermissionResolved = 'session/permissionResolved', + SessionTurnComplete = 'session/turnComplete', + SessionTurnCancelled = 'session/turnCancelled', + SessionError = 'session/error', + SessionTitleChanged = 'session/titleChanged', + SessionUsage = 'session/usage', + SessionReasoning = 'session/reasoning', + SessionModelChanged = 'session/modelChanged', + SessionServerToolsChanged = 'session/serverToolsChanged', + SessionActiveClientChanged = 'session/activeClientChanged', + SessionActiveClientToolsChanged = 'session/activeClientToolsChanged', +} // ─── Action Envelope ───────────────────────────────────────────────────────── @@ -99,7 +98,7 @@ interface IToolCallActionBase { * @version 1 */ export interface IRootAgentsChangedAction { - type: 'root/agentsChanged'; + type: ActionType.RootAgentsChanged; /** Updated agent list */ agents: IAgentInfo[]; } @@ -111,7 +110,7 @@ export interface IRootAgentsChangedAction { * @version 1 */ export interface IRootActiveSessionsChangedAction { - type: 'root/activeSessionsChanged'; + type: ActionType.RootActiveSessionsChanged; /** Current count of active sessions */ activeSessions: number; } @@ -125,7 +124,7 @@ export interface IRootActiveSessionsChangedAction { * @version 1 */ export interface ISessionReadyAction { - type: 'session/ready'; + type: ActionType.SessionReady; /** Session URI */ session: URI; } @@ -137,7 +136,7 @@ export interface ISessionReadyAction { * @version 1 */ export interface ISessionCreationFailedAction { - type: 'session/creationFailed'; + type: ActionType.SessionCreationFailed; /** Session URI */ session: URI; /** Error details */ @@ -152,7 +151,7 @@ export interface ISessionCreationFailedAction { * @clientDispatchable */ export interface ISessionTurnStartedAction { - type: 'session/turnStarted'; + type: ActionType.SessionTurnStarted; /** Session URI */ session: URI; /** Turn identifier */ @@ -168,7 +167,7 @@ export interface ISessionTurnStartedAction { * @version 1 */ export interface ISessionDeltaAction { - type: 'session/delta'; + type: ActionType.SessionDelta; /** Session URI */ session: URI; /** Turn identifier */ @@ -184,7 +183,7 @@ export interface ISessionDeltaAction { * @version 1 */ export interface ISessionResponsePartAction { - type: 'session/responsePart'; + type: ActionType.SessionResponsePart; /** Session URI */ session: URI; /** Turn identifier */ @@ -204,7 +203,7 @@ export interface ISessionResponsePartAction { * @version 1 */ export interface ISessionToolCallStartAction extends IToolCallActionBase { - type: 'session/toolCallStart'; + type: ActionType.SessionToolCallStart; /** Internal tool name (for debugging/logging) */ toolName: string; /** Human-readable tool name */ @@ -223,7 +222,7 @@ export interface ISessionToolCallStartAction extends IToolCallActionBase { * @version 1 */ export interface ISessionToolCallDeltaAction extends IToolCallActionBase { - type: 'session/toolCallDelta'; + type: ActionType.SessionToolCallDelta; /** Partial parameter content to append */ content: string; /** Updated progress message */ @@ -242,7 +241,7 @@ export interface ISessionToolCallDeltaAction extends IToolCallActionBase { * @version 1 */ export interface ISessionToolCallReadyAction extends IToolCallActionBase { - type: 'session/toolCallReady'; + type: ActionType.SessionToolCallReady; /** Message describing what the tool will do */ invocationMessage: StringOrMarkdown; /** Raw tool input */ @@ -259,7 +258,7 @@ export interface ISessionToolCallReadyAction extends IToolCallActionBase { * @clientDispatchable */ export interface ISessionToolCallApprovedAction extends IToolCallActionBase { - type: 'session/toolCallConfirmed'; + type: ActionType.SessionToolCallConfirmed; /** The tool call was approved */ approved: true; /** How the tool was confirmed */ @@ -277,11 +276,11 @@ export interface ISessionToolCallApprovedAction extends IToolCallActionBase { * @clientDispatchable */ export interface ISessionToolCallDeniedAction extends IToolCallActionBase { - type: 'session/toolCallConfirmed'; + type: ActionType.SessionToolCallConfirmed; /** The tool call was denied */ approved: false; /** Why the tool was cancelled */ - reason: 'denied' | 'skipped'; + reason: ToolCallCancellationReason.Denied | ToolCallCancellationReason.Skipped; /** What the user suggested doing instead */ userSuggestion?: IUserMessage; /** Optional explanation for the denial */ @@ -316,7 +315,7 @@ export type ISessionToolCallConfirmedAction = * @clientDispatchable */ export interface ISessionToolCallCompleteAction extends IToolCallActionBase { - type: 'session/toolCallComplete'; + type: ActionType.SessionToolCallComplete; /** Execution result */ result: IToolCallResult; /** If true, the result requires client approval before finalizing */ @@ -333,7 +332,7 @@ export interface ISessionToolCallCompleteAction extends IToolCallActionBase { * @clientDispatchable */ export interface ISessionToolCallResultConfirmedAction extends IToolCallActionBase { - type: 'session/toolCallResultConfirmed'; + type: ActionType.SessionToolCallResultConfirmed; /** Whether the result was approved */ approved: boolean; } @@ -345,7 +344,7 @@ export interface ISessionToolCallResultConfirmedAction extends IToolCallActionBa * @version 1 */ export interface ISessionPermissionRequestAction { - type: 'session/permissionRequest'; + type: ActionType.SessionPermissionRequest; /** Session URI */ session: URI; /** Turn identifier */ @@ -362,7 +361,7 @@ export interface ISessionPermissionRequestAction { * @clientDispatchable */ export interface ISessionPermissionResolvedAction { - type: 'session/permissionResolved'; + type: ActionType.SessionPermissionResolved; /** Session URI */ session: URI; /** Turn identifier */ @@ -380,7 +379,7 @@ export interface ISessionPermissionResolvedAction { * @version 1 */ export interface ISessionTurnCompleteAction { - type: 'session/turnComplete'; + type: ActionType.SessionTurnComplete; /** Session URI */ session: URI; /** Turn identifier */ @@ -395,7 +394,7 @@ export interface ISessionTurnCompleteAction { * @clientDispatchable */ export interface ISessionTurnCancelledAction { - type: 'session/turnCancelled'; + type: ActionType.SessionTurnCancelled; /** Session URI */ session: URI; /** Turn identifier */ @@ -409,7 +408,7 @@ export interface ISessionTurnCancelledAction { * @version 1 */ export interface ISessionErrorAction { - type: 'session/error'; + type: ActionType.SessionError; /** Session URI */ session: URI; /** Turn identifier */ @@ -425,7 +424,7 @@ export interface ISessionErrorAction { * @version 1 */ export interface ISessionTitleChangedAction { - type: 'session/titleChanged'; + type: ActionType.SessionTitleChanged; /** Session URI */ session: URI; /** New title */ @@ -439,7 +438,7 @@ export interface ISessionTitleChangedAction { * @version 1 */ export interface ISessionUsageAction { - type: 'session/usage'; + type: ActionType.SessionUsage; /** Session URI */ session: URI; /** Turn identifier */ @@ -455,7 +454,7 @@ export interface ISessionUsageAction { * @version 1 */ export interface ISessionReasoningAction { - type: 'session/reasoning'; + type: ActionType.SessionReasoning; /** Session URI */ session: URI; /** Turn identifier */ @@ -472,7 +471,7 @@ export interface ISessionReasoningAction { * @clientDispatchable */ export interface ISessionModelChangedAction { - type: 'session/modelChanged'; + type: ActionType.SessionModelChanged; /** Session URI */ session: URI; /** New model ID */ @@ -488,7 +487,7 @@ export interface ISessionModelChangedAction { * @version 1 */ export interface ISessionServerToolsChangedAction { - type: 'session/serverToolsChanged'; + type: ActionType.SessionServerToolsChanged; /** Session URI */ session: URI; /** Updated server tools list (full replacement) */ @@ -508,7 +507,7 @@ export interface ISessionServerToolsChangedAction { * @clientDispatchable */ export interface ISessionActiveClientChangedAction { - type: 'session/activeClientChanged'; + type: ActionType.SessionActiveClientChanged; /** Session URI */ session: URI; /** The new active client, or `null` to unset */ @@ -527,7 +526,7 @@ export interface ISessionActiveClientChangedAction { * @clientDispatchable */ export interface ISessionActiveClientToolsChangedAction { - type: 'session/activeClientToolsChanged'; + type: ActionType.SessionActiveClientToolsChanged; /** Session URI */ session: URI; /** Updated client tools list (full replacement) */ diff --git a/src/vs/platform/agentHost/common/state/protocol/commands.ts b/src/vs/platform/agentHost/common/state/protocol/commands.ts index d6c9c009a74..676841e0728 100644 --- a/src/vs/platform/agentHost/common/state/protocol/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/commands.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ a566419 +// Synced from agent-host-protocol @ 3116861 import type { URI, ISnapshot, ISessionSummary, ITurn } from './state.js'; import type { IActionEnvelope, IStateAction } from './actions.js'; @@ -56,11 +56,10 @@ export interface IInitializeResult { * * @category Commands */ -export const ReconnectResultType = { - Replay: 'replay', - Snapshot: 'snapshot', -} as const; -export type ReconnectResultType = typeof ReconnectResultType[keyof typeof ReconnectResultType]; +export const enum ReconnectResultType { + Replay = 'replay', + Snapshot = 'snapshot', +} /** * Re-establishes a dropped connection. The server replays missed actions or @@ -89,7 +88,7 @@ export interface IReconnectParams { */ export interface IReconnectReplayResult { /** Discriminant */ - type: 'replay'; + type: ReconnectResultType.Replay; /** Missed action envelopes since `lastSeenServerSeq` */ actions: IActionEnvelope[]; } @@ -99,7 +98,7 @@ export interface IReconnectReplayResult { */ export interface IReconnectSnapshotResult { /** Discriminant */ - type: 'snapshot'; + type: ReconnectResultType.Snapshot; /** Fresh snapshots for each subscription */ snapshots: ISnapshot[]; } @@ -227,11 +226,10 @@ export interface IListSessionsResult { * * @category Commands */ -export const ContentEncoding = { - Base64: 'base64', - Utf8: 'utf-8', -} as const; -export type ContentEncoding = typeof ContentEncoding[keyof typeof ContentEncoding]; +export const enum ContentEncoding { + Base64 = 'base64', + Utf8 = 'utf-8', +} /** * Fetches large content referenced by a `ContentRef` in the state tree. diff --git a/src/vs/platform/agentHost/common/state/protocol/errors.ts b/src/vs/platform/agentHost/common/state/protocol/errors.ts index 071725afbf7..638189c2bc1 100644 --- a/src/vs/platform/agentHost/common/state/protocol/errors.ts +++ b/src/vs/platform/agentHost/common/state/protocol/errors.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ a566419 +// Synced from agent-host-protocol @ 3116861 // ─── Standard JSON-RPC Codes ───────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/protocol/messages.ts b/src/vs/platform/agentHost/common/state/protocol/messages.ts index 740d30c04a6..395da78f6ea 100644 --- a/src/vs/platform/agentHost/common/state/protocol/messages.ts +++ b/src/vs/platform/agentHost/common/state/protocol/messages.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ a566419 +// Synced from agent-host-protocol @ 3116861 import type { IInitializeParams, IInitializeResult, IReconnectParams, IReconnectResult, ISubscribeParams, ISubscribeResult, ICreateSessionParams, IDisposeSessionParams, IListSessionsParams, IListSessionsResult, IFetchContentParams, IFetchContentResult, IBrowseDirectoryParams, IBrowseDirectoryResult, IFetchTurnsParams, IFetchTurnsResult, IUnsubscribeParams, IDispatchActionParams } from './commands.js'; diff --git a/src/vs/platform/agentHost/common/state/protocol/notifications.ts b/src/vs/platform/agentHost/common/state/protocol/notifications.ts index 140858822eb..3a55ca3b658 100644 --- a/src/vs/platform/agentHost/common/state/protocol/notifications.ts +++ b/src/vs/platform/agentHost/common/state/protocol/notifications.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ a566419 +// Synced from agent-host-protocol @ 3116861 import type { URI, ISessionSummary } from './state.js'; @@ -16,11 +16,10 @@ import type { URI, ISessionSummary } from './state.js'; * * @category Protocol Notifications */ -export const NotificationType = { - SessionAdded: 'notify/sessionAdded', - SessionRemoved: 'notify/sessionRemoved', -} as const; -export type NotificationType = typeof NotificationType[keyof typeof NotificationType]; +export const enum NotificationType { + SessionAdded = 'notify/sessionAdded', + SessionRemoved = 'notify/sessionRemoved', +} /** * Broadcast to all connected clients when a new session is created. @@ -49,7 +48,7 @@ export type NotificationType = typeof NotificationType[keyof typeof Notification * ``` */ export interface ISessionAddedNotification { - type: 'notify/sessionAdded'; + type: NotificationType.SessionAdded; /** Summary of the new session */ summary: ISessionSummary; } @@ -74,7 +73,7 @@ export interface ISessionAddedNotification { * ``` */ export interface ISessionRemovedNotification { - type: 'notify/sessionRemoved'; + type: NotificationType.SessionRemoved; /** URI of the removed session */ session: URI; } diff --git a/src/vs/platform/agentHost/common/state/protocol/reducers.ts b/src/vs/platform/agentHost/common/state/protocol/reducers.ts new file mode 100644 index 00000000000..ce78d37dc40 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/reducers.ts @@ -0,0 +1,491 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts +// Synced from agent-host-protocol @ 3116861 + +import { ActionType } from './actions.js'; +import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, type IRootState, type ISessionState, type IToolCallState, type IToolCallCompletedState, type IToolCallCancelledState, type ITurn } from './state.js'; +import { IS_CLIENT_DISPATCHABLE, type IRootAction, type ISessionAction, type IClientSessionAction } from './action-origin.generated.js'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Soft assertion for exhaustiveness checking. Place in the `default` branch of + * a switch on a discriminated union so the compiler errors when a new variant + * is added but not handled. + * + * At runtime, logs a warning instead of throwing so that forward-compatible + * clients receiving unknown actions from a newer server degrade gracefully. + */ +export function softAssertNever(value: never, log?: (msg: string) => void): void { + const msg = `Unhandled action type: ${(value as { type: string }).type}`; + (log ?? console.warn)(msg); +} + +/** Extracts the common base fields shared by all tool call lifecycle states. */ +function tcBase(tc: IToolCallState) { + return { + toolCallId: tc.toolCallId, + toolName: tc.toolName, + displayName: tc.displayName, + toolClientId: tc.toolClientId, + _meta: tc._meta, + }; +} + +/** + * Ends the active turn, finalizing it into a completed turn record. + */ +function endTurn( + state: ISessionState, + turnId: string, + turnState: TurnState, + summaryStatus: SessionStatus, + error?: { errorType: string; message: string; stack?: string }, +): ISessionState { + if (!state.activeTurn || state.activeTurn.id !== turnId) { + return state; + } + const active = state.activeTurn; + + const toolCalls: (IToolCallCompletedState | IToolCallCancelledState)[] = []; + for (const tc of Object.values(active.toolCalls)) { + if (tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Cancelled) { + toolCalls.push(tc); + } else { + // Force non-terminal tool calls into cancelled state. + toolCalls.push({ + status: ToolCallStatus.Cancelled, + ...tcBase(tc), + invocationMessage: tc.status === ToolCallStatus.Streaming ? (tc.invocationMessage ?? '') : tc.invocationMessage, + toolInput: tc.status === ToolCallStatus.Streaming ? undefined : tc.toolInput, + reason: ToolCallCancellationReason.Skipped, + }); + } + } + + const turn: ITurn = { + id: active.id, + userMessage: active.userMessage, + responseText: active.streamingText, + responseParts: active.responseParts, + toolCalls, + usage: active.usage, + state: turnState, + error, + }; + + return { + ...state, + turns: [...state.turns, turn], + activeTurn: undefined, + summary: { ...state.summary, status: summaryStatus, modifiedAt: Date.now() }, + }; +} + +/** + * Immutably updates a single tool call in the active turn's toolCalls map. + * Returns `state` unchanged if the active turn or tool call doesn't match. + */ +function updateToolCall( + state: ISessionState, + turnId: string, + toolCallId: string, + updater: (tc: IToolCallState) => IToolCallState, +): ISessionState { + const activeTurn = state.activeTurn; + if (!activeTurn || activeTurn.id !== turnId) { + return state; + } + + const existing = activeTurn.toolCalls[toolCallId]; + if (!existing) { + return state; + } + + return { + ...state, + activeTurn: { + ...activeTurn, + toolCalls: { + ...activeTurn.toolCalls, + [toolCallId]: updater(existing), + }, + }, + }; +} + +// ─── Root Reducer ──────────────────────────────────────────────────────────── + +/** + * Pure reducer for root state. Handles all {@link IRootAction} variants. + */ +export function rootReducer(state: IRootState, action: IRootAction, log?: (msg: string) => void): IRootState { + switch (action.type) { + case ActionType.RootAgentsChanged: + return { ...state, agents: action.agents }; + + case ActionType.RootActiveSessionsChanged: + return { ...state, activeSessions: action.activeSessions }; + + default: + softAssertNever(action, log); + return state; + } +} + +// ─── Session Reducer ───────────────────────────────────────────────────────── + +/** + * Pure reducer for session state. Handles all {@link ISessionAction} variants. + */ +export function sessionReducer(state: ISessionState, action: ISessionAction, log?: (msg: string) => void): ISessionState { + switch (action.type) { + // ── Lifecycle ────────────────────────────────────────────────────────── + + case ActionType.SessionReady: + return { + ...state, + lifecycle: SessionLifecycle.Ready, + summary: { ...state.summary, status: SessionStatus.Idle }, + }; + + case ActionType.SessionCreationFailed: + return { + ...state, + lifecycle: SessionLifecycle.CreationFailed, + creationError: action.error, + }; + + // ── Turn Lifecycle ──────────────────────────────────────────────────── + + case ActionType.SessionTurnStarted: + return { + ...state, + summary: { ...state.summary, status: SessionStatus.InProgress, modifiedAt: Date.now() }, + activeTurn: { + id: action.turnId, + userMessage: action.userMessage, + streamingText: '', + responseParts: [], + toolCalls: {}, + pendingPermissions: {}, + reasoning: '', + usage: undefined, + }, + }; + + case ActionType.SessionDelta: + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + return { + ...state, + activeTurn: { + ...state.activeTurn, + streamingText: state.activeTurn.streamingText + action.content, + }, + }; + + case ActionType.SessionResponsePart: + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + return { + ...state, + activeTurn: { + ...state.activeTurn, + responseParts: [...state.activeTurn.responseParts, action.part], + }, + }; + + case ActionType.SessionTurnComplete: + return endTurn(state, action.turnId, TurnState.Complete, SessionStatus.Idle); + + case ActionType.SessionTurnCancelled: + return endTurn(state, action.turnId, TurnState.Cancelled, SessionStatus.Idle); + + case ActionType.SessionError: + return endTurn(state, action.turnId, TurnState.Error, SessionStatus.Error, action.error); + + // ── Tool Call State Machine ─────────────────────────────────────────── + + case ActionType.SessionToolCallStart: + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + return { + ...state, + activeTurn: { + ...state.activeTurn, + toolCalls: { + ...state.activeTurn.toolCalls, + [action.toolCallId]: { + toolCallId: action.toolCallId, + toolName: action.toolName, + displayName: action.displayName, + toolClientId: action.toolClientId, + _meta: action._meta, + status: ToolCallStatus.Streaming, + }, + }, + }, + }; + + case ActionType.SessionToolCallDelta: + return updateToolCall(state, action.turnId, action.toolCallId, tc => { + if (tc.status !== ToolCallStatus.Streaming) { + return tc; + } + return { + ...tc, + partialInput: (tc.partialInput ?? '') + action.content, + invocationMessage: action.invocationMessage ?? tc.invocationMessage, + }; + }); + + case ActionType.SessionToolCallReady: + return updateToolCall(state, action.turnId, action.toolCallId, tc => { + const base = tcBase(tc); + if (action.confirmed) { + return { + status: ToolCallStatus.Running, + ...base, + invocationMessage: action.invocationMessage, + toolInput: action.toolInput, + confirmed: action.confirmed, + }; + } + return { + status: ToolCallStatus.PendingConfirmation, + ...base, + invocationMessage: action.invocationMessage, + toolInput: action.toolInput, + }; + }); + + case ActionType.SessionToolCallConfirmed: + return updateToolCall(state, action.turnId, action.toolCallId, tc => { + if (tc.status !== ToolCallStatus.PendingConfirmation) { + return tc; + } + const base = tcBase(tc); + if (action.approved) { + return { + status: ToolCallStatus.Running, + ...base, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + confirmed: action.confirmed, + }; + } + return { + status: ToolCallStatus.Cancelled, + ...base, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + reason: action.reason, + reasonMessage: action.reasonMessage, + userSuggestion: action.userSuggestion, + }; + }); + + case ActionType.SessionToolCallComplete: + return updateToolCall(state, action.turnId, action.toolCallId, tc => { + if (tc.status !== ToolCallStatus.Running && tc.status !== ToolCallStatus.PendingConfirmation) { + return tc; + } + const base = tcBase(tc); + const confirmed = tc.status === ToolCallStatus.Running + ? tc.confirmed + : ToolCallConfirmationReason.NotNeeded; + if (action.requiresResultConfirmation) { + return { + status: ToolCallStatus.PendingResultConfirmation, + ...base, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + confirmed, + ...action.result, + }; + } + return { + status: ToolCallStatus.Completed, + ...base, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + confirmed, + ...action.result, + }; + }); + + case ActionType.SessionToolCallResultConfirmed: + return updateToolCall(state, action.turnId, action.toolCallId, tc => { + if (tc.status !== ToolCallStatus.PendingResultConfirmation) { + return tc; + } + const base = tcBase(tc); + if (action.approved) { + return { + status: ToolCallStatus.Completed, + ...base, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + confirmed: tc.confirmed, + success: tc.success, + pastTenseMessage: tc.pastTenseMessage, + content: tc.content, + structuredContent: tc.structuredContent, + error: tc.error, + }; + } + return { + status: ToolCallStatus.Cancelled, + ...base, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + reason: ToolCallCancellationReason.ResultDenied, + }; + }); + + // ── Permissions ─────────────────────────────────────────────────────── + + case ActionType.SessionPermissionRequest: { + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + const pendingPermissions = { + ...state.activeTurn.pendingPermissions, + [action.request.requestId]: action.request, + }; + // If the permission is tied to a tool call, transition it to pending-confirmation + let toolCalls = state.activeTurn.toolCalls; + if (action.request.toolCallId) { + const tc = toolCalls[action.request.toolCallId]; + if (tc && (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Streaming)) { + toolCalls = { + ...toolCalls, + [action.request.toolCallId]: { + ...tc, + status: ToolCallStatus.PendingConfirmation, + invocationMessage: tc.invocationMessage ?? '', + }, + }; + } + } + return { + ...state, + activeTurn: { ...state.activeTurn, pendingPermissions, toolCalls }, + }; + } + + case ActionType.SessionPermissionResolved: { + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + const resolved = state.activeTurn.pendingPermissions[action.requestId]; + const { [action.requestId]: _, ...pendingPermissions } = state.activeTurn.pendingPermissions; + // If the permission was tied to a tool call, transition it based on approval + let toolCalls = state.activeTurn.toolCalls; + if (resolved?.toolCallId) { + const tc = toolCalls[resolved.toolCallId]; + if (tc && tc.status === ToolCallStatus.PendingConfirmation) { + const base = tcBase(tc); + const updated: IToolCallState = action.approved + ? { + status: ToolCallStatus.Running, + ...base, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + confirmed: ToolCallConfirmationReason.UserAction, + } + : { + status: ToolCallStatus.Cancelled, + ...base, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + reason: ToolCallCancellationReason.Denied, + }; + toolCalls = { ...toolCalls, [resolved.toolCallId]: updated }; + } + } + return { + ...state, + activeTurn: { ...state.activeTurn, pendingPermissions, toolCalls }, + }; + } + + // ── Metadata ────────────────────────────────────────────────────────── + + case ActionType.SessionTitleChanged: + return { + ...state, + summary: { ...state.summary, title: action.title, modifiedAt: Date.now() }, + }; + + case ActionType.SessionUsage: + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + return { + ...state, + activeTurn: { ...state.activeTurn, usage: action.usage }, + }; + + case ActionType.SessionReasoning: + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + return { + ...state, + activeTurn: { + ...state.activeTurn, + reasoning: state.activeTurn.reasoning + action.content, + }, + }; + + case ActionType.SessionModelChanged: + return { + ...state, + summary: { ...state.summary, model: action.model, modifiedAt: Date.now() }, + }; + + case ActionType.SessionServerToolsChanged: + return { ...state, serverTools: action.tools }; + + case ActionType.SessionActiveClientChanged: + return { + ...state, + activeClient: action.activeClient ?? undefined, + }; + + case ActionType.SessionActiveClientToolsChanged: + if (!state.activeClient) { + return state; + } + return { + ...state, + activeClient: { ...state.activeClient, tools: action.tools }, + }; + + default: + softAssertNever(action, log); + return state; + } +} + +// ─── Dispatch Validation ───────────────────────────────────────────────────── + +/** + * Type guard that checks whether an action may be dispatched by a client. + * + * Servers SHOULD call this to validate incoming `dispatchAction` requests + * and reject any action the client is not allowed to originate. + */ +export function isClientDispatchable(action: ISessionAction): action is IClientSessionAction { + return IS_CLIENT_DISPATCHABLE[action.type]; +} diff --git a/src/vs/platform/agentHost/common/state/protocol/state.ts b/src/vs/platform/agentHost/common/state/protocol/state.ts index 60e7672e77e..a037ca22059 100644 --- a/src/vs/platform/agentHost/common/state/protocol/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/state.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ a566419 +// Synced from agent-host-protocol @ 3116861 // ─── Type Aliases ──────────────────────────────────────────────────────────── @@ -27,12 +27,11 @@ export type StringOrMarkdown = string | { markdown: string }; * * @category Root State */ -export const PolicyState = { - Enabled: 'enabled', - Disabled: 'disabled', - Unconfigured: 'unconfigured', -} as const; -export type PolicyState = typeof PolicyState[keyof typeof PolicyState]; +export const enum PolicyState { + Enabled = 'enabled', + Disabled = 'disabled', + Unconfigured = 'unconfigured', +} /** * Global state shared with every client subscribed to `agenthost:/root`. @@ -85,24 +84,22 @@ export interface ISessionModelInfo { * * @category Session State */ -export const SessionLifecycle = { - Creating: 'creating', - Ready: 'ready', - CreationFailed: 'creationFailed', -} as const; -export type SessionLifecycle = typeof SessionLifecycle[keyof typeof SessionLifecycle]; +export const enum SessionLifecycle { + Creating = 'creating', + Ready = 'ready', + CreationFailed = 'creationFailed', +} /** * Current session status. * * @category Session State */ -export const SessionStatus = { - Idle: 'idle', - InProgress: 'in-progress', - Error: 'error', -} as const; -export type SessionStatus = typeof SessionStatus[keyof typeof SessionStatus]; +export const enum SessionStatus { + Idle = 'idle', + InProgress = 'in-progress', + Error = 'error', +} /** * Full state for a single session, loaded when a client subscribes to the session's URI. @@ -170,24 +167,22 @@ export interface ISessionSummary { * * @category Turn Types */ -export const TurnState = { - Complete: 'complete', - Cancelled: 'cancelled', - Error: 'error', -} as const; -export type TurnState = typeof TurnState[keyof typeof TurnState]; +export const enum TurnState { + Complete = 'complete', + Cancelled = 'cancelled', + Error = 'error', +} /** * Type of a message attachment. * * @category Turn Types */ -export const AttachmentType = { - File: 'file', - Directory: 'directory', - Selection: 'selection', -} as const; -export type AttachmentType = typeof AttachmentType[keyof typeof AttachmentType]; +export const enum AttachmentType { + File = 'file', + Directory = 'directory', + Selection = 'selection', +} /** * A completed request/response cycle. @@ -266,18 +261,17 @@ export interface IMessageAttachment { * * @category Response Parts */ -export const ResponsePartKind = { - Markdown: 'markdown', - ContentRef: 'contentRef', -} as const; -export type ResponsePartKind = typeof ResponsePartKind[keyof typeof ResponsePartKind]; +export const enum ResponsePartKind { + Markdown = 'markdown', + ContentRef = 'contentRef', +} /** * @category Response Parts */ export interface IMarkdownResponsePart { /** Discriminant */ - kind: 'markdown'; + kind: ResponsePartKind.Markdown; /** Markdown content */ content: string; } @@ -289,7 +283,7 @@ export interface IMarkdownResponsePart { */ export interface IContentRef { /** Discriminant */ - kind: 'contentRef'; + kind: ResponsePartKind.ContentRef; /** Content URI */ uri: string; /** Approximate size in bytes */ @@ -310,15 +304,14 @@ export type IResponsePart = IMarkdownResponsePart | IContentRef; * * @category Tool Call Types */ -export const ToolCallStatus = { - Streaming: 'streaming', - PendingConfirmation: 'pending-confirmation', - Running: 'running', - PendingResultConfirmation: 'pending-result-confirmation', - Completed: 'completed', - Cancelled: 'cancelled', -} as const; -export type ToolCallStatus = typeof ToolCallStatus[keyof typeof ToolCallStatus]; +export const enum ToolCallStatus { + Streaming = 'streaming', + PendingConfirmation = 'pending-confirmation', + Running = 'running', + PendingResultConfirmation = 'pending-result-confirmation', + Completed = 'completed', + Cancelled = 'cancelled', +} /** * How a tool call was confirmed for execution. @@ -329,24 +322,22 @@ export type ToolCallStatus = typeof ToolCallStatus[keyof typeof ToolCallStatus]; * * @category Tool Call Types */ -export const ToolCallConfirmationReason = { - NotNeeded: 'not-needed', - UserAction: 'user-action', - Setting: 'setting', -} as const; -export type ToolCallConfirmationReason = typeof ToolCallConfirmationReason[keyof typeof ToolCallConfirmationReason]; +export const enum ToolCallConfirmationReason { + NotNeeded = 'not-needed', + UserAction = 'user-action', + Setting = 'setting', +} /** * Why a tool call was cancelled. * * @category Tool Call Types */ -export const ToolCallCancellationReason = { - Denied: 'denied', - Skipped: 'skipped', - ResultDenied: 'result-denied', -} as const; -export type ToolCallCancellationReason = typeof ToolCallCancellationReason[keyof typeof ToolCallCancellationReason]; +export const enum ToolCallCancellationReason { + Denied = 'denied', + Skipped = 'skipped', + ResultDenied = 'result-denied', +} /** * Metadata common to all tool call states. @@ -428,7 +419,7 @@ export interface IToolCallResult { * @category Tool Call Types */ export interface IToolCallStreamingState extends IToolCallBase { - status: 'streaming'; + status: ToolCallStatus.Streaming; /** Partial parameters accumulated so far */ partialInput?: string; /** Progress message shown while parameters are streaming */ @@ -441,7 +432,7 @@ export interface IToolCallStreamingState extends IToolCallBase { * @category Tool Call Types */ export interface IToolCallPendingConfirmationState extends IToolCallBase, IToolCallParameterFields { - status: 'pending-confirmation'; + status: ToolCallStatus.PendingConfirmation; } /** @@ -450,7 +441,7 @@ export interface IToolCallPendingConfirmationState extends IToolCallBase, IToolC * @category Tool Call Types */ export interface IToolCallRunningState extends IToolCallBase, IToolCallParameterFields { - status: 'running'; + status: ToolCallStatus.Running; /** How the tool was confirmed for execution */ confirmed: ToolCallConfirmationReason; } @@ -461,7 +452,7 @@ export interface IToolCallRunningState extends IToolCallBase, IToolCallParameter * @category Tool Call Types */ export interface IToolCallPendingResultConfirmationState extends IToolCallBase, IToolCallParameterFields, IToolCallResult { - status: 'pending-result-confirmation'; + status: ToolCallStatus.PendingResultConfirmation; /** How the tool was confirmed for execution */ confirmed: ToolCallConfirmationReason; } @@ -472,7 +463,7 @@ export interface IToolCallPendingResultConfirmationState extends IToolCallBase, * @category Tool Call Types */ export interface IToolCallCompletedState extends IToolCallBase, IToolCallParameterFields, IToolCallResult { - status: 'completed'; + status: ToolCallStatus.Completed; /** How the tool was confirmed for execution */ confirmed: ToolCallConfirmationReason; } @@ -483,7 +474,7 @@ export interface IToolCallCompletedState extends IToolCallBase, IToolCallParamet * @category Tool Call Types */ export interface IToolCallCancelledState extends IToolCallBase, IToolCallParameterFields { - status: 'cancelled'; + status: ToolCallStatus.Cancelled; /** Why the tool was cancelled */ reason: ToolCallCancellationReason; /** Optional message explaining the cancellation */ @@ -584,11 +575,10 @@ export interface IToolAnnotations { * * @category Tool Result Content */ -export const ToolResultContentType = { - Text: 'text', - Binary: 'binary', -} as const; -export type ToolResultContentType = typeof ToolResultContentType[keyof typeof ToolResultContentType]; +export const enum ToolResultContentType { + Text = 'text', + Binary = 'binary', +} /** * Text content in a tool result. @@ -598,7 +588,7 @@ export type ToolResultContentType = typeof ToolResultContentType[keyof typeof To * @category Tool Result Content */ export interface IToolResultTextContent { - type: 'text'; + type: ToolResultContentType.Text; /** The text content */ text: string; } @@ -611,7 +601,7 @@ export interface IToolResultTextContent { * @category Tool Result Content */ export interface IToolResultBinaryContent { - type: 'binary'; + type: ToolResultContentType.Binary; /** Base64-encoded data */ data: string; /** Content type (e.g. `"image/png"`, `"application/pdf"`) */ @@ -638,14 +628,13 @@ export type IToolResultContent = * * @category Permission Types */ -export const PermissionKind = { - Shell: 'shell', - Write: 'write', - Mcp: 'mcp', - Read: 'read', - Url: 'url', -} as const; -export type PermissionKind = typeof PermissionKind[keyof typeof PermissionKind]; +export const enum PermissionKind { + Shell = 'shell', + Write = 'write', + Mcp = 'mcp', + Read = 'read', + Url = 'url', +} /** * @category Permission Types diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index e2d6d44e452..94193f19930 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -5,12 +5,10 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ a566419 - -import type { IStateAction } from '../actions.js'; - -import type { IProtocolNotification } from '../notifications.js'; +// Synced from agent-host-protocol @ 3116861 +import { ActionType, type IStateAction } from '../actions.js'; +import { NotificationType, type IProtocolNotification } from '../notifications.js'; // ─── Protocol Version Constants ────────────────────────────────────────────── @@ -27,31 +25,31 @@ export const MIN_PROTOCOL_VERSION = 1; * Adding a new action to `IStateAction` without adding it here is a compile error. */ export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: number } = { - ['root/agentsChanged']: 1, - ['root/activeSessionsChanged']: 1, - ['session/ready']: 1, - ['session/creationFailed']: 1, - ['session/turnStarted']: 1, - ['session/delta']: 1, - ['session/responsePart']: 1, - ['session/toolCallStart']: 1, - ['session/toolCallDelta']: 1, - ['session/toolCallReady']: 1, - ['session/toolCallConfirmed']: 1, - ['session/toolCallComplete']: 1, - ['session/toolCallResultConfirmed']: 1, - ['session/permissionRequest']: 1, - ['session/permissionResolved']: 1, - ['session/turnComplete']: 1, - ['session/turnCancelled']: 1, - ['session/error']: 1, - ['session/titleChanged']: 1, - ['session/usage']: 1, - ['session/reasoning']: 1, - ['session/modelChanged']: 1, - ['session/serverToolsChanged']: 1, - ['session/activeClientChanged']: 1, - ['session/activeClientToolsChanged']: 1, + [ActionType.RootAgentsChanged]: 1, + [ActionType.RootActiveSessionsChanged]: 1, + [ActionType.SessionReady]: 1, + [ActionType.SessionCreationFailed]: 1, + [ActionType.SessionTurnStarted]: 1, + [ActionType.SessionDelta]: 1, + [ActionType.SessionResponsePart]: 1, + [ActionType.SessionToolCallStart]: 1, + [ActionType.SessionToolCallDelta]: 1, + [ActionType.SessionToolCallReady]: 1, + [ActionType.SessionToolCallConfirmed]: 1, + [ActionType.SessionToolCallComplete]: 1, + [ActionType.SessionToolCallResultConfirmed]: 1, + [ActionType.SessionPermissionRequest]: 1, + [ActionType.SessionPermissionResolved]: 1, + [ActionType.SessionTurnComplete]: 1, + [ActionType.SessionTurnCancelled]: 1, + [ActionType.SessionError]: 1, + [ActionType.SessionTitleChanged]: 1, + [ActionType.SessionUsage]: 1, + [ActionType.SessionReasoning]: 1, + [ActionType.SessionModelChanged]: 1, + [ActionType.SessionServerToolsChanged]: 1, + [ActionType.SessionActiveClientChanged]: 1, + [ActionType.SessionActiveClientToolsChanged]: 1, }; /** @@ -69,8 +67,8 @@ export function isActionKnownToVersion(action: IStateAction, clientVersion: numb * is a compile error. */ export const NOTIFICATION_INTRODUCED_IN: { readonly [K in IProtocolNotification['type']]: number } = { - ['notify/sessionAdded']: 1, - ['notify/sessionRemoved']: 1, + [NotificationType.SessionAdded]: 1, + [NotificationType.SessionRemoved]: 1, }; /** diff --git a/src/vs/platform/agentHost/common/state/sessionActions.ts b/src/vs/platform/agentHost/common/state/sessionActions.ts index ab2d5b3c085..e1242a2a995 100644 --- a/src/vs/platform/agentHost/common/state/sessionActions.ts +++ b/src/vs/platform/agentHost/common/state/sessionActions.ts @@ -57,14 +57,10 @@ export { // Consumers use these shorter names; they're type-only aliases. import type { - IActionEnvelope as _IActionEnvelope, IRootAgentsChangedAction, IRootActiveSessionsChangedAction, - ISessionCreationFailedAction, ISessionDeltaAction, - ISessionErrorAction, ISessionModelChangedAction, - ISessionReadyAction, ISessionReasoningAction, ISessionResponsePartAction, ISessionPermissionRequestAction, @@ -80,18 +76,20 @@ import type { ISessionTurnCompleteAction, ISessionTurnStartedAction, ISessionUsageAction, - ISessionServerToolsChangedAction, - ISessionActiveClientChangedAction, - ISessionActiveClientToolsChangedAction, IStateAction, } from './protocol/actions.js'; import type { IProtocolNotification } from './protocol/notifications.js'; +import type { IRootAction as IRootAction_, ISessionAction as ISessionAction_, IClientSessionAction as IClientSessionAction_, IServerSessionAction as IServerSessionAction_ } from './protocol/action-origin.generated.js'; + +export type IRootAction = IRootAction_; +export type ISessionAction = ISessionAction_; +export type IClientSessionAction = IClientSessionAction_; +export type IServerSessionAction = IServerSessionAction_; // Root actions export type IAgentsChangedAction = IRootAgentsChangedAction; export type IActiveSessionsChangedAction = IRootActiveSessionsChangedAction; -export type IRootAction = IAgentsChangedAction | IActiveSessionsChangedAction; // Session actions — short aliases export type ITurnStartedAction = ISessionTurnStartedAction; @@ -114,32 +112,6 @@ export type IUsageAction = ISessionUsageAction; export type IReasoningAction = ISessionReasoningAction; export type IModelChangedAction = ISessionModelChangedAction; -/** Union of all session-scoped actions. */ -export type ISessionAction = - | ISessionReadyAction - | ISessionCreationFailedAction - | ISessionTurnStartedAction - | ISessionDeltaAction - | ISessionResponsePartAction - | ISessionToolCallStartAction - | ISessionToolCallDeltaAction - | ISessionToolCallReadyAction - | ISessionToolCallConfirmedAction - | ISessionToolCallCompleteAction - | ISessionToolCallResultConfirmedAction - | ISessionPermissionRequestAction - | ISessionPermissionResolvedAction - | ISessionTurnCompleteAction - | ISessionTurnCancelledAction - | ISessionErrorAction - | ISessionTitleChangedAction - | ISessionUsageAction - | ISessionReasoningAction - | ISessionModelChangedAction - | ISessionServerToolsChangedAction - | ISessionActiveClientChangedAction - | ISessionActiveClientToolsChangedAction; - // Notifications export type INotification = IProtocolNotification; diff --git a/src/vs/platform/agentHost/common/state/sessionReducers.ts b/src/vs/platform/agentHost/common/state/sessionReducers.ts index d39c72ab58f..3b02a189e5d 100644 --- a/src/vs/platform/agentHost/common/state/sessionReducers.ts +++ b/src/vs/platform/agentHost/common/state/sessionReducers.ts @@ -3,457 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// Pure reducer functions for the sessions process protocol. -// See protocol.md -> Reducers for the full design. -// -// Both the server and clients run the same reducers. This is what makes -// write-ahead possible: the client can locally predict the result of its -// own action using the exact same logic the server will run. -// -// IMPORTANT: Reducers must be pure — no side effects, no I/O, no service -// calls. Server-side effects (e.g. forwarding to the Copilot SDK) are -// handled by a separate dispatch layer. +// Re-exports the protocol reducers and adds VS Code-specific helpers. +// The actual reducer logic lives in the auto-generated protocol layer. -import type { IRootAction, ISessionAction } from './sessionActions.js'; -import { - type ICompletedToolCall, - type IErrorInfo, - type IRootState, - type ISessionState, - type IToolCallState, - type ITurn, - createActiveTurn, - SessionLifecycle, - SessionStatus, - TurnState, -} from './sessionState.js'; +import type { IToolCallState, ICompletedToolCall } from './sessionState.js'; -// ---- Helper: extract common base fields from a tool call state -------------- - -function tcBase(tc: IToolCallState) { - return { - toolCallId: tc.toolCallId, - toolName: tc.toolName, - displayName: tc.displayName, - _meta: tc._meta, - }; -} - -// ---- Root reducer ----------------------------------------------------------- - -/** - * Reduces root-level actions into a new RootState. - * Root actions are server-only (clients observe but cannot produce them). - */ -export function rootReducer(state: IRootState, action: IRootAction): IRootState { - switch (action.type) { - case 'root/agentsChanged': { - return { ...state, agents: [...action.agents] }; - } - case 'root/activeSessionsChanged': { - return { ...state, activeSessions: action.activeSessions }; - } - } -} - -// ---- Session reducer -------------------------------------------------------- - -/** - * Reduces session-level actions into a new SessionState. - * Handles lifecycle, turn lifecycle, streaming deltas, tool calls, permissions. - */ -export function sessionReducer(state: ISessionState, action: ISessionAction): ISessionState { - switch (action.type) { - case 'session/ready': { - return { ...state, lifecycle: SessionLifecycle.Ready }; - } - case 'session/creationFailed': { - return { - ...state, - lifecycle: SessionLifecycle.CreationFailed, - creationError: action.error, - }; - } - case 'session/turnStarted': { - const activeTurn = createActiveTurn(action.turnId, action.userMessage); - return { - ...state, - activeTurn, - summary: { ...state.summary, status: SessionStatus.InProgress }, - }; - } - case 'session/delta': { - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { - return state; - } - return { - ...state, - activeTurn: { - ...state.activeTurn, - streamingText: state.activeTurn.streamingText + action.content, - }, - }; - } - case 'session/responsePart': { - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { - return state; - } - return { - ...state, - activeTurn: { - ...state.activeTurn, - responseParts: [...state.activeTurn.responseParts, action.part], - }, - }; - } - case 'session/toolCallStart': { - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { - return state; - } - return { - ...state, - activeTurn: { - ...state.activeTurn, - toolCalls: { - ...state.activeTurn.toolCalls, - [action.toolCallId]: { - status: 'streaming', - toolCallId: action.toolCallId, - toolName: action.toolName, - displayName: action.displayName, - _meta: action._meta, - }, - }, - }, - }; - } - case 'session/toolCallDelta': { - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { - return state; - } - const tc = state.activeTurn.toolCalls[action.toolCallId]; - if (!tc || tc.status !== 'streaming') { - return state; - } - return { - ...state, - activeTurn: { - ...state.activeTurn, - toolCalls: { - ...state.activeTurn.toolCalls, - [action.toolCallId]: { - ...tc, - partialInput: (tc.partialInput ?? '') + action.content, - invocationMessage: action.invocationMessage ?? tc.invocationMessage, - }, - }, - }, - }; - } - case 'session/toolCallReady': { - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { - return state; - } - const tc = state.activeTurn.toolCalls[action.toolCallId]; - if (!tc) { - return state; - } - const base = tcBase(tc); - const updated: IToolCallState = action.confirmed - ? { - status: 'running', - ...base, - invocationMessage: action.invocationMessage, - toolInput: action.toolInput, - confirmed: action.confirmed, - } - : { - status: 'pending-confirmation', - ...base, - invocationMessage: action.invocationMessage, - toolInput: action.toolInput, - }; - return { - ...state, - activeTurn: { - ...state.activeTurn, - toolCalls: { ...state.activeTurn.toolCalls, [action.toolCallId]: updated }, - }, - }; - } - case 'session/toolCallConfirmed': { - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { - return state; - } - const tc = state.activeTurn.toolCalls[action.toolCallId]; - if (!tc || tc.status !== 'pending-confirmation') { - return state; - } - const base = tcBase(tc); - const updated: IToolCallState = action.approved - ? { - status: 'running', - ...base, - invocationMessage: tc.invocationMessage, - toolInput: tc.toolInput, - confirmed: action.confirmed, - } - : { - status: 'cancelled', - ...base, - invocationMessage: tc.invocationMessage, - toolInput: tc.toolInput, - reason: action.reason, - reasonMessage: action.reasonMessage, - userSuggestion: action.userSuggestion, - }; - return { - ...state, - activeTurn: { - ...state.activeTurn, - toolCalls: { ...state.activeTurn.toolCalls, [action.toolCallId]: updated }, - }, - }; - } - case 'session/toolCallComplete': { - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { - return state; - } - const tc = state.activeTurn.toolCalls[action.toolCallId]; - if (!tc || (tc.status !== 'running' && tc.status !== 'pending-confirmation')) { - return state; - } - const base = tcBase(tc); - const confirmed = tc.status === 'running' ? tc.confirmed : 'not-needed'; - const updated: IToolCallState = action.requiresResultConfirmation - ? { - status: 'pending-result-confirmation', - ...base, - invocationMessage: tc.invocationMessage, - toolInput: tc.toolInput, - confirmed, - ...action.result, - } - : { - status: 'completed', - ...base, - invocationMessage: tc.invocationMessage, - toolInput: tc.toolInput, - confirmed, - ...action.result, - }; - return { - ...state, - activeTurn: { - ...state.activeTurn, - toolCalls: { ...state.activeTurn.toolCalls, [action.toolCallId]: updated }, - }, - }; - } - case 'session/toolCallResultConfirmed': { - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { - return state; - } - const tc = state.activeTurn.toolCalls[action.toolCallId]; - if (!tc || tc.status !== 'pending-result-confirmation') { - return state; - } - const base = tcBase(tc); - const updated: IToolCallState = action.approved - ? { - status: 'completed', - ...base, - invocationMessage: tc.invocationMessage, - toolInput: tc.toolInput, - confirmed: tc.confirmed, - success: tc.success, - pastTenseMessage: tc.pastTenseMessage, - content: tc.content, - structuredContent: tc.structuredContent, - error: tc.error, - } - : { - status: 'cancelled', - ...base, - invocationMessage: tc.invocationMessage, - toolInput: tc.toolInput, - reason: 'result-denied', - }; - return { - ...state, - activeTurn: { - ...state.activeTurn, - toolCalls: { ...state.activeTurn.toolCalls, [action.toolCallId]: updated }, - }, - }; - } - case 'session/permissionRequest': { - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { - return state; - } - const pendingPermissions = { ...state.activeTurn.pendingPermissions, [action.request.requestId]: action.request }; - let toolCalls = state.activeTurn.toolCalls; - if (action.request.toolCallId) { - const toolCall = toolCalls[action.request.toolCallId]; - if (toolCall && (toolCall.status === 'running' || toolCall.status === 'streaming')) { - toolCalls = { - ...toolCalls, - [action.request.toolCallId]: { - ...toolCall, - status: 'pending-confirmation', - invocationMessage: toolCall.invocationMessage ?? '', - }, - }; - } - } - return { - ...state, - activeTurn: { ...state.activeTurn, pendingPermissions, toolCalls }, - }; - } - case 'session/permissionResolved': { - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { - return state; - } - const resolved = state.activeTurn.pendingPermissions[action.requestId]; - const { [action.requestId]: _, ...pendingPermissions } = state.activeTurn.pendingPermissions; - let toolCalls = state.activeTurn.toolCalls; - if (resolved?.toolCallId) { - const toolCall = toolCalls[resolved.toolCallId]; - if (toolCall && toolCall.status === 'pending-confirmation') { - const base = tcBase(toolCall); - const updated: IToolCallState = action.approved - ? { - status: 'running', - ...base, - invocationMessage: toolCall.invocationMessage, - toolInput: toolCall.toolInput, - confirmed: 'user-action', - } - : { - status: 'cancelled', - ...base, - invocationMessage: toolCall.invocationMessage, - toolInput: toolCall.toolInput, - reason: 'denied', - }; - toolCalls = { ...toolCalls, [resolved.toolCallId]: updated }; - } - } - return { - ...state, - activeTurn: { ...state.activeTurn, pendingPermissions, toolCalls }, - }; - } - case 'session/turnComplete': { - return finalizeTurn(state, action.turnId, TurnState.Complete); - } - case 'session/turnCancelled': { - return finalizeTurn(state, action.turnId, TurnState.Cancelled); - } - case 'session/error': { - return finalizeTurn(state, action.turnId, TurnState.Error, action.error); - } - case 'session/titleChanged': { - return { - ...state, - summary: { ...state.summary, title: action.title, modifiedAt: Date.now() }, - }; - } - case 'session/modelChanged': { - return { - ...state, - summary: { ...state.summary, model: action.model, modifiedAt: Date.now() }, - }; - } - case 'session/usage': { - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { - return state; - } - return { - ...state, - activeTurn: { - ...state.activeTurn, - usage: action.usage, - }, - }; - } - case 'session/reasoning': { - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { - return state; - } - return { - ...state, - activeTurn: { - ...state.activeTurn, - reasoning: state.activeTurn.reasoning + action.content, - }, - }; - } - case 'session/serverToolsChanged': { - return { ...state, serverTools: action.tools }; - } - case 'session/activeClientChanged': { - return { ...state, activeClient: action.activeClient ?? undefined }; - } - case 'session/activeClientToolsChanged': { - if (!state.activeClient) { - return state; - } - return { ...state, activeClient: { ...state.activeClient, tools: action.tools } }; - } - } -} - -// ---- Helpers ---------------------------------------------------------------- - -/** - * Moves the active turn into the completed turns array and clears `activeTurn`. - */ -function finalizeTurn(state: ISessionState, turnId: string, turnState: TurnState, error?: IErrorInfo): ISessionState { - if (!state.activeTurn || state.activeTurn.id !== turnId) { - return state; - } - const active = state.activeTurn; - - const completedToolCalls: ICompletedToolCall[] = []; - for (const tc of Object.values(active.toolCalls)) { - if (tc.status === 'completed') { - completedToolCalls.push(tc); - } else if (tc.status === 'cancelled') { - completedToolCalls.push(tc); - } else { - // For tool calls that are not in a terminal state when the turn - // finishes (e.g. still streaming or running), force them into - // a cancelled state so they are persisted properly. - completedToolCalls.push({ - status: 'cancelled', - ...tcBase(tc), - invocationMessage: tc.status === 'streaming' ? (tc.invocationMessage ?? '') : tc.invocationMessage, - toolInput: tc.status === 'streaming' ? undefined : tc.toolInput, - reason: 'skipped', - }); - } - } - - const finalizedTurn: ITurn = { - id: active.id, - userMessage: active.userMessage, - responseText: active.streamingText, - responseParts: active.responseParts, - toolCalls: completedToolCalls, - usage: active.usage, - state: turnState, - error, - }; - - return { - ...state, - turns: [...state.turns, finalizedTurn], - activeTurn: undefined, - summary: { ...state.summary, status: SessionStatus.Idle, modifiedAt: Date.now() }, - }; -} +// Re-export reducers from the protocol layer +export { rootReducer, sessionReducer, softAssertNever, isClientDispatchable } from './protocol/reducers.js'; // ---- Tool call metadata helpers (VS Code extensions via _meta) -------------- diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 0120f77d1c0..f63a17bde4b 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -11,17 +11,19 @@ // helpers and re-exports. import { hasKey } from '../../../../base/common/types.js'; -import type { - IActiveTurn, - IRootState, - ISessionState, - ISessionSummary, - IToolCallCancelledState, - IToolCallCompletedState, - IToolCallResult, - IToolCallState, - IToolResultTextContent, - IUserMessage, +import { + SessionLifecycle, + ToolResultContentType, + type IActiveTurn, + type IRootState, + type ISessionState, + type ISessionSummary, + type IToolCallCancelledState, + type IToolCallCompletedState, + type IToolCallResult, + type IToolCallState, + type IToolResultTextContent, + type IUserMessage, } from './protocol/state.js'; // Re-export everything from the protocol state module @@ -58,7 +60,9 @@ export { type IUserMessage, type StringOrMarkdown, type URI, + AttachmentType, PolicyState, + PermissionKind, ResponsePartKind, SessionLifecycle, SessionStatus, @@ -100,7 +104,7 @@ export function getToolOutputText(result: IToolCallResult): string | undefined { } const textParts: IToolResultTextContent[] = []; for (const c of result.content) { - if (hasKey(c, { type: true }) && c.type === 'text') { + if (hasKey(c, { type: true }) && c.type === ToolResultContentType.Text) { textParts.push(c); } } @@ -122,7 +126,7 @@ export function createRootState(): IRootState { export function createSessionState(summary: ISessionSummary): ISessionState { return { summary, - lifecycle: 'creating', + lifecycle: SessionLifecycle.Creating, turns: [], activeTurn: undefined, }; diff --git a/src/vs/platform/agentHost/node/agentEventMapper.ts b/src/vs/platform/agentHost/node/agentEventMapper.ts index b886efeec0f..5f378e7b739 100644 --- a/src/vs/platform/agentHost/node/agentEventMapper.ts +++ b/src/vs/platform/agentHost/node/agentEventMapper.ts @@ -14,20 +14,21 @@ import type { IAgentDeltaEvent, IAgentTitleChangedEvent, } from '../common/agentService.js'; -import type { - ISessionAction, - IDeltaAction, - IToolCallStartAction, - IToolCallReadyAction, - IToolCallCompleteAction, - ITurnCompleteAction, - ISessionErrorAction, - IUsageAction, - ITitleChangedAction, - IPermissionRequestAction, - IReasoningAction, +import { + ActionType, + type ISessionAction, + type IDeltaAction, + type IToolCallStartAction, + type IToolCallReadyAction, + type IToolCallCompleteAction, + type ITurnCompleteAction, + type ISessionErrorAction, + type IUsageAction, + type ITitleChangedAction, + type IPermissionRequestAction, + type IReasoningAction, } from '../common/state/sessionActions.js'; -import type { URI } from '../common/state/sessionState.js'; +import { ToolCallConfirmationReason, ToolResultContentType, type URI } from '../common/state/sessionState.js'; /** * Maps a flat {@link IAgentProgressEvent} from the agent host into @@ -41,7 +42,7 @@ export function mapProgressEventToActions(event: IAgentProgressEvent, session: U switch (event.type) { case 'delta': return { - type: 'session/delta', + type: ActionType.SessionDelta, session, turnId, content: (event as IAgentDeltaEvent).content, @@ -53,7 +54,7 @@ export function mapProgressEventToActions(event: IAgentProgressEvent, session: U // (params complete → running with auto-confirm) as a pair. const e = event as IAgentToolStartEvent; const startAction: IToolCallStartAction = { - type: 'session/toolCallStart', + type: ActionType.SessionToolCallStart, session, turnId, toolCallId: e.toolCallId, @@ -62,13 +63,13 @@ export function mapProgressEventToActions(event: IAgentProgressEvent, session: U _meta: { toolKind: e.toolKind, language: e.language }, }; const readyAction: IToolCallReadyAction = { - type: 'session/toolCallReady', + type: ActionType.SessionToolCallReady, session, turnId, toolCallId: e.toolCallId, invocationMessage: e.invocationMessage, toolInput: e.toolInput, - confirmed: 'not-needed', + confirmed: ToolCallConfirmationReason.NotNeeded, }; return [startAction, readyAction]; } @@ -76,14 +77,14 @@ export function mapProgressEventToActions(event: IAgentProgressEvent, session: U case 'tool_complete': { const e = event as IAgentToolCompleteEvent; return { - type: 'session/toolCallComplete', + type: ActionType.SessionToolCallComplete, session, turnId, toolCallId: e.toolCallId, result: { success: e.success, pastTenseMessage: e.pastTenseMessage, - content: e.toolOutput !== undefined ? [{ type: 'text' as const, text: e.toolOutput }] : undefined, + content: e.toolOutput !== undefined ? [{ type: ToolResultContentType.Text, text: e.toolOutput }] : undefined, error: e.error, }, } satisfies IToolCallCompleteAction; @@ -91,7 +92,7 @@ export function mapProgressEventToActions(event: IAgentProgressEvent, session: U case 'idle': return { - type: 'session/turnComplete', + type: ActionType.SessionTurnComplete, session, turnId, } satisfies ITurnCompleteAction; @@ -99,7 +100,7 @@ export function mapProgressEventToActions(event: IAgentProgressEvent, session: U case 'error': { const e = event as IAgentErrorEvent; return { - type: 'session/error', + type: ActionType.SessionError, session, turnId, error: { @@ -113,7 +114,7 @@ export function mapProgressEventToActions(event: IAgentProgressEvent, session: U case 'usage': { const e = event as IAgentUsageEvent; return { - type: 'session/usage', + type: ActionType.SessionUsage, session, turnId, usage: { @@ -127,7 +128,7 @@ export function mapProgressEventToActions(event: IAgentProgressEvent, session: U case 'title_changed': return { - type: 'session/titleChanged', + type: ActionType.SessionTitleChanged, session, title: (event as IAgentTitleChangedEvent).title, } satisfies ITitleChangedAction; @@ -135,7 +136,7 @@ export function mapProgressEventToActions(event: IAgentProgressEvent, session: U case 'permission_request': { const e = event as IAgentPermissionRequestEvent; return { - type: 'session/permissionRequest', + type: ActionType.SessionPermissionRequest, session, turnId, request: { @@ -154,7 +155,7 @@ export function mapProgressEventToActions(event: IAgentProgressEvent, session: U case 'reasoning': return { - type: 'session/reasoning', + type: ActionType.SessionReasoning, session, turnId, content: (event as IAgentReasoningEvent).content, diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 8c1fce8fdd3..ab8b67a7b72 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -10,7 +10,7 @@ import { URI } from '../../../base/common/uri.js'; import { ILogService } from '../../log/common/log.js'; import { IFileService } from '../../files/common/files.js'; import { AgentProvider, IAgentCreateSessionConfig, IAgent, IAgentService, IAgentSessionMetadata, AgentSession, IAgentDescriptor } from '../common/agentService.js'; -import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; +import { ActionType, type IActionEnvelope, type INotification, type ISessionAction } from '../common/state/sessionActions.js'; import type { IBrowseDirectoryResult, IStateSnapshot } from '../common/state/sessionProtocol.js'; import { SessionStatus, type ISessionSummary } from '../common/state/sessionState.js'; import { AgentSideEffects } from './agentSideEffects.js'; @@ -140,7 +140,7 @@ export class AgentService extends Disposable implements IAgentService { modifiedAt: Date.now(), }; this._stateManager.createSession(summary); - this._stateManager.dispatchServerAction({ type: 'session/ready', session: session.toString() }); + this._stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: session.toString() }); return session; } diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 73adeba5a01..c3cd4f362e6 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -10,7 +10,7 @@ import * as os from 'os'; import { IFileService } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; import { IAgent, IAgentAttachment } from '../common/agentService.js'; -import type { ISessionAction } from '../common/state/sessionActions.js'; +import { ActionType, type ISessionAction } from '../common/state/sessionActions.js'; import { IBrowseDirectoryResult, ICreateSessionParams, AHP_PROVIDER_NOT_FOUND, JSON_RPC_INTERNAL_ERROR, ProtocolError, IDirectoryEntry } from '../common/state/sessionProtocol.js'; import { type ISessionModelInfo, @@ -79,7 +79,7 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH } return { provider: d.provider, displayName: d.displayName, description: d.description, models }; })); - this._stateManager.dispatchServerAction({ type: 'root/agentsChanged', agents: infos }); + this._stateManager.dispatchServerAction({ type: ActionType.RootAgentsChanged, agents: infos }); } // ---- Agent registration ------------------------------------------------- @@ -119,11 +119,11 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH handleAction(action: ISessionAction): void { switch (action.type) { - case 'session/turnStarted': { + case ActionType.SessionTurnStarted: { const agent = this._options.getAgent(action.session); if (!agent) { this._stateManager.dispatchServerAction({ - type: 'session/error', + type: ActionType.SessionError, session: action.session, turnId: action.turnId, error: { errorType: 'noAgent', message: 'No agent found for session' }, @@ -138,7 +138,7 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH agent.sendMessage(URI.parse(action.session), action.userMessage.text, attachments).catch(err => { this._logService.error('[AgentSideEffects] sendMessage failed', err); this._stateManager.dispatchServerAction({ - type: 'session/error', + type: ActionType.SessionError, session: action.session, turnId: action.turnId, error: { errorType: 'sendFailed', message: String(err) }, @@ -146,7 +146,7 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH }); break; } - case 'session/permissionResolved': { + case ActionType.SessionPermissionResolved: { const providerId = this._pendingPermissions.get(action.requestId); if (providerId) { this._pendingPermissions.delete(action.requestId); @@ -157,14 +157,14 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH } break; } - case 'session/turnCancelled': { + case ActionType.SessionTurnCancelled: { const agent = this._options.getAgent(action.session); agent?.abortSession(URI.parse(action.session)).catch(err => { this._logService.error('[AgentSideEffects] abortSession failed', err); }); break; } - case 'session/modelChanged': { + case ActionType.SessionModelChanged: { const agent = this._options.getAgent(action.session); agent?.changeModel?.(URI.parse(action.session), action.model).catch(err => { this._logService.error('[AgentSideEffects] changeModel failed', err); @@ -200,7 +200,7 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH modifiedAt: Date.now(), }; this._stateManager.createSession(summary); - this._stateManager.dispatchServerAction({ type: 'session/ready', session }); + this._stateManager.dispatchServerAction({ type: ActionType.SessionReady, session }); } handleDisposeSession(session: ProtocolURI): void { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 6f8ef0cd5b9..334edbba1d4 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -14,6 +14,7 @@ import { rgPath } from '@vscode/ripgrep'; import { generateUuid } from '../../../../base/common/uuid.js'; import { ILogService } from '../../../log/common/log.js'; import { IAgentCreateSessionConfig, IAgentModelInfo, IAgentProgressEvent, IAgentMessageEvent, IAgent, IAgentSessionMetadata, IAgentToolStartEvent, IAgentToolCompleteEvent, AgentSession, IAgentDescriptor, IAgentAttachment } from '../../common/agentService.js'; +import { PermissionKind, type PolicyState } from '../../common/state/sessionState.js'; import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isHiddenTool } from './copilotToolDisplay.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; @@ -164,7 +165,7 @@ export class CopilotAgent extends Disposable implements IAgent { supportsReasoningEffort: m.capabilities.supports.reasoningEffort, supportedReasoningEfforts: m.supportedReasoningEfforts, defaultReasoningEffort: m.defaultReasoningEffort, - policyState: m.policy?.state, + policyState: m.policy?.state as PolicyState | undefined, billingMultiplier: m.billing?.multiplier, })); this._logService.info(`[Copilot] Found ${result.length} models`); @@ -304,9 +305,9 @@ export class CopilotAgent extends Disposable implements IAgent { const deferred = new DeferredPromise(); this._pendingPermissions.set(requestId, { sessionId: invocation.sessionId, deferred }); - const permissionKind = (['shell', 'write', 'mcp', 'read', 'url'] as const).includes(request.kind as 'shell') - ? request.kind as 'shell' | 'write' | 'mcp' | 'read' | 'url' - : 'read'; // Treat unknown kinds as read (safest default) + const permissionKind = ([PermissionKind.Shell, PermissionKind.Write, PermissionKind.Mcp, PermissionKind.Read, PermissionKind.Url] as const).includes(request.kind as PermissionKind) + ? request.kind as PermissionKind + : PermissionKind.Read; // Treat unknown kinds as read (safest default) // Fire the event so the renderer can handle it this._onDidSessionProgress.fire({ diff --git a/src/vs/platform/agentHost/node/sessionStateManager.ts b/src/vs/platform/agentHost/node/sessionStateManager.ts index 02561fc0948..99e8382f431 100644 --- a/src/vs/platform/agentHost/node/sessionStateManager.ts +++ b/src/vs/platform/agentHost/node/sessionStateManager.ts @@ -6,7 +6,7 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { ILogService } from '../../log/common/log.js'; -import { IActionEnvelope, IActionOrigin, INotification, ISessionAction, IRootAction, IStateAction, isRootAction, isSessionAction } from '../common/state/sessionActions.js'; +import { ActionType, NotificationType, IActionEnvelope, IActionOrigin, INotification, ISessionAction, IRootAction, IStateAction, isRootAction, isSessionAction } from '../common/state/sessionActions.js'; import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; import { rootReducer, sessionReducer } from '../common/state/sessionReducers.js'; import { createRootState, createSessionState, type IRootState, type ISessionState, type ISessionSummary, type URI, ROOT_STATE_URI } from '../common/state/sessionState.js'; @@ -105,7 +105,7 @@ export class SessionStateManager extends Disposable { this._logService.trace(`[SessionStateManager] Created session: ${key}`); this._onDidEmitNotification.fire({ - type: 'notify/sessionAdded', + type: NotificationType.SessionAdded, summary, }); @@ -130,7 +130,7 @@ export class SessionStateManager extends Disposable { this._logService.trace(`[SessionStateManager] Removed session: ${session}`); this._onDidEmitNotification.fire({ - type: 'notify/sessionRemoved', + type: NotificationType.SessionRemoved, session, }); } @@ -186,16 +186,16 @@ export class SessionStateManager extends Disposable { this._sessionStates.set(key, newState); // Track active turn for turn lifecycle - if (sessionAction.type === 'session/turnStarted') { + if (sessionAction.type === ActionType.SessionTurnStarted) { this._activeTurnToSession.set(sessionAction.turnId, key); - this.dispatchServerAction({ type: 'root/activeSessionsChanged', activeSessions: this._activeTurnToSession.size }); + this.dispatchServerAction({ type: ActionType.RootActiveSessionsChanged, activeSessions: this._activeTurnToSession.size }); } else if ( - sessionAction.type === 'session/turnComplete' || - sessionAction.type === 'session/turnCancelled' || - sessionAction.type === 'session/error' + sessionAction.type === ActionType.SessionTurnComplete || + sessionAction.type === ActionType.SessionTurnCancelled || + sessionAction.type === ActionType.SessionError ) { this._activeTurnToSession.delete(sessionAction.turnId); - this.dispatchServerAction({ type: 'root/activeSessionsChanged', activeSessions: this._activeTurnToSession.size }); + this.dispatchServerAction({ type: ActionType.RootActiveSessionsChanged, activeSessions: this._activeTurnToSession.size }); } resultingState = newState; diff --git a/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts b/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts index e58eac8dece..fe8993fea3e 100644 --- a/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts +++ b/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts @@ -31,6 +31,7 @@ import type { ITurnCompleteAction, IUsageAction, } from '../../common/state/sessionActions.js'; +import { PermissionKind } from '../../common/state/sessionState.js'; import { mapProgressEventToActions } from '../../node/agentEventMapper.js'; /** Helper: flatten the result of mapProgressEventToActions into an array. */ @@ -188,7 +189,7 @@ suite('AgentEventMapper', () => { session, type: 'permission_request', requestId: 'perm-1', - permissionKind: 'shell', + permissionKind: PermissionKind.Shell, toolCallId: 'tc-2', fullCommandText: 'rm -rf /', intention: 'Delete all files', diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 8754a80bb71..dd71dfbaa89 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -10,7 +10,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { NullLogService } from '../../../log/common/log.js'; import { FileService } from '../../../files/common/fileService.js'; import { AgentSession } from '../../common/agentService.js'; -import { IActionEnvelope } from '../../common/state/sessionActions.js'; +import { ActionType, IActionEnvelope } from '../../common/state/sessionActions.js'; import { AgentService } from '../../node/agentService.js'; import { MockAgent } from './mockAgent.js'; @@ -51,7 +51,7 @@ suite('AgentService (node dispatcher)', () => { // Start a turn so there's an active turn to map events to service.dispatchAction( - { type: 'session/turnStarted', session: session.toString(), turnId: 'turn-1', userMessage: { text: 'hello' } }, + { type: ActionType.SessionTurnStarted, session: session.toString(), turnId: 'turn-1', userMessage: { text: 'hello' } }, 'test-client', 1, ); @@ -59,7 +59,7 @@ suite('AgentService (node dispatcher)', () => { disposables.add(service.onDidAction(e => envelopes.push(e))); copilotAgent.fireProgress({ session, type: 'delta', messageId: 'msg-1', content: 'hello' }); - assert.ok(envelopes.some(e => e.action.type === 'session/delta')); + assert.ok(envelopes.some(e => e.action.type === ActionType.SessionDelta)); }); }); @@ -164,7 +164,7 @@ suite('AgentService (node dispatcher)', () => { // Model fetch is async inside AgentSideEffects — wait for it await new Promise(r => setTimeout(r, 50)); - const agentsChanged = envelopes.find(e => e.action.type === 'root/agentsChanged'); + const agentsChanged = envelopes.find(e => e.action.type === ActionType.RootAgentsChanged); assert.ok(agentsChanged); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index fc1772e5d37..86f9284b2a7 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -14,8 +14,8 @@ import { FileService } from '../../../files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; import { AgentSession, IAgent } from '../../common/agentService.js'; -import { IActionEnvelope, ISessionAction } from '../../common/state/sessionActions.js'; -import { SessionStatus } from '../../common/state/sessionState.js'; +import { ActionType, IActionEnvelope, ISessionAction } from '../../common/state/sessionActions.js'; +import { PermissionKind, SessionStatus } from '../../common/state/sessionState.js'; import { AgentSideEffects } from '../../node/agentSideEffects.js'; import { SessionStateManager } from '../../node/sessionStateManager.js'; import { MockAgent } from './mockAgent.js'; @@ -42,12 +42,12 @@ suite('AgentSideEffects', () => { createdAt: Date.now(), modifiedAt: Date.now(), }); - stateManager.dispatchServerAction({ type: 'session/ready', session: sessionUri.toString() }); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri.toString() }); } function startTurn(turnId: string): void { stateManager.dispatchClientAction( - { type: 'session/turnStarted', session: sessionUri.toString(), turnId, userMessage: { text: 'hello' } }, + { type: ActionType.SessionTurnStarted, session: sessionUri.toString(), turnId, userMessage: { text: 'hello' } }, { clientId: 'test', clientSeq: 1 }, ); } @@ -84,7 +84,7 @@ suite('AgentSideEffects', () => { test('calls sendMessage on the agent', async () => { setupSession(); const action: ISessionAction = { - type: 'session/turnStarted', + type: ActionType.SessionTurnStarted, session: sessionUri.toString(), turnId: 'turn-1', userMessage: { text: 'hello world' }, @@ -109,13 +109,13 @@ suite('AgentSideEffects', () => { disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); noAgentSideEffects.handleAction({ - type: 'session/turnStarted', + type: ActionType.SessionTurnStarted, session: sessionUri.toString(), turnId: 'turn-1', userMessage: { text: 'hello' }, }); - const errorAction = envelopes.find(e => e.action.type === 'session/error'); + const errorAction = envelopes.find(e => e.action.type === ActionType.SessionError); assert.ok(errorAction, 'should dispatch session/error'); }); }); @@ -127,7 +127,7 @@ suite('AgentSideEffects', () => { test('calls abortSession on the agent', async () => { setupSession(); sideEffects.handleAction({ - type: 'session/turnCancelled', + type: ActionType.SessionTurnCancelled, session: sessionUri.toString(), turnId: 'turn-1', }); @@ -152,14 +152,14 @@ suite('AgentSideEffects', () => { session: sessionUri, type: 'permission_request', requestId: 'perm-1', - permissionKind: 'write', + permissionKind: PermissionKind.Write, path: 'file.ts', rawRequest: '{}', }); // Now resolve it sideEffects.handleAction({ - type: 'session/permissionResolved', + type: ActionType.SessionPermissionResolved, session: sessionUri.toString(), turnId: 'turn-1', requestId: 'perm-1', @@ -177,7 +177,7 @@ suite('AgentSideEffects', () => { test('calls changeModel on the agent', async () => { setupSession(); sideEffects.handleAction({ - type: 'session/modelChanged', + type: ActionType.SessionModelChanged, session: sessionUri.toString(), model: 'gpt-5', }); @@ -202,7 +202,7 @@ suite('AgentSideEffects', () => { agent.fireProgress({ session: sessionUri, type: 'delta', messageId: 'msg-1', content: 'hi' }); - assert.ok(envelopes.some(e => e.action.type === 'session/delta')); + assert.ok(envelopes.some(e => e.action.type === ActionType.SessionDelta)); }); test('returns a disposable that stops listening', () => { @@ -214,11 +214,11 @@ suite('AgentSideEffects', () => { const listener = sideEffects.registerProgressListener(agent); agent.fireProgress({ session: sessionUri, type: 'delta', messageId: 'msg-1', content: 'before' }); - assert.strictEqual(envelopes.filter(e => e.action.type === 'session/delta').length, 1); + assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.SessionDelta).length, 1); listener.dispose(); agent.fireProgress({ session: sessionUri, type: 'delta', messageId: 'msg-2', content: 'after' }); - assert.strictEqual(envelopes.filter(e => e.action.type === 'session/delta').length, 1); + assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.SessionDelta).length, 1); }); }); @@ -232,7 +232,7 @@ suite('AgentSideEffects', () => { await sideEffects.handleCreateSession({ session: sessionUri.toString(), provider: 'mock' }); - const ready = envelopes.find(e => e.action.type === 'session/ready'); + const ready = envelopes.find(e => e.action.type === ActionType.SessionReady); assert.ok(ready, 'should dispatch session/ready'); }); @@ -318,7 +318,7 @@ suite('AgentSideEffects', () => { // Model fetch is async — wait for it await new Promise(r => setTimeout(r, 50)); - const action = envelopes.find(e => e.action.type === 'root/agentsChanged'); + const action = envelopes.find(e => e.action.type === ActionType.RootAgentsChanged); assert.ok(action, 'should dispatch root/agentsChanged'); }); }); diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 6ddf3ac28c3..bea9ddcff28 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -6,6 +6,7 @@ import { Emitter } from '../../../../base/common/event.js'; import { URI } from '../../../../base/common/uri.js'; import { AgentSession, type AgentProvider, type IAgent, type IAgentAttachment, type IAgentCreateSessionConfig, type IAgentDescriptor, type IAgentMessageEvent, type IAgentModelInfo, type IAgentProgressEvent, type IAgentSessionMetadata, type IAgentToolCompleteEvent, type IAgentToolStartEvent } from '../../common/agentService.js'; +import { PermissionKind } from '../../common/state/sessionState.js'; /** * General-purpose mock agent for unit tests. Tracks all method calls @@ -149,10 +150,10 @@ export class ScriptedMockAgent implements IAgent { type: 'permission_request', session, requestId: 'perm-1', - permissionKind: 'shell', + permissionKind: PermissionKind.Shell, fullCommandText: 'echo test', intention: 'Run a test command', - rawRequest: JSON.stringify({ permissionKind: 'shell', fullCommandText: 'echo test', intention: 'Run a test command' }), + rawRequest: JSON.stringify({ permissionKind: PermissionKind.Shell, fullCommandText: 'echo test', intention: 'Run a test command' }), }; setTimeout(() => this._onDidSessionProgress.fire(permEvent), 10); this._pendingPermissions.set('perm-1', (approved) => { diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 3c6a3b83fed..9385ce8f6b7 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -9,7 +9,7 @@ import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; -import type { ISessionAction } from '../../common/state/sessionActions.js'; +import { ActionType, type ISessionAction } from '../../common/state/sessionActions.js'; import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, type ICreateSessionParams, type IInitializeResult, type IProtocolMessage, type IAhpNotification, type IReconnectResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; import { SessionStatus, type ISessionSummary } from '../../common/state/sessionState.js'; import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js'; @@ -206,7 +206,7 @@ suite('ProtocolServerHandler', () => { test('client action is dispatched and echoed', () => { stateManager.createSession(makeSessionSummary()); - stateManager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); const transport = connectClient('client-1', [sessionUri]); transport.sent.length = 0; @@ -214,7 +214,7 @@ suite('ProtocolServerHandler', () => { transport.simulateMessage(notification('dispatchAction', { clientSeq: 1, action: { - type: 'session/turnStarted', + type: ActionType.SessionTurnStarted, session: sessionUri, turnId: 'turn-1', userMessage: { text: 'hello' }, @@ -224,7 +224,7 @@ suite('ProtocolServerHandler', () => { const actionMsgs = findNotifications(transport.sent, 'action'); const turnStarted = actionMsgs.find(m => { const envelope = m.params as unknown as { action: { type: string } }; - return envelope.action.type === 'session/turnStarted'; + return envelope.action.type === ActionType.SessionTurnStarted; }); assert.ok(turnStarted, 'should have echoed turnStarted'); const envelope = turnStarted!.params as unknown as { origin: { clientId: string; clientSeq: number } }; @@ -234,7 +234,7 @@ suite('ProtocolServerHandler', () => { test('actions are scoped to subscribed sessions', () => { stateManager.createSession(makeSessionSummary()); - stateManager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); const transportA = connectClient('client-a', [sessionUri]); const transportB = connectClient('client-b'); @@ -243,7 +243,7 @@ suite('ProtocolServerHandler', () => { transportB.sent.length = 0; stateManager.dispatchServerAction({ - type: 'session/titleChanged', + type: ActionType.SessionTitleChanged, session: sessionUri, title: 'New Title', }); @@ -267,15 +267,15 @@ suite('ProtocolServerHandler', () => { test('reconnect replays missed actions', () => { stateManager.createSession(makeSessionSummary()); - stateManager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); const transport1 = connectClient('client-r', [sessionUri]); const resp = findResponse(transport1.sent, 1); const initSeq = (resp as { result: IInitializeResult }).result.serverSeq; transport1.simulateClose(); - stateManager.dispatchServerAction({ type: 'session/titleChanged', session: sessionUri, title: 'Title A' }); - stateManager.dispatchServerAction({ type: 'session/titleChanged', session: sessionUri, title: 'Title B' }); + stateManager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Title A' }); + stateManager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Title B' }); const transport2 = new MockProtocolTransport(); server.simulateConnection(transport2); @@ -296,13 +296,13 @@ suite('ProtocolServerHandler', () => { test('reconnect sends fresh snapshots when gap too large', () => { stateManager.createSession(makeSessionSummary()); - stateManager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); const transport1 = connectClient('client-g', [sessionUri]); transport1.simulateClose(); for (let i = 0; i < 1100; i++) { - stateManager.dispatchServerAction({ type: 'session/titleChanged', session: sessionUri, title: `Title ${i}` }); + stateManager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: `Title ${i}` }); } const transport2 = new MockProtocolTransport(); @@ -324,14 +324,14 @@ suite('ProtocolServerHandler', () => { test('client disconnect cleans up', () => { stateManager.createSession(makeSessionSummary()); - stateManager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); const transport = connectClient('client-d', [sessionUri]); transport.sent.length = 0; transport.simulateClose(); - stateManager.dispatchServerAction({ type: 'session/titleChanged', session: sessionUri, title: 'After Disconnect' }); + stateManager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'After Disconnect' }); assert.strictEqual(transport.sent.length, 0); }); diff --git a/src/vs/platform/agentHost/test/node/sessionStateManager.test.ts b/src/vs/platform/agentHost/test/node/sessionStateManager.test.ts index 989f61d6338..10b2db4d8c9 100644 --- a/src/vs/platform/agentHost/test/node/sessionStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionStateManager.test.ts @@ -8,7 +8,7 @@ import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; -import type { IActionEnvelope, INotification } from '../../common/state/sessionActions.js'; +import { ActionType, NotificationType, type IActionEnvelope, type INotification } from '../../common/state/sessionActions.js'; import { ISessionSummary, ROOT_STATE_URI, SessionLifecycle, SessionStatus, type ISessionState } from '../../common/state/sessionState.js'; import { SessionStateManager } from '../../node/sessionStateManager.js'; @@ -76,7 +76,7 @@ suite('SessionStateManager', () => { disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); manager.dispatchServerAction({ - type: 'session/ready', + type: ActionType.SessionReady, session: sessionUri, }); @@ -85,7 +85,7 @@ suite('SessionStateManager', () => { assert.strictEqual(state.lifecycle, SessionLifecycle.Ready); assert.strictEqual(envelopes.length, 1); - assert.strictEqual(envelopes[0].action.type, 'session/ready'); + assert.strictEqual(envelopes[0].action.type, ActionType.SessionReady); assert.strictEqual(envelopes[0].serverSeq, 1); assert.strictEqual(envelopes[0].origin, undefined); }); @@ -96,8 +96,8 @@ suite('SessionStateManager', () => { const envelopes: IActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); - manager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); - manager.dispatchServerAction({ type: 'session/titleChanged', session: sessionUri, title: 'Updated' }); + manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + manager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Updated' }); assert.strictEqual(envelopes.length, 2); assert.strictEqual(envelopes[0].serverSeq, 1); @@ -113,7 +113,7 @@ suite('SessionStateManager', () => { const origin = { clientId: 'renderer-1', clientSeq: 42 }; manager.dispatchClientAction( - { type: 'session/ready', session: sessionUri }, + { type: ActionType.SessionReady, session: sessionUri }, origin, ); @@ -132,7 +132,7 @@ suite('SessionStateManager', () => { assert.strictEqual(manager.getSessionState(sessionUri), undefined); assert.strictEqual(manager.getSnapshot(sessionUri), undefined); assert.strictEqual(notifications.length, 1); - assert.strictEqual(notifications[0].type, 'notify/sessionRemoved'); + assert.strictEqual(notifications[0].type, NotificationType.SessionRemoved); }); test('createSession emits sessionAdded notification', () => { @@ -142,17 +142,17 @@ suite('SessionStateManager', () => { manager.createSession(makeSessionSummary()); assert.strictEqual(notifications.length, 1); - assert.strictEqual(notifications[0].type, 'notify/sessionAdded'); + assert.strictEqual(notifications[0].type, NotificationType.SessionAdded); }); test('getActiveTurnId returns active turn id after turnStarted', () => { manager.createSession(makeSessionSummary()); - manager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); + manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); assert.strictEqual(manager.getActiveTurnId(sessionUri), undefined); manager.dispatchServerAction({ - type: 'session/turnStarted', + type: ActionType.SessionTurnStarted, session: sessionUri, turnId: 'turn-1', userMessage: { text: 'hello' }, @@ -169,19 +169,19 @@ suite('SessionStateManager', () => { test('turnStarted dispatches root/activeSessionsChanged with correct count', () => { manager.createSession(makeSessionSummary()); - manager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); + manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); const envelopes: IActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); manager.dispatchServerAction({ - type: 'session/turnStarted', + type: ActionType.SessionTurnStarted, session: sessionUri, turnId: 'turn-1', userMessage: { text: 'hello' }, }); - const activeChanged = envelopes.filter(e => e.action.type === 'root/activeSessionsChanged'); + const activeChanged = envelopes.filter(e => e.action.type === ActionType.RootActiveSessionsChanged); assert.strictEqual(activeChanged.length, 1); assert.strictEqual((activeChanged[0].action as { activeSessions: number }).activeSessions, 1); assert.strictEqual(manager.rootState.activeSessions, 1); @@ -189,9 +189,9 @@ suite('SessionStateManager', () => { test('turnComplete dispatches root/activeSessionsChanged back to 0', () => { manager.createSession(makeSessionSummary()); - manager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); + manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); manager.dispatchServerAction({ - type: 'session/turnStarted', + type: ActionType.SessionTurnStarted, session: sessionUri, turnId: 'turn-1', userMessage: { text: 'hello' }, @@ -201,12 +201,12 @@ suite('SessionStateManager', () => { disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); manager.dispatchServerAction({ - type: 'session/turnComplete', + type: ActionType.SessionTurnComplete, session: sessionUri, turnId: 'turn-1', }); - const activeChanged = envelopes.filter(e => e.action.type === 'root/activeSessionsChanged'); + const activeChanged = envelopes.filter(e => e.action.type === ActionType.RootActiveSessionsChanged); assert.strictEqual(activeChanged.length, 1); assert.strictEqual((activeChanged[0].action as { activeSessions: number }).activeSessions, 0); assert.strictEqual(manager.rootState.activeSessions, 0); @@ -216,17 +216,17 @@ suite('SessionStateManager', () => { const session2Uri = URI.from({ scheme: 'copilot', path: '/test-session-2' }).toString(); manager.createSession(makeSessionSummary(sessionUri)); manager.createSession(makeSessionSummary(session2Uri)); - manager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); - manager.dispatchServerAction({ type: 'session/ready', session: session2Uri }); + manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + manager.dispatchServerAction({ type: ActionType.SessionReady, session: session2Uri }); manager.dispatchServerAction({ - type: 'session/turnStarted', + type: ActionType.SessionTurnStarted, session: sessionUri, turnId: 'turn-1', userMessage: { text: 'a' }, }); manager.dispatchServerAction({ - type: 'session/turnStarted', + type: ActionType.SessionTurnStarted, session: session2Uri, turnId: 'turn-2', userMessage: { text: 'b' }, @@ -234,14 +234,14 @@ suite('SessionStateManager', () => { assert.strictEqual(manager.rootState.activeSessions, 2); manager.dispatchServerAction({ - type: 'session/turnComplete', + type: ActionType.SessionTurnComplete, session: sessionUri, turnId: 'turn-1', }); assert.strictEqual(manager.rootState.activeSessions, 1); manager.dispatchServerAction({ - type: 'session/turnComplete', + type: ActionType.SessionTurnComplete, session: session2Uri, turnId: 'turn-2', }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index a1ddeacc81b..6bd95dca882 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -16,10 +16,10 @@ import { IInstantiationService } from '../../../../../../platform/instantiation/ import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IAgentAttachment, AgentProvider, AgentSession, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; -import { isSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { ActionType, isSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionClientState } from '../../../../../../platform/agentHost/common/state/sessionClientState.js'; import { getToolKind, getToolLanguage } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; -import { TurnState, type IMessageAttachment } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { AttachmentType, ToolCallStatus, TurnState, type IMessageAttachment } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { IChatAgentData, IChatAgentImplementation, IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../../../common/participants/chatAgents.js'; import { IChatProgress, IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js'; @@ -265,7 +265,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const currentModel = this._clientState.getSessionState(session.toString())?.summary.model; if (currentModel !== rawModelId) { const modelAction = { - type: 'session/modelChanged' as const, + type: ActionType.SessionModelChanged as const, session: session.toString(), model: rawModelId, }; @@ -277,7 +277,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // Dispatch session/turnStarted — the server will call sendMessage on // the provider as a side effect. const turnAction = { - type: 'session/turnStarted' as const, + type: ActionType.SessionTurnStarted as const, session: session.toString(), turnId, userMessage: { @@ -355,15 +355,15 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC for (const [toolCallId, tc] of Object.entries(activeTurn.toolCalls)) { const existing = activeToolInvocations.get(toolCallId); if (!existing) { - if (tc.status === 'running' || tc.status === 'streaming' || tc.status === 'pending-confirmation') { + if (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Streaming || tc.status === ToolCallStatus.PendingConfirmation) { const invocation = toolCallStateToInvocation(tc); activeToolInvocations.set(toolCallId, invocation); progress([invocation]); } - } else if (tc.status === 'completed' || tc.status === 'cancelled') { + } else if (tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Cancelled) { activeToolInvocations.delete(toolCallId); finalizeToolInvocation(existing, tc); - } else if (tc.status === 'running' || tc.status === 'pending-confirmation') { + } else if (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.PendingConfirmation) { // Tool transitioned from streaming to ready — update the invocation // with the now-available invocationMessage and toolSpecificData. existing.invocationMessage = typeof tc.invocationMessage === 'string' @@ -392,7 +392,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const approved = reason.type !== ToolConfirmKind.Denied && reason.type !== ToolConfirmKind.Skipped; this._logService.info(`[AgentHost] Permission response: requestId=${requestId}, approved=${approved}`); const resolveAction = { - type: 'session/permissionResolved' as const, + type: ActionType.SessionPermissionResolved as const, session: session.toString(), turnId, requestId, @@ -414,7 +414,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC turnDisposables.add(cancellationToken.onCancellationRequested(() => { this._logService.info(`[AgentHost] Cancellation requested for ${session.toString()}, dispatching turnCancelled`); const cancelAction = { - type: 'session/turnCancelled' as const, + type: ActionType.SessionTurnCancelled as const, session: session.toString(), turnId, }; @@ -481,17 +481,17 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (v.kind === 'file') { const uri = v.value instanceof URI ? v.value : undefined; if (uri?.scheme === 'file') { - attachments.push({ type: 'file', path: uri.fsPath, displayName: v.name }); + attachments.push({ type: AttachmentType.File, path: uri.fsPath, displayName: v.name }); } } else if (v.kind === 'directory') { const uri = v.value instanceof URI ? v.value : undefined; if (uri?.scheme === 'file') { - attachments.push({ type: 'directory', path: uri.fsPath, displayName: v.name }); + attachments.push({ type: AttachmentType.Directory, path: uri.fsPath, displayName: v.name }); } } else if (v.kind === 'implicit' && v.isSelection) { const uri = v.uri; if (uri?.scheme === 'file') { - attachments.push({ type: 'selection', path: uri.fsPath, displayName: v.name }); + attachments.push({ type: AttachmentType.Selection, path: uri.fsPath, displayName: v.name }); } } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 7b5cf77c3dd..bad97cd2521 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { TurnState, getToolOutputText, type ICompletedToolCall, type IPermissionRequest, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { PermissionKind, ToolCallStatus, TurnState, getToolOutputText, type ICompletedToolCall, type IPermissionRequest, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { getToolKind, getToolLanguage } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { type IChatProgress, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { type IChatSessionHistoryItem } from '../../../common/chatSessionsService.js'; @@ -49,12 +49,12 @@ export function turnsToHistory(turns: readonly ITurn[], participantId: string): */ function completedToolCallToSerialized(tc: ICompletedToolCall): IChatToolInvocationSerialized { const isTerminal = getToolKind(tc) === 'terminal'; - const isSuccess = tc.status === 'completed' && tc.success; + const isSuccess = tc.status === ToolCallStatus.Completed && tc.success; const invocationMsg = stringOrMarkdownToString(tc.invocationMessage) ?? ''; let toolSpecificData: IChatTerminalToolInvocationData | undefined; if (isTerminal && tc.toolInput) { - const toolOutput = tc.status === 'completed' ? getToolOutputText(tc) : undefined; + const toolOutput = tc.status === ToolCallStatus.Completed ? getToolOutputText(tc) : undefined; toolSpecificData = { kind: 'terminal', commandLine: { original: tc.toolInput }, @@ -117,7 +117,7 @@ export function toolCallStateToInvocation(tc: IToolCallState): ChatToolInvocatio if (getToolKind(tc) === 'terminal') { invocation.toolSpecificData = { kind: 'terminal', - commandLine: { original: tc.status !== 'streaming' ? (tc.toolInput ?? '') : '' }, + commandLine: { original: tc.status !== ToolCallStatus.Streaming ? (tc.toolInput ?? '') : '' }, language: getToolLanguage(tc) ?? 'shellscript', } satisfies IChatTerminalToolInvocationData; } @@ -135,7 +135,7 @@ export function permissionToConfirmation(perm: IPermissionRequest): ChatToolInvo let toolSpecificData: IChatTerminalToolInvocationData | IChatToolInputInvocationData | undefined; switch (perm.permissionKind) { - case 'shell': { + case PermissionKind.Shell: { title = perm.intention ?? 'Run command'; toolSpecificData = perm.fullCommandText ? { kind: 'terminal', @@ -144,14 +144,14 @@ export function permissionToConfirmation(perm: IPermissionRequest): ChatToolInvo } : undefined; break; } - case 'write': { + case PermissionKind.Write: { title = perm.path ? `Edit ${perm.path}` : 'Edit file'; let rawInput: unknown; try { rawInput = perm.rawRequest ? JSON.parse(perm.rawRequest) : { path: perm.path }; } catch { rawInput = { path: perm.path }; } toolSpecificData = { kind: 'input', rawInput }; break; } - case 'mcp': { + case PermissionKind.Mcp: { const toolTitle = perm.toolName ?? 'MCP Tool'; title = perm.serverName ? `${perm.serverName}: ${toolTitle}` : toolTitle; let rawInput: unknown; @@ -159,7 +159,7 @@ export function permissionToConfirmation(perm: IPermissionRequest): ChatToolInvo toolSpecificData = { kind: 'input', rawInput }; break; } - case 'read': { + case PermissionKind.Read: { title = perm.intention ?? 'Read file'; let rawInput: unknown; try { rawInput = perm.rawRequest ? JSON.parse(perm.rawRequest) : { path: perm.path, intention: perm.intention }; } catch { rawInput = { path: perm.path, intention: perm.intention }; } @@ -202,8 +202,8 @@ export function permissionToConfirmation(perm: IPermissionRequest): ChatToolInvo * protocol's tool-call state, transitioning it to the completed state. */ export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: IToolCallState): void { - const isCompleted = tc.status === 'completed'; - const isCancelled = tc.status === 'cancelled'; + const isCompleted = tc.status === ToolCallStatus.Completed; + const isCancelled = tc.status === ToolCallStatus.Cancelled; const isTerminal = invocation.toolSpecificData?.kind === 'terminal' || getToolKind(tc) === 'terminal'; if (isTerminal && (isCompleted || isCancelled)) { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 36256611e93..07344ef9636 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -16,7 +16,7 @@ import { IConfigurationService } from '../../../../../../platform/configuration/ import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; import type { IActionEnvelope, INotification, IPermissionResolvedAction, ISessionAction, ITurnStartedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { SessionLifecycle, SessionStatus, TurnState, createSessionState, ROOT_STATE_URI, type ISessionState, type ISessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { SessionLifecycle, SessionStatus, TurnState, createSessionState, ROOT_STATE_URI, PolicyState, type ISessionState, type ISessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; import { IChatAgentData, IChatAgentImplementation, IChatAgentRequest, IChatAgentService } from '../../../common/participants/chatAgents.js'; @@ -1181,8 +1181,8 @@ suite('AgentHostChatContribution', () => { test('filters out disabled models', async () => { const provider = disposables.add(new AgentHostLanguageModelProvider('agent-host-copilot', 'agent-host-copilot')); provider.updateModels([ - { provider: 'copilot', id: 'gpt-4o', name: 'GPT-4o', maxContextWindow: 128000, supportsVision: false, policyState: 'enabled' }, - { provider: 'copilot', id: 'gpt-3.5', name: 'GPT-3.5', maxContextWindow: 16000, supportsVision: false, policyState: 'disabled' }, + { provider: 'copilot', id: 'gpt-4o', name: 'GPT-4o', maxContextWindow: 128000, supportsVision: false, policyState: PolicyState.Enabled }, + { provider: 'copilot', id: 'gpt-3.5', name: 'GPT-3.5', maxContextWindow: 16000, supportsVision: false, policyState: PolicyState.Disabled }, ]); const models = await provider.provideLanguageModelChatInfo({}, CancellationToken.None); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 19b43fdf7eb..aa4337eaa49 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { TurnState, type ICompletedToolCall, type IPermissionRequest, type IToolCallRunningState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { ToolCallStatus, ToolCallConfirmationReason, PermissionKind, ToolResultContentType, TurnState, type ICompletedToolCall, type IPermissionRequest, type IToolCallRunningState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IChatToolInvocationSerialized, type IChatMarkdownContent } from '../../../common/chatService/chatService.js'; import { ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; import { turnsToHistory, toolCallStateToInvocation, permissionToConfirmation, finalizeToolInvocation } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; @@ -18,21 +18,21 @@ function createToolCallState(overrides?: Partial): IToolC toolName: 'test_tool', displayName: 'Test Tool', invocationMessage: 'Running test tool...', - status: 'running', - confirmed: 'not-needed', + status: ToolCallStatus.Running, + confirmed: ToolCallConfirmationReason.NotNeeded, ...overrides, }; } function createCompletedToolCall(overrides?: Partial): ICompletedToolCall { return { - status: 'completed', + status: ToolCallStatus.Completed, toolCallId: 'tc-1', toolName: 'test_tool', displayName: 'Test Tool', invocationMessage: 'Running test tool...', success: true, - confirmed: 'not-needed', + confirmed: ToolCallConfirmationReason.NotNeeded, pastTenseMessage: 'Ran test tool', ...overrides, } as ICompletedToolCall; @@ -54,7 +54,7 @@ function createTurn(overrides?: Partial): ITurn { function createPermission(overrides?: Partial): IPermissionRequest { return { requestId: 'perm-1', - permissionKind: 'shell', + permissionKind: PermissionKind.Shell, ...overrides, }; } @@ -103,7 +103,7 @@ suite('stateToProgressAdapter', () => { toolCalls: [createCompletedToolCall({ _meta: { toolKind: 'terminal', language: 'shellscript' }, toolInput: 'echo hello', - content: [{ type: 'text', text: 'hello' }], + content: [{ type: ToolResultContentType.Text, text: 'hello' }], success: true, })], }); @@ -157,7 +157,7 @@ suite('stateToProgressAdapter', () => { toolCalls: [createCompletedToolCall({ _meta: { toolKind: 'terminal' }, toolInput: 'bad-command', - content: [{ type: 'text', text: 'error' }], + content: [{ type: ToolResultContentType.Text, text: 'error' }], success: false, })], }); @@ -183,7 +183,7 @@ suite('stateToProgressAdapter', () => { toolName: 'my_tool', displayName: 'My Tool', invocationMessage: 'Doing stuff', - status: 'running', + status: ToolCallStatus.Running, }); const invocation = toolCallStateToInvocation(tc); @@ -217,7 +217,7 @@ suite('stateToProgressAdapter', () => { test('shell permission has terminal data', () => { const perm = createPermission({ - permissionKind: 'shell', + permissionKind: PermissionKind.Shell, fullCommandText: 'rm -rf /', intention: 'Delete everything', }); @@ -231,7 +231,7 @@ suite('stateToProgressAdapter', () => { test('mcp permission uses server + tool name as title', () => { const perm = createPermission({ - permissionKind: 'mcp', + permissionKind: PermissionKind.Mcp, serverName: 'My Server', toolName: 'my_tool', }); @@ -243,7 +243,7 @@ suite('stateToProgressAdapter', () => { test('write permission has input data', () => { const perm = createPermission({ - permissionKind: 'write', + permissionKind: PermissionKind.Write, path: '/test.ts', rawRequest: '{"path":"/test.ts","content":"hello"}', }); @@ -260,22 +260,22 @@ suite('stateToProgressAdapter', () => { const tc = createToolCallState({ _meta: { toolKind: 'terminal' }, toolInput: 'echo hi', - status: 'running', + status: ToolCallStatus.Running, }); const invocation = toolCallStateToInvocation(tc); finalizeToolInvocation(invocation, { - status: 'completed', + status: ToolCallStatus.Completed, toolCallId: 'tc-1', toolName: 'test_tool', displayName: 'Test Tool', invocationMessage: 'Running test tool...', _meta: { toolKind: 'terminal' }, toolInput: 'echo hi', - confirmed: 'not-needed', + confirmed: ToolCallConfirmationReason.NotNeeded, success: true, pastTenseMessage: 'Ran echo hi', - content: [{ type: 'text', text: 'output text' }], + content: [{ type: ToolResultContentType.Text, text: 'output text' }], }); assert.ok(invocation.toolSpecificData); @@ -287,17 +287,17 @@ suite('stateToProgressAdapter', () => { test('finalizes failed tool with error message', () => { const tc = createToolCallState({ - status: 'running', + status: ToolCallStatus.Running, }); const invocation = toolCallStateToInvocation(tc); finalizeToolInvocation(invocation, { - status: 'completed', + status: ToolCallStatus.Completed, toolCallId: 'tc-1', toolName: 'test_tool', displayName: 'Test Tool', invocationMessage: 'Running test tool...', - confirmed: 'not-needed', + confirmed: ToolCallConfirmationReason.NotNeeded, success: false, pastTenseMessage: 'Failed', error: { message: 'timeout' }, From dcc0c2235d21fd0d877e9f7f631e5aaff901043d Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Thu, 19 Mar 2026 07:18:35 +1100 Subject: [PATCH 16/24] Support forking contributed Chat Sessions (#302743) * Support forking contributed Chat Sessions * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Updates * add tests * Add controller API * Updates * Fix tests * Update docs --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../api/browser/mainThreadChatSessions.ts | 24 ++- .../workbench/api/common/extHost.protocol.ts | 4 + .../api/common/extHostChatSessions.ts | 58 +++++- .../browser/mainThreadChatSessions.test.ts | 195 +++++++++++++++++- .../chat/browser/actions/chatForkActions.ts | 80 ++++++- .../chat/browser/actions/chatTitleActions.ts | 15 +- src/vs/workbench/contrib/chat/browser/chat.ts | 1 + .../chatSessions/chatSessions.contribution.ts | 16 +- .../chat/browser/widget/chatListRenderer.ts | 3 +- .../contrib/chat/browser/widget/chatWidget.ts | 6 + .../chat/common/actions/chatContextKeys.ts | 1 + .../chat/common/chatSessionsService.ts | 24 +++ .../test/common/mockChatSessionsService.ts | 10 +- .../vscode.proposed.chatSessionsProvider.d.ts | 38 ++++ 14 files changed, 452 insertions(+), 23 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index c4eafbad378..c3c31b321d9 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -25,7 +25,7 @@ import { ChatEditorInput } from '../../contrib/chat/browser/widgetHosts/editor/c import { IChatRequestVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; import { awaitStatsForSession } from '../../contrib/chat/common/chat.js'; import { IChatContentInlineReference, IChatProgress, IChatService, ResponseModelState } from '../../contrib/chat/common/chatService/chatService.js'; -import { ChatSessionStatus, IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionProviderOptionItem, IChatSessionsService } from '../../contrib/chat/common/chatSessionsService.js'; +import { ChatSessionStatus, IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsService } from '../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { IChatModel } from '../../contrib/chat/common/model/chatModel.js'; import { isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js'; @@ -36,7 +36,7 @@ import { IEditorGroupsService } from '../../services/editor/common/editorGroupsS import { IEditorService } from '../../services/editor/common/editorService.js'; import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; -import { ChatSessionContentContextDto, ExtHostChatSessionsShape, ExtHostContext, IChatProgressDto, IChatSessionHistoryItemDto, IChatSessionItemsChange, MainContext, MainThreadChatSessionsShape } from '../common/extHost.protocol.js'; +import { ChatSessionContentContextDto, ExtHostChatSessionsShape, ExtHostContext, IChatProgressDto, IChatSessionHistoryItemDto, IChatSessionItemsChange, IChatSessionRequestHistoryItemDto, MainContext, MainThreadChatSessionsShape } from '../common/extHost.protocol.js'; export class ObservableChatSession extends Disposable implements IChatSession { @@ -68,6 +68,7 @@ export class ObservableChatSession extends Disposable implements IChatSession { history: any[], token: CancellationToken ) => Promise; + forkSession?: (request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken) => Promise; private readonly _proxy: ExtHostChatSessionsShape; private readonly _providerHandle: number; @@ -240,6 +241,13 @@ export class ObservableChatSession extends Disposable implements IChatSession { }; } + if (sessionContent.hasForkHandler && !this.forkSession) { + this.forkSession = async (request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken) => { + const result = await this._proxy.$forkChatSession(this._providerHandle, this.sessionResource, request ? this.toRequestDto(request) : undefined, token); + return revive(result) as IChatSessionItem; + }; + } + this._isInitialized = true; // Process any pending progress chunks @@ -308,6 +316,18 @@ export class ObservableChatSession extends Disposable implements IChatSession { } } + private toRequestDto(request: IChatSessionRequestHistoryItem): IChatSessionRequestHistoryItemDto { + return { + type: 'request', + id: request.id, + prompt: request.prompt, + participant: request.participant, + command: request.command, + variableData: undefined, + modelId: request.modelId, + }; + } + override dispose(): void { this._onWillDispose.fire(); this._onWillDispose.dispose(); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 96d734e6d0f..a7a52b724ba 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3590,6 +3590,8 @@ export type IChatSessionHistoryItemDto = { participant: string; }; +export type IChatSessionRequestHistoryItemDto = Extract; + export interface ChatSessionOptionUpdateDto { readonly optionId: string; readonly value: string | IChatSessionProviderOptionItem | undefined; @@ -3611,6 +3613,7 @@ export interface ChatSessionDto { history: Array; hasActiveResponseCallback: boolean; hasRequestHandler: boolean; + hasForkHandler: boolean; supportsInterruption: boolean; options?: Record; } @@ -3653,6 +3656,7 @@ export interface ExtHostChatSessionsShape { $provideChatSessionProviderOptions(providerHandle: number, token: CancellationToken): Promise; $invokeOptionGroupSearch(providerHandle: number, optionGroupId: string, query: string, token: CancellationToken): Promise; $provideHandleOptionsChange(providerHandle: number, sessionResource: UriComponents, updates: ReadonlyArray, token: CancellationToken): Promise; + $forkChatSession(providerHandle: number, sessionResource: UriComponents, request: IChatSessionRequestHistoryItemDto | undefined, token: CancellationToken): Promise>; } export interface GitRefQueryDto { diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index c7e15f34f8a..9ecd3a3a6e4 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -22,7 +22,7 @@ import { IChatNewSessionRequest, IChatSessionProviderOptionItem } from '../../co import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { IChatAgentRequest, IChatAgentResult } from '../../contrib/chat/common/participants/chatAgents.js'; import { Proxied } from '../../services/extensions/common/proxyIdentifier.js'; -import { ChatSessionContentContextDto, ChatSessionDto, ExtHostChatSessionsShape, IChatAgentProgressShape, IChatSessionProviderOptions, MainContext, MainThreadChatSessionsShape } from './extHost.protocol.js'; +import { ChatSessionContentContextDto, ChatSessionDto, ExtHostChatSessionsShape, IChatAgentProgressShape, IChatSessionRequestHistoryItemDto, IChatSessionProviderOptions, MainContext, MainThreadChatSessionsShape } from './extHost.protocol.js'; import { ChatAgentResponseStream } from './extHostChatAgents2.js'; import { CommandsConverter, ExtHostCommands } from './extHostCommands.js'; import { ExtHostLanguageModels } from './extHostLanguageModels.js'; @@ -423,6 +423,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio let isDisposed = false; let newChatSessionItemHandler: vscode.ChatSessionItemController['newChatSessionItemHandler']; + let forkHandler: vscode.ChatSessionItemController['forkHandler']; const onDidChangeChatSessionItemStateEmitter = disposables.add(new Emitter()); const collection = new ChatSessionItemCollectionImpl(controllerHandle, this._proxy); @@ -454,6 +455,8 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }, get newChatSessionItemHandler() { return newChatSessionItemHandler; }, set newChatSessionItemHandler(handler: vscode.ChatSessionItemController['newChatSessionItemHandler']) { newChatSessionItemHandler = handler; }, + get forkHandler() { return forkHandler; }, + set forkHandler(handler: vscode.ChatSessionItemController['forkHandler']) { forkHandler = handler; }, dispose: () => { isDisposed = true; disposables.dispose(); @@ -514,6 +517,8 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio throw new CancellationError(); } + const controllerData = this.getChatSessionItemController(sessionResource.scheme); + const sessionDisposables = new DisposableStore(); const sessionId = ExtHostChatSessions._sessionHandlePool++; const id = sessionResource.toString(); @@ -550,6 +555,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio title: session.title, hasActiveResponseCallback: !!session.activeResponseCallback, hasRequestHandler: !!session.requestHandler, + hasForkHandler: !!controllerData?.controller.forkHandler || !!session.forkHandler, supportsInterruption: !!capabilities?.supportsInterruptions, options: session.options, history: session.history.map(turn => { @@ -649,6 +655,56 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio return {}; } + async $forkChatSession(handle: number, sessionResourceComponents: UriComponents, request: IChatSessionRequestHistoryItemDto | undefined, token: CancellationToken): Promise> { + const sessionResource = URI.revive(sessionResourceComponents); + const entry = this._extHostChatSessions.get(sessionResource); + if (!entry) { + throw new Error(`No chat session found for resource ${sessionResource.toString()}`); + } + + const requestTurn = this.convertRequestDtoToRequestTurn(request); + + const controllerData = this.getChatSessionItemController(sessionResource.scheme); + if (controllerData?.controller.forkHandler) { + const item = await controllerData.controller.forkHandler(sessionResource, requestTurn, token); + return typeConvert.ChatSessionItem.from(item); + } + + if (!entry.sessionObj.session.forkHandler) { + throw new Error(`No fork handler for session ${sessionResource.toString()}`); + } + + const item = await entry.sessionObj.session.forkHandler(sessionResource, requestTurn, token); + return typeConvert.ChatSessionItem.from(item); + } + + private convertRequestDtoToRequestTurn(request: IChatSessionRequestHistoryItemDto | undefined): extHostTypes.ChatRequestTurn | undefined { + if (!request) { + return undefined; + } + + return new extHostTypes.ChatRequestTurn( + request.prompt, + request.command, + [], + request.participant, + [], + undefined, + request.id, + request.modelId, + ); + } + + private getChatSessionItemController(chatSessionType: string) { + for (const controllerData of this._chatSessionItemControllers.values()) { + if (controllerData.chatSessionType === chatSessionType) { + return controllerData; + } + } + + return undefined; + } + private async getModelForRequest(request: IChatAgentRequest, extension: IExtensionDescription): Promise { let model: vscode.LanguageModelChat | undefined; if (request.userSelectedModelId) { diff --git a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts index eeb2d197d82..ef00b137c84 100644 --- a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import * as sinon from 'sinon'; +import type * as vscode from 'vscode'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; @@ -19,22 +20,29 @@ import { ILogService, NullLogService } from '../../../../platform/log/common/log import { ChatSessionsService } from '../../../contrib/chat/browser/chatSessions/chatSessions.contribution.js'; import { IChatAgentRequest } from '../../../contrib/chat/common/participants/chatAgents.js'; import { IChatProgress, IChatProgressMessage, IChatService } from '../../../contrib/chat/common/chatService/chatService.js'; -import { IChatSessionProviderOptionGroup, IChatSessionsService } from '../../../contrib/chat/common/chatSessionsService.js'; +import { IChatSessionRequestHistoryItem, IChatSessionProviderOptionGroup, IChatSessionsService } from '../../../contrib/chat/common/chatSessionsService.js'; import { LocalChatSessionUri } from '../../../contrib/chat/common/model/chatUri.js'; import { ChatAgentLocation } from '../../../contrib/chat/common/constants.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { IExtHostContext } from '../../../services/extensions/common/extHostCustomers.js'; import { ExtensionHostKind } from '../../../services/extensions/common/extensionHostKind.js'; -import { IExtensionService } from '../../../services/extensions/common/extensions.js'; +import { IExtensionService, nullExtensionDescription } from '../../../services/extensions/common/extensions.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; import { mock, TestExtensionService } from '../../../test/common/workbenchTestServices.js'; import { MainThreadChatSessions, ObservableChatSession } from '../../browser/mainThreadChatSessions.js'; +import { ExtHostChatSessions } from '../../common/extHostChatSessions.js'; +import { ExtHostCommands } from '../../common/extHostCommands.js'; +import { ExtHostLanguageModels } from '../../common/extHostLanguageModels.js'; +import * as extHostTypes from '../../common/extHostTypes.js'; import { ExtHostChatSessionsShape, IChatProgressDto, IChatSessionProviderOptions } from '../../common/extHost.protocol.js'; +import { IExtHostAuthentication } from '../../common/extHostAuthentication.js'; +import { IExtHostTelemetry } from '../../common/extHostTelemetry.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; import { MockChatService } from '../../../contrib/chat/test/common/chatService/mockChatService.js'; import { IAgentSessionsService } from '../../../contrib/chat/browser/agentSessions/agentSessionsService.js'; import { IAgentSessionsModel } from '../../../contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { Event } from '../../../../base/common/event.js'; +import { AnyCallRPCProtocol } from '../common/testRPCProtocol.js'; suite('ObservableChatSession', function () { let disposables: DisposableStore; @@ -63,6 +71,7 @@ suite('ObservableChatSession', function () { $refreshChatSessionItems: sinon.stub(), $onDidChangeChatSessionItemState: sinon.stub(), $newChatSessionItem: sinon.stub().resolves(undefined), + $forkChatSession: sinon.stub().resolves(undefined), }; }); @@ -79,13 +88,15 @@ suite('ObservableChatSession', function () { history?: any[]; hasActiveResponseCallback?: boolean; hasRequestHandler?: boolean; + hasForkHandler?: boolean; } = {}) { return { id: options.id || 'test-id', title: options.title, history: options.history || [], - hasActiveResponseCallback: options.hasActiveResponseCallback || false, - hasRequestHandler: options.hasRequestHandler || false + hasActiveResponseCallback: options.hasActiveResponseCallback ?? false, + hasRequestHandler: options.hasRequestHandler ?? false, + hasForkHandler: options.hasForkHandler ?? false }; } @@ -163,6 +174,49 @@ suite('ObservableChatSession', function () { assert.ok(session.requestHandler); }); + test('initialization sets forkSession and revives forked items', async function () { + const session = disposables.add(await createInitializedSession(createSessionContent({ hasForkHandler: true }))); + assert.ok(session.forkSession); + + const forkedResource = URI.file('/tmp/forked-chat.md'); + const forkedItem = { + resource: forkedResource, + label: 'Forked Session', + timing: { + created: 123, + lastRequestStarted: 234, + lastRequestEnded: 345, + }, + changes: [{ + uri: URI.file('/tmp/changed.ts'), + originalUri: URI.file('/tmp/original.ts'), + insertions: 4, + deletions: 2, + }], + }; + (proxy.$forkChatSession as sinon.SinonStub).resolves(forkedItem); + + const request: IChatSessionRequestHistoryItem = { type: 'request', id: 'request-1', prompt: 'Previous question', participant: 'participant' }; + const expectedRequestDto = { + type: 'request', + id: 'request-1', + prompt: 'Previous question', + participant: 'participant', + command: undefined, + variableData: undefined, + modelId: undefined, + }; + const result = await session.forkSession?.(request, CancellationToken.None); + + assert.ok((proxy.$forkChatSession as sinon.SinonStub).calledOnceWithExactly(1, session.sessionResource, expectedRequestDto, CancellationToken.None)); + assert.ok(result); + assert.ok(result.resource instanceof URI); + assert.ok(Array.isArray(result.changes)); + assert.ok(result.changes[0].uri instanceof URI); + assert.ok(result.changes[0].originalUri instanceof URI); + assert.deepStrictEqual(result, forkedItem); + }); + test('initialization sets title from session content', async function () { const sessionContent = createSessionContent({ title: 'My Custom Title', @@ -398,6 +452,7 @@ suite('MainThreadChatSessions', function () { $refreshChatSessionItems: sinon.stub(), $onDidChangeChatSessionItemState: sinon.stub(), $newChatSessionItem: sinon.stub().resolves(undefined), + $forkChatSession: sinon.stub().resolves(undefined), }; const extHostContext = new class implements IExtHostContext { @@ -808,3 +863,135 @@ suite('MainThreadChatSessions', function () { mainThread.$unregisterChatSessionContentProvider(1); }); }); + +suite('ExtHostChatSessions', function () { + let disposables: DisposableStore; + let extHostChatSessions: ExtHostChatSessions; + let mainThreadChatSessionsProxy: { + $registerChatSessionItemController: sinon.SinonStub; + $unregisterChatSessionItemController: sinon.SinonStub; + $updateChatSessionItems: sinon.SinonStub; + $addOrUpdateChatSessionItem: sinon.SinonStub; + $onDidCommitChatSessionItem: sinon.SinonStub; + $registerChatSessionContentProvider: sinon.SinonStub; + $unregisterChatSessionContentProvider: sinon.SinonStub; + $onDidChangeChatSessionOptions: sinon.SinonStub; + $onDidChangeChatSessionProviderOptions: sinon.SinonStub; + }; + + setup(function () { + disposables = new DisposableStore(); + mainThreadChatSessionsProxy = { + $registerChatSessionItemController: sinon.stub(), + $unregisterChatSessionItemController: sinon.stub(), + $updateChatSessionItems: sinon.stub().resolves(), + $addOrUpdateChatSessionItem: sinon.stub().resolves(), + $onDidCommitChatSessionItem: sinon.stub(), + $registerChatSessionContentProvider: sinon.stub(), + $unregisterChatSessionContentProvider: sinon.stub(), + $onDidChangeChatSessionOptions: sinon.stub(), + $onDidChangeChatSessionProviderOptions: sinon.stub(), + }; + + const rpcProtocol = AnyCallRPCProtocol(mainThreadChatSessionsProxy); + const commands = new ExtHostCommands(rpcProtocol, new NullLogService(), new class extends mock() { }); + const languageModels = new ExtHostLanguageModels(rpcProtocol, new NullLogService(), new class extends mock() { }); + + extHostChatSessions = disposables.add(new ExtHostChatSessions(commands, languageModels, rpcProtocol, new NullLogService())); + }); + + teardown(function () { + disposables.dispose(); + sinon.restore(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + function createContentProvider(session: vscode.ChatSession): vscode.ChatSessionContentProvider { + return { + provideChatSessionContent: async () => session, + }; + } + + test('advertises controller fork support when only the controller registers a fork handler', async function () { + const sessionScheme = 'test-session-type'; + const sessionResource = URI.parse(`${sessionScheme}:/test-session`); + const controller = disposables.add(extHostChatSessions.createChatSessionItemController(nullExtensionDescription, sessionScheme, async () => { })); + controller.forkHandler = async resource => controller.createChatSessionItem(resource.with({ path: '/forked-session' }), 'Forked Session'); + + disposables.add(extHostChatSessions.registerChatSessionContentProvider(nullExtensionDescription, sessionScheme, undefined!, createContentProvider({ + history: [], + requestHandler: undefined, + }))); + + const session = await extHostChatSessions.$provideChatSessionContent(0, sessionResource, { initialSessionOptions: [] }, CancellationToken.None); + + assert.strictEqual(session.hasForkHandler, true); + await extHostChatSessions.$disposeChatSessionContent(0, sessionResource); + }); + + test('prefers controller fork handler over deprecated session fork handler', async function () { + const sessionScheme = 'test-session-type'; + const sessionResource = URI.parse(`${sessionScheme}:/test-session`); + const requestTurn = new extHostTypes.ChatRequestTurn('prompt', undefined, [], 'participant', [], undefined, 'request-1'); + const controller = disposables.add(extHostChatSessions.createChatSessionItemController(nullExtensionDescription, sessionScheme, async () => { })); + const controllerItem = controller.createChatSessionItem(URI.parse(`${sessionScheme}:/forked-by-controller`), 'Forked by Controller'); + const sessionItem = { + resource: URI.parse(`${sessionScheme}:/forked-by-session`), + label: 'Forked by Session' + }; + + const controllerForkHandler = sinon.stub().resolves(controllerItem); + const deprecatedSessionForkHandler = sinon.stub().resolves(sessionItem); + controller.forkHandler = controllerForkHandler; + + disposables.add(extHostChatSessions.registerChatSessionContentProvider(nullExtensionDescription, sessionScheme, undefined!, createContentProvider({ + history: [requestTurn], + requestHandler: undefined, + forkHandler: deprecatedSessionForkHandler, + }))); + + await extHostChatSessions.$provideChatSessionContent(0, sessionResource, { initialSessionOptions: [] }, CancellationToken.None); + const result = await extHostChatSessions.$forkChatSession(0, sessionResource, { + type: 'request', + id: 'request-1', + prompt: 'prompt', + participant: 'participant', + }, CancellationToken.None); + + assert.ok(controllerForkHandler.calledOnceWithExactly(sessionResource, requestTurn, CancellationToken.None)); + assert.strictEqual(deprecatedSessionForkHandler.callCount, 0); + assert.strictEqual(result.resource.toString(), controllerItem.resource.toString()); + assert.strictEqual(result.label, controllerItem.label); + await extHostChatSessions.$disposeChatSessionContent(0, sessionResource); + }); + + test('falls back to deprecated session fork handler when no controller fork handler exists', async function () { + const sessionScheme = 'test-session-type'; + const sessionResource = URI.parse(`${sessionScheme}:/test-session`); + const requestTurn = new extHostTypes.ChatRequestTurn('prompt', undefined, [], 'participant', [], undefined, 'request-1'); + const deprecatedSessionForkHandler = sinon.stub().resolves({ + resource: URI.parse(`${sessionScheme}:/forked-by-session`), + label: 'Forked by Session' + }); + + disposables.add(extHostChatSessions.registerChatSessionContentProvider(nullExtensionDescription, sessionScheme, undefined!, createContentProvider({ + history: [requestTurn], + requestHandler: undefined, + forkHandler: deprecatedSessionForkHandler, + }))); + + await extHostChatSessions.$provideChatSessionContent(0, sessionResource, { initialSessionOptions: [] }, CancellationToken.None); + const result = await extHostChatSessions.$forkChatSession(0, sessionResource, { + type: 'request', + id: 'request-1', + prompt: 'prompt', + participant: 'participant', + }, CancellationToken.None); + + assert.ok(deprecatedSessionForkHandler.calledOnceWithExactly(sessionResource, requestTurn, CancellationToken.None)); + assert.strictEqual(result.resource.toString(), `${sessionScheme}:/forked-by-session`); + assert.strictEqual(result.label, 'Forked by Session'); + await extHostChatSessions.$disposeChatSessionContent(0, sessionResource); + }); +}); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts index fd008ea10c9..026866c4e4e 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { revive } from '../../../../../base/common/marshalling.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -11,10 +12,12 @@ import { localize, localize2 } from '../../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IProgressService, ProgressLocation } from '../../../../../platform/progress/common/progress.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatService, ResponseModelState } from '../../common/chatService/chatService.js'; import type { ISerializableChatData } from '../../common/model/chatModel.js'; import { isChatTreeItem, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js'; +import { IChatSessionRequestHistoryItem, IChatSessionsService } from '../../common/chatSessionsService.js'; import { CHAT_CATEGORY } from './chatActions.js'; import { ChatTreeItem, ChatViewPaneTarget, IChatWidgetService } from '../chat.js'; @@ -34,7 +37,13 @@ export function registerChatForkActions() { id: MenuId.ChatMessageCheckpoint, group: 'navigation', order: 3, - when: ContextKeyExpr.and(ChatContextKeys.isRequest, ChatContextKeys.lockedToCodingAgent.negate()) + when: ContextKeyExpr.and( + ChatContextKeys.isRequest, + ContextKeyExpr.or( + ChatContextKeys.lockedToCodingAgent.negate(), + ChatContextKeys.chatSessionSupportsFork + ) + ) } ] }); @@ -43,12 +52,22 @@ export function registerChatForkActions() { async run(accessor: ServicesAccessor, ...args: unknown[]) { const chatWidgetService = accessor.get(IChatWidgetService); const chatService = accessor.get(IChatService); + const chatSessionsService = accessor.get(IChatSessionsService); + const progressService = accessor.get(IProgressService); const forkedTitlePrefix = localize('chat.forked.titlePrefix', "Forked: "); // When invoked via /fork slash command, args[0] is a URI (sessionResource). // Fork at the last request in that session. if (URI.isUri(args[0])) { const sourceSessionResource = args[0]; + + // Check if this is a contributed session that supports forking + const contentProviderSchemes = chatSessionsService.getContentProviderSchemes(); + if (contentProviderSchemes.includes(sourceSessionResource.scheme)) { + await forkContributedChatSession(sourceSessionResource, undefined, false, chatSessionsService, chatWidgetService, progressService); + return; + } + const chatModel = chatService.getSession(sourceSessionResource); if (!chatModel) { return; @@ -117,17 +136,43 @@ export function registerChatForkActions() { return; } - const chatModel = chatService.getSession(sessionResource); - if (!chatModel) { - return; - } - // Get all requests and find the target request index const targetRequestId = isRequestVM(item) ? item.id : isResponseVM(item) ? item.requestId : undefined; if (!targetRequestId) { return; } + // Check if this is a contributed session that supports forking + const contentProviderSchemes = chatSessionsService.getContentProviderSchemes(); + if (contentProviderSchemes.includes(sessionResource.scheme)) { + const contributedSession = await chatSessionsService.getOrCreateChatSession(sessionResource, CancellationToken.None); + let request = contributedSession.history.find((entry): entry is IChatSessionRequestHistoryItem => entry.type === 'request' && entry.id === targetRequestId); + if (!request) { + const chatModel = chatService.getSession(sessionResource); + const serializedData = chatModel?.toJSON(); + for (const [, entry] of serializedData?.requests.entries() ?? []) { + if (entry.requestId === targetRequestId) { + request = { + id: entry.requestId, + type: 'request', + prompt: typeof entry.message === 'string' ? entry.message : entry.message.text, + participant: entry.agent?.id ?? '', + variableData: entry.variableData, + modelId: entry.modelId, + }; + break; + } + } + } + await forkContributedChatSession(sessionResource, request, true, chatSessionsService, chatWidgetService, progressService); + return; + } + + const chatModel = chatService.getSession(sessionResource); + if (!chatModel) { + return; + } + // Export the full session data and truncate to include only requests up to and including the target const serializedData = chatModel.toJSON(); const isRequestItem = isRequestVM(item); @@ -187,3 +232,26 @@ export function registerChatForkActions() { } }); } + +async function forkContributedChatSession(sourceSessionResource: URI, request: IChatSessionRequestHistoryItem | undefined, openForkedSessionImmediately: boolean, chatSessionsService: IChatSessionsService, chatWidgetService: IChatWidgetService, progressService: IProgressService) { + const forkedItem = await progressService.withProgress( + { location: ProgressLocation.Notification, title: localize('forking', "Forking session...") }, + async () => { + const cts = new CancellationTokenSource(); + try { + return await chatSessionsService.forkChatSession(sourceSessionResource, request, cts.token); + } finally { + cts.dispose(); + } + } + ); + if (forkedItem) { + if (openForkedSessionImmediately) { + await chatWidgetService.openSession(forkedItem.resource, ChatViewPaneTarget); + } else { + setTimeout(async () => { + await chatWidgetService.openSession(forkedItem.resource, ChatViewPaneTarget); + }, 0); + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts index 14c73c7b688..89475130ce5 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts @@ -45,12 +45,12 @@ export function registerChatTitleActions() { id: MenuId.ChatMessageFooter, group: 'navigation', order: 2, - when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ChatContextKeys.responseHasError.negate(), ContextKeyExpr.has(enableFeedbackConfig)) + when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ChatContextKeys.responseHasError.negate(), ContextKeyExpr.has(enableFeedbackConfig), ChatContextKeys.lockedToCodingAgent.negate()) }, { id: MENU_INLINE_CHAT_WIDGET_SECONDARY, group: 'navigation', order: 1, - when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ChatContextKeys.responseHasError.negate(), ContextKeyExpr.has(enableFeedbackConfig)) + when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ChatContextKeys.responseHasError.negate(), ContextKeyExpr.has(enableFeedbackConfig), ChatContextKeys.lockedToCodingAgent.negate()) }] }); } @@ -92,12 +92,12 @@ export function registerChatTitleActions() { id: MenuId.ChatMessageFooter, group: 'navigation', order: 3, - when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ContextKeyExpr.has(enableFeedbackConfig)) + when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ContextKeyExpr.has(enableFeedbackConfig), ChatContextKeys.lockedToCodingAgent.negate()) }, { id: MENU_INLINE_CHAT_WIDGET_SECONDARY, group: 'navigation', order: 2, - when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ChatContextKeys.responseHasError.negate(), ContextKeyExpr.has(enableFeedbackConfig)) + when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ChatContextKeys.responseHasError.negate(), ContextKeyExpr.has(enableFeedbackConfig), ChatContextKeys.lockedToCodingAgent.negate()) }] }); } @@ -188,12 +188,13 @@ export function registerChatTitleActions() { group: 'navigation', when: ContextKeyExpr.and( ChatContextKeys.isResponse, - ContextKeyExpr.in(ChatContextKeys.itemId.key, ChatContextKeys.lastItemId.key)) + ContextKeyExpr.in(ChatContextKeys.itemId.key, ChatContextKeys.lastItemId.key), + ChatContextKeys.lockedToCodingAgent.negate()) }, { id: MenuId.ChatEditingWidgetToolbar, group: 'navigation', - when: applyingChatEditsFailedContextKey, + when: ContextKeyExpr.and(applyingChatEditsFailedContextKey, ChatContextKeys.lockedToCodingAgent.negate()), order: 0 } ] @@ -283,7 +284,7 @@ export function registerChatTitleActions() { id: MenuId.ChatMessageFooter, group: 'navigation', isHiddenByDefault: true, - when: ContextKeyExpr.and(NOTEBOOK_IS_ACTIVE_EDITOR, ChatContextKeys.isResponse, ChatContextKeys.responseIsFiltered.negate()) + when: ContextKeyExpr.and(NOTEBOOK_IS_ACTIVE_EDITOR, ChatContextKeys.isResponse, ChatContextKeys.responseIsFiltered.negate(), ChatContextKeys.lockedToCodingAgent.negate()) } }); } diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 1ef5314da04..60e2bff6558 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -227,6 +227,7 @@ export interface IChatListItemRendererOptions { readonly noFooter?: boolean; readonly renderDetectedCommandsWithRequest?: boolean; readonly restorable?: boolean; + readonly supportsFork?: boolean; readonly editable?: boolean; readonly renderTextEditsAsSummary?: (uri: URI) => boolean; readonly referencesExpandedWhenEmptyResponse?: boolean | ((mode: ChatModeKind) => boolean); diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 74b3226e3fb..91da42a4866 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -31,7 +31,7 @@ import { ExtensionsRegistry } from '../../../../services/extensions/common/exten import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js'; import { IChatAgentAttachmentCapabilities, IChatAgentData, IChatAgentService } from '../../common/participants/chatAgents.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsWillNotifyExtensionEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsExtensionPoint, IChatSessionsService, isSessionInProgressStatus, ResolvedChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; +import { IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsWillNotifyExtensionEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsExtensionPoint, IChatSessionsService, isSessionInProgressStatus, ResolvedChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { IChatEditorOptions } from '../widgetHosts/editor/chatEditor.js'; @@ -1214,6 +1214,20 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return contribution?.supportsDelegation !== false; } + public sessionSupportsFork(sessionResource: URI): boolean { + const resolved = this._resolveResource(sessionResource); + const session = this._sessions.get(resolved); + return !!session?.session.forkSession; + } + + public async forkChatSession(sessionResource: URI, request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken): Promise { + const session = this._sessions.get(this._resolveResource(sessionResource)); + if (!session?.session.forkSession) { + throw new Error(`Session ${sessionResource.toString()} does not support forking`); + } + return session.session.forkSession(request, token); + } + public getContentProviderSchemes(): string[] { return Array.from(this._contentProviders.keys()); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 963547f9cec..7d3f433bef7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -771,8 +771,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer(ChatConfiguration.CheckpointsEnabled) - && (this.rendererOptions.restorable ?? true); + && supportsForkOrRestoration; const isPendingRequest = isRequestVM(element) && !!element.pendingKind; templateData.checkpointContainer.classList.toggle('hidden', isResponseVM(element) || isPendingRequest || !(checkpointEnabled)); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index d3910dbb606..bc73130ffed 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -289,6 +289,7 @@ export class ChatWidget extends Disposable implements IChatWidget { }; private readonly _lockedToCodingAgentContextKey: IContextKey; private readonly _lockedCodingAgentIdContextKey: IContextKey; + private readonly _chatSessionSupportsForkContextKey: IContextKey; private readonly _agentSupportsAttachmentsContextKey: IContextKey; private readonly _sessionIsEmptyContextKey: IContextKey; private readonly _hasPendingRequestsContextKey: IContextKey; @@ -405,6 +406,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this._lockedToCodingAgentContextKey = ChatContextKeys.lockedToCodingAgent.bindTo(this.contextKeyService); this._lockedCodingAgentIdContextKey = ChatContextKeys.lockedCodingAgentId.bindTo(this.contextKeyService); + this._chatSessionSupportsForkContextKey = ChatContextKeys.chatSessionSupportsFork.bindTo(this.contextKeyService); this._agentSupportsAttachmentsContextKey = ChatContextKeys.agentSupportsAttachments.bindTo(this.contextKeyService); this._sessionIsEmptyContextKey = ChatContextKeys.chatSessionIsEmpty.bindTo(this.contextKeyService); this._hasPendingRequestsContextKey = ChatContextKeys.hasPendingRequests.bindTo(this.contextKeyService); @@ -2033,6 +2035,9 @@ export class ChatWidget extends Disposable implements IChatWidget { this.onDidChangeItems(); })); this._sessionIsEmptyContextKey.set(model.getRequests().length === 0); + const supportsFork = this.chatSessionsService.sessionSupportsFork(model.sessionResource); + this._chatSessionSupportsForkContextKey.set(supportsFork); + this.listWidget?.updateRendererOptions({ supportsFork }); this._sessionHasDebugDataContextKey.set(this.chatDebugService.getEvents(model.sessionResource).length > 0); let lastSteeringCount = 0; const updatePendingRequestKeys = (announceSteering: boolean) => { @@ -2157,6 +2162,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this._lockedAgent = undefined; this._lockedToCodingAgentContextKey.set(false); this._lockedCodingAgentIdContextKey.set(''); + this._chatSessionSupportsForkContextKey.set(false); this._updateAgentCapabilitiesContextKeys(undefined); // Explicitly update the DOM to reflect unlocked state diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 58df5cc8610..64b70535c91 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -132,6 +132,7 @@ export namespace ChatContextKeys { export const agentSessionsViewerVisible = new RawContextKey('agentSessionsViewerVisible', undefined, { type: 'boolean', description: localize('agentSessionsViewerVisible', "Visibility of the agent sessions view in the chat view.") }); export const agentSessionType = new RawContextKey('chatSessionType', '', { type: 'string', description: localize('agentSessionType', "The type of the current agent session item.") }); export const chatSessionSupportsDelegation = new RawContextKey('chatSessionSupportsDelegation', true, { type: 'boolean', description: localize('chatSessionSupportsDelegation', "True when the current session type supports delegation.") }); + export const chatSessionSupportsFork = new RawContextKey('chatSessionSupportsFork', false, { type: 'boolean', description: localize('chatSessionSupportsFork', "True when the current chat session provider supports forking conversations.") }); export const agentSessionSection = new RawContextKey('agentSessionSection', '', { type: 'string', description: localize('agentSessionSection', "The section of the current agent session section item.") }); export const isArchivedAgentSession = new RawContextKey('agentSessionIsArchived', false, { type: 'boolean', description: localize('agentSessionIsArchived', "True when the agent session item is archived.") }); export const isPinnedAgentSession = new RawContextKey('agentSessionIsPinned', false, { type: 'boolean', description: localize('agentSessionIsPinned', "True when the agent session item is pinned.") }); diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index bb0fb16361a..1220250312b 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -153,6 +153,8 @@ export type IChatSessionHistoryItem = { participant: string; }; +export type IChatSessionRequestHistoryItem = Extract; + /** * The session type used for local agent chat sessions. */ @@ -197,6 +199,14 @@ export interface IChatSession extends IDisposable { history: any[], // TODO: Nail down types token: CancellationToken ) => Promise; + + /** + * Forks the session from the given request point. + * @param request The request history item to fork from, or undefined to fork from the end. + * @param token Cancellation token. + * @returns The forked session item. The promise is rejected if forking fails. + */ + forkSession?: (request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken) => Promise; } export interface IChatSessionContentProvider { @@ -324,6 +334,20 @@ export interface IChatSessionsService { */ supportsDelegationForSessionType(chatSessionType: string): boolean; + /** + * Returns whether the loaded session supports forking conversations. + */ + sessionSupportsFork(sessionResource: URI): boolean; + + /** + * Forks a contributed chat session from the given request point. + * @param sessionResource The session resource to fork. + * @param request The request history item to fork from, or undefined to fork from the end. + * @param token Cancellation token. + * @returns The forked session item, or undefined if forking failed. + */ + forkChatSession(sessionResource: URI, request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken): Promise; + readonly onDidChangeOptionGroups: Event; getOptionGroupsForSessionType(chatSessionType: string): IChatSessionProviderOptionGroup[] | undefined; diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index 68e73f1b7d5..dbf321f972e 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -9,7 +9,7 @@ import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../base/common/map.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; -import { IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsWillNotifyExtensionEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsExtensionPoint, IChatSessionsService, ResolvedChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; +import { IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsWillNotifyExtensionEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsExtensionPoint, IChatSessionsService, ResolvedChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; import { IChatModel } from '../../common/model/chatModel.js'; import { IChatAgentAttachmentCapabilities } from '../../common/participants/chatAgents.js'; import { Target } from '../../common/promptSyntax/promptTypes.js'; @@ -222,6 +222,14 @@ export class MockChatSessionsService implements IChatSessionsService { return this.contributions.find(c => c.type === chatSessionType)?.supportsDelegation !== false; } + sessionSupportsFork(_sessionResource: URI): boolean { + return false; + } + + async forkChatSession(_sessionResource: URI, _request: IChatSessionRequestHistoryItem | undefined, _token: CancellationToken): Promise { + throw new Error('Not implemented'); + } + getContentProviderSchemes(): string[] { return Array.from(this.contentProviders.keys()); } diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index efc76e3cc70..5d6b9f48d17 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -108,6 +108,20 @@ declare module 'vscode' { */ export type ChatSessionItemControllerNewItemHandler = (context: ChatSessionItemControllerNewItemHandlerContext, token: CancellationToken) => Thenable; + /** + * Extension callback invoked to fork an existing chat session item managed by a {@linkcode ChatSessionItemController}. + * + * The handler should create a new session on the provider's backend and + * return the new {@link ChatSessionItem} representing the forked session. + * + * @param sessionResource The resource of the chat session being forked. + * @param request The request turn that marks the fork point. The forked session includes all turns + * upto this request turn and includes this request turn itself. If undefined, fork the full session. + * @param token A cancellation token. + * @returns The forked session item. + */ + export type ChatSessionItemControllerForkHandler = (sessionResource: Uri, request: ChatRequestTurn2 | undefined, token: CancellationToken) => Thenable | ChatSessionItem; + /** * Manages chat sessions for a specific chat session type */ @@ -145,6 +159,14 @@ declare module 'vscode' { */ newChatSessionItemHandler?: ChatSessionItemControllerNewItemHandler; + /** + * Invoked when an existing chat session is forked. + * + * When both this handler and {@linkcode ChatSession.forkHandler} are registered, + * this handler takes precedence. + */ + forkHandler?: ChatSessionItemControllerForkHandler; + /** * Fired when an item's archived state changes. */ @@ -399,6 +421,22 @@ declare module 'vscode' { // TODO: Revisit this to align with code. // TODO: pass in options? readonly requestHandler: ChatRequestHandler | undefined; + + /** + * Handles a request to fork the session. + * + * The handler should create a new session on the provider's backend and + * return the new {@link ChatSessionItem} representing the forked session. + * + * @deprecated Use {@linkcode ChatSessionItemController.forkHandler} instead. This remains supported for backwards compatibility. + * + * @param sessionResource The resource of the chat session being forked. + * @param request The request turn that marks the fork point. The forked session includes all turns + * until this request turn and includes this request turn itself. If undefined, fork the full session. + * @param token A cancellation token. + * @returns The forked session item. + */ + readonly forkHandler?: ChatSessionItemControllerForkHandler; } /** From 98754edcba73f3cad413e0d6fb7312cd8b84e64d Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 18 Mar 2026 21:21:40 +0100 Subject: [PATCH 17/24] Disable task.notifyWindowOnTaskCompletion in sessions window (#302966) * feat - add `task.notifyWindowOnTaskCompletion` config * fix - update `task.notifyWindowOnTaskCompletion` value --- .../contrib/configuration/browser/configuration.contribution.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts index 9d11c99feca..e976e17e2eb 100644 --- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -56,6 +56,8 @@ Registry.as(Extensions.Configuration).registerDefaultCon 'search.quickOpen.includeHistory': false, + 'task.notifyWindowOnTaskCompletion': -1, + 'terminal.integrated.initialHint': false, 'workbench.editor.doubleClickTabToToggleEditorGroupSizes': 'maximize', From d4f7ac5014bb1e54a52bc620f26611e8cb8c2f9d Mon Sep 17 00:00:00 2001 From: Robo Date: Thu, 19 Mar 2026 05:26:19 +0900 Subject: [PATCH 18/24] chore: bump electron@39.8.3 (#302875) * chore: bump electron@39.8.3 * chore: bump distro --- .npmrc | 4 +- build/checksums/electron.txt | 150 +++++++++++++++++------------------ cgmanifest.json | 6 +- package-lock.json | 8 +- package.json | 2 +- remote/.npmrc | 2 +- 6 files changed, 86 insertions(+), 86 deletions(-) diff --git a/.npmrc b/.npmrc index 2e08f5efcdd..6df04ca0e7e 100644 --- a/.npmrc +++ b/.npmrc @@ -1,6 +1,6 @@ disturl="https://electronjs.org/headers" -target="39.8.2" -ms_build_id="13563792" +target="39.8.3" +ms_build_id="13586709" runtime="electron" ignore-scripts=false build_from_source="true" diff --git a/build/checksums/electron.txt b/build/checksums/electron.txt index 4364e9bfc3e..3a0e930f3f4 100644 --- a/build/checksums/electron.txt +++ b/build/checksums/electron.txt @@ -1,75 +1,75 @@ -0f8398b79fb1d6a0036be18c24caef2d48dab9e8980ff6a7f0f658e11df86ca0 *chromedriver-v39.8.2-darwin-arm64.zip -f9995e244e0c703b0c1e06bcad2b1b9feca79d4437901e3b9dfa1f635b03884b *chromedriver-v39.8.2-darwin-x64.zip -45083a530bd03781dd759720519c805c046f392d88e2404268392446f896e265 *chromedriver-v39.8.2-linux-arm64.zip -09a6548e5abc4e1589870031bf35edb00b506da10102bb5d1b52fc069b7c1b34 *chromedriver-v39.8.2-linux-armv7l.zip -713570bbe7877fa950cbb533197cfb12aa7ff85d4db7e1fc9ad6ac57ca5733c9 *chromedriver-v39.8.2-linux-x64.zip -66a0109f235f0dec7d05d95f67f3ab07edebfd3e919d093ce71115484d2cfea2 *chromedriver-v39.8.2-mas-arm64.zip -d124f6440f2ff6de9c26f8764ad461cb8daec8e150699006d2ece850f1ff7125 *chromedriver-v39.8.2-mas-x64.zip -89c57558bf892492f5945415c20dc34cf7836661ed82f0f5816081a9e85b6859 *chromedriver-v39.8.2-win32-arm64.zip -2f5452b92dd26d0262329be08ad185bee3e9ce73536337df961e2a36273e99a9 *chromedriver-v39.8.2-win32-ia32.zip -d18fcd1ee0e2905ea8775470e956cd8ccd357f5e790169820bac26b5d5e5f540 *chromedriver-v39.8.2-win32-x64.zip -b8a2b1464313aa4e3d3e70ba84604879a1e2f21b654ef1feedc244eff294e46f *electron-api.json -e2a63aff66cfae22037682db1b3bdbeb616c9070eb56eac8f0cca58ff67168dd *electron-v39.8.2-darwin-arm64-dsym-snapshot.zip -8eca78b4b567cf258a4cfe6f9277060fbc1533dffe494936cc407453d68afe1a *electron-v39.8.2-darwin-arm64-dsym.zip -540715f221cf9c286c2ba30013bae3900595950e3e32fd7650b670a70f82d472 *electron-v39.8.2-darwin-arm64-symbols.zip -1910b2b857e0ee6d2ebd57ead75c3ace7d367a6bb9ccd6a48f8d2b23d93ffe67 *electron-v39.8.2-darwin-arm64.zip -491c9092487835661006c7d9665f3293ba547af1379ea779458cc7c3a79665a0 *electron-v39.8.2-darwin-x64-dsym-snapshot.zip -4e6a5a65947b7cec21571e8cac7afcd4d548c7c98c42c1107a47451fb35b1057 *electron-v39.8.2-darwin-x64-dsym.zip -942688360848bcf4b371553096e0ad77627acbb92eeea2426ba7885c7e4949b6 *electron-v39.8.2-darwin-x64-symbols.zip -9d80221dd2621a9526047be09379e32bbfc9dd57331e41bc0826aadbb69f632a *electron-v39.8.2-darwin-x64.zip -6ca8338548b63198143e25d9be34fa729763b82b68401b4112a787cf1d08ef60 *electron-v39.8.2-linux-arm64-debug.zip -12e1cae738ee45020249c7f15a3c2fb379425f0c8b6226ce7d3f53db356f3a82 *electron-v39.8.2-linux-arm64-symbols.zip -856848216c549a783b39f8d84dd93668d71da0d804e3bba709265804e5b4ba94 *electron-v39.8.2-linux-arm64.zip -a4b19bc2da1d531c6e689c2ac82af1453d45883197021ac8fd4f25029a9cf995 *electron-v39.8.2-linux-armv7l-debug.zip -e3be10fea936d22abaf70371c093d732a330ed639931ceeb04865edbce4c48bc *electron-v39.8.2-linux-armv7l-symbols.zip -56602fe1579eec07d810389ccf3d10c3d50e994f0319048f4f3057f8b24aa97b *electron-v39.8.2-linux-armv7l.zip -89d9a1c4dd9e632ebb1d7f816e003d152d58722f4b1849ff962df2330aa55edc *electron-v39.8.2-linux-x64-debug.zip -c5ad596d3017e4b2e5c8dd8fe7b7fbfaeca97505462c157f10ceedb1782c8cc2 *electron-v39.8.2-linux-x64-symbols.zip -3977017548b5dfdf78e1342cbe251c7ee7a127e52514903e181fa92143b0fa3a *electron-v39.8.2-linux-x64.zip -559f513006663e18e65dc936c6f50add34cda8fc0d639fd861daf354f017d293 *electron-v39.8.2-mas-arm64-dsym-snapshot.zip -d98d80c47d06169790a68bc72f652c5300235d93612787a97580a3ec6201ff03 *electron-v39.8.2-mas-arm64-dsym.zip -7862973f21e05dc5619110e789ddfc8b3973ae482a3dbcd6f33dfe42c939dc3d *electron-v39.8.2-mas-arm64-symbols.zip -a7dacb1566e909407510437d030904825ad88829186dc31e364d5b7a747b4fc6 *electron-v39.8.2-mas-arm64.zip -ecb9de6b8d7c564e5b0e62f5153d9c61272ef49e7f186fb986c58e225172c2b2 *electron-v39.8.2-mas-x64-dsym-snapshot.zip -3044fa159f9ff6b9c53311a4b4561b726f544240ae9452e1aa8505ccdb08a457 *electron-v39.8.2-mas-x64-dsym.zip -1b5e30679a43faee9a671b67b7c04b9ac464469aa493ce3c14cb8f52debec0f2 *electron-v39.8.2-mas-x64-symbols.zip -dcb094d185447f8bef67c3bf5537b47c61a80e077c2482d4a1a1289121c7cad0 *electron-v39.8.2-mas-x64.zip -d8475aef9e0e5f8f77fb9e3e9547656ec4c7688a432957686761ea8a19fa1a92 *electron-v39.8.2-win32-arm64-pdb.zip -1113ba8fc6dbebbad1a6eb0c0ba3f14698e0b99c16aa9b8cf6d637408f01646a *electron-v39.8.2-win32-arm64-symbols.zip -b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.2-win32-arm64-toolchain-profile.zip -d3d478f30002a70da0bf02775436b5f865345b9a25d0e0b75e1b089560bbf7fd *electron-v39.8.2-win32-arm64.zip -2d67e61dce2d50d291305df43e7bd312c2c665b71257d71e5c8cfab6c0ec931e *electron-v39.8.2-win32-ia32-pdb.zip -b5dd932b5ca51089dec5b9830554dfda5abdcee6a3bbd69f8531ae76a27524e6 *electron-v39.8.2-win32-ia32-symbols.zip -b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.2-win32-ia32-toolchain-profile.zip -fd8270cb5ba43193d32a371263fa0cf73d112534ab852867fb86d10c6a82db39 *electron-v39.8.2-win32-ia32.zip -31f069c1ebdf46d3dc6704157e3ed60d707aec414f391b9993c6918c0b8ae0fd *electron-v39.8.2-win32-x64-pdb.zip -c732d314d4a7f44c20bcaeb6bc12a74947e7f28e16426fdbe041c9a35759e76f *electron-v39.8.2-win32-x64-symbols.zip -b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.2-win32-x64-toolchain-profile.zip -e5b2c8bda64b65e6587c2f3c97f48857fd02ab894bb7a6e4c73bd4a5bcc10416 *electron-v39.8.2-win32-x64.zip -179d2bf1b64e27cda05128656ff6bbbbd80eaf8b2ff04de3ae0999b850362785 *electron.d.ts -27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.2-darwin-arm64.zip -321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.2-darwin-x64.zip -52ae6eccbdb4a9403a6c3eb46b356a28940ec25958b6b9181fb2f38e612e40ed *ffmpeg-v39.8.2-linux-arm64.zip -622cb781fb1e3b9617e7e60c36384427f7b0d9b5ad888e9bc356a83b050e13f1 *ffmpeg-v39.8.2-linux-armv7l.zip -ba441851788008362f013bf2983b22b0042af8df31bf90123328f928cc067492 *ffmpeg-v39.8.2-linux-x64.zip -27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.2-mas-arm64.zip -321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.2-mas-x64.zip -a0b525af0aa198214ba3c29a0b41297b15618fdad8c4f5aa3c42cf6a6ab80bfa *ffmpeg-v39.8.2-win32-arm64.zip -5418269cf6fe82f3d9fc5cbbc6d6f9241462b40046a87178515a36cb45549be1 *ffmpeg-v39.8.2-win32-ia32.zip -10bbd25b3e9af36f26147410b31e6e1d928bf4c25ac28571fa1bbe4eb7fe9af9 *ffmpeg-v39.8.2-win32-x64.zip -122ba5515c3a94b272886d156f0bb174ca120a18be44e21bc8b5f586dd679b6e *hunspell_dictionaries.zip -e8aebe7d361983ce1329598f5541c4dde26d18e72228d0ab1ac526c0e1a40dfc *libcxx-objects-v39.8.2-linux-arm64.zip -0d9e646e77ed3fad4560d10f7964cf316cbdcd9a50114c9163427be2222eb35b *libcxx-objects-v39.8.2-linux-armv7l.zip -928c6ff0761f496deda96203960d1933cae1ff488483ea31283a0e8ffe36426a *libcxx-objects-v39.8.2-linux-x64.zip -c65cf035770b74a8a6be4692be704c286427e63eb577e9d10c226b600f6121cc *libcxx_headers.zip -006ccb83761a3bf5791d165bc9795e4de3308c65df646b4cbcaa61025fe7a6c5 *libcxxabi_headers.zip -b189f37011a77ce5d3b6478474172b4594fee626daa75b63da8feb9d376ad983 *mksnapshot-v39.8.2-darwin-arm64.zip -436daa4ae7ca171c51d265976ddc5a5e8ede5b7c1c9cb5467547f14cef87b0c9 *mksnapshot-v39.8.2-darwin-x64.zip -b25ee4873f0bdb9ad663446f9443aa23faeb9e4e2f2734afe47c383e66b6939b *mksnapshot-v39.8.2-linux-arm64-x64.zip -26ebf5acbec96fd08d58d3d9351c26b8cb1ded51a948e0b0513a636deaf17648 *mksnapshot-v39.8.2-linux-armv7l-x64.zip -619b5349abd00d4b7c91894114b2c2aae94d2467d912f476ebcd8c718031493b *mksnapshot-v39.8.2-linux-x64.zip -ec14eeccd924c97a2716b2de5c8279dc5ecb0588c3a333be1dfe4122d192bebc *mksnapshot-v39.8.2-mas-arm64.zip -503f0b1263ebd86c094140307c02c7da474c219b839079a59fcdb1dc1451986a *mksnapshot-v39.8.2-mas-x64.zip -5d7082a1811e11807f78ce9888b00085db92c3dd721d67f54954fd0192570826 *mksnapshot-v39.8.2-win32-arm64-x64.zip -531ea5cd112438eb9276c6327487f8b1f0845b11c080697c438406968d51a859 *mksnapshot-v39.8.2-win32-ia32.zip -5f3ab3f4c4bb7783cd59235a2fffd22ceb86afdafcecdc9492b595302e03ed3e *mksnapshot-v39.8.2-win32-x64.zip +0ab48e3e8888b5c33950be0c36da939aa989df7609d3c32140c5e5371ea53abb *chromedriver-v39.8.3-darwin-arm64.zip +b7103565ffb4068dc705c50ce3039ed3178cac350301abf82545de54ac3bc849 *chromedriver-v39.8.3-darwin-x64.zip +e7e43ee7a3d14482ce488d0b0bc338a026a00ee544e5a3d55aed220af6b5da0e *chromedriver-v39.8.3-linux-arm64.zip +060223baebe6d8f9e8c7367bf561dd558fca03509edcc3bce660c42f96ad73ea *chromedriver-v39.8.3-linux-armv7l.zip +854a6f921684e59866aed9db0e9f61d28f756f70b7898f947359b4d04dba76db *chromedriver-v39.8.3-linux-x64.zip +f70ea58bc5e4e51eec51f65e153cfd36eea568ecd571c2815a4c05a457b6923d *chromedriver-v39.8.3-mas-arm64.zip +8e3e1450bc544bff712ffab0ba365d1ed2c9b79116b4ec4750a46c8607242ed4 *chromedriver-v39.8.3-mas-x64.zip +c07e35a2a5a673c8902452571f3436ca8b774fa4628ad9e42f179d3c935f4ed7 *chromedriver-v39.8.3-win32-arm64.zip +d0361344208d8bdf58500d08ae1bb723b9ccdc66fc736c2fc6c9f011bcc6e47d *chromedriver-v39.8.3-win32-ia32.zip +e2e91fd7d97e3e9806d22c4990253cbd5e466cdfa1a8e4c86c72431f7d3a8d0f *chromedriver-v39.8.3-win32-x64.zip +f004c879e159edf3eb403bd43bc76c3069b0b375c6dfae5b249b96d543e51e26 *electron-api.json +21a5324aaed782fead97b2e50f833373148392d4c13ec818f80f142e800c6158 *electron-v39.8.3-darwin-arm64-dsym-snapshot.zip +bb9c14900f48aabb7d272149ba4b60813b366f1e67f95b510da73355e15ba78c *electron-v39.8.3-darwin-arm64-dsym.zip +8a42b50a0841e7bfefc49e704f5cfdb3cbb7b9a507ac74b9982004a9350a202d *electron-v39.8.3-darwin-arm64-symbols.zip +e1b9e03a56fc27ad567c8d2bb32a21e0e2afe6a095f71c26df5b8b8ed8dd8d4c *electron-v39.8.3-darwin-arm64.zip +5b474116e398286a80def6509fa255481ab88fbb52b1770dfd5d39ddff124c6b *electron-v39.8.3-darwin-x64-dsym-snapshot.zip +14648a98eef5a28c1158f0580a813617d9ce6d77a8b7881c389acfff34d328cd *electron-v39.8.3-darwin-x64-dsym.zip +231e13b26c39cceecec359e74c00e4d6a13de3ae9fb6459f18846f91f214074f *electron-v39.8.3-darwin-x64-symbols.zip +22cf6f6147d5d632e2a8ad5207504a18db94a8c96e3f4f65f792822eaed7bf1c *electron-v39.8.3-darwin-x64.zip +fdf25df8857e1ef2cdb0a5be71b78dfb9923a6061cf11336577c6a4368ecfdcd *electron-v39.8.3-linux-arm64-debug.zip +731bf3f908a1efe871e862852087b67027c791427284f057d42376634d4d53d3 *electron-v39.8.3-linux-arm64-symbols.zip +e1a0e6939fe2d10c1f807888f74dbbb9f28a2cfc25e28bb8168f5513513fc124 *electron-v39.8.3-linux-arm64.zip +65893fd03097eadb0c89eb95ded97e97a9910bbc53634f12170cfb40b9165832 *electron-v39.8.3-linux-armv7l-debug.zip +997acce3540d16f9e0551cde811021999a4276c970bfe42ed77c3fd769ba6d05 *electron-v39.8.3-linux-armv7l-symbols.zip +5d5825966a3b2678c50121c81ed3fb8c39d35c3798dd0413a19afaac04109ef9 *electron-v39.8.3-linux-armv7l.zip +52b44ef60f73ef7b7c8461f520a1048da3601d9cc869262ec63f507cd6591e78 *electron-v39.8.3-linux-x64-debug.zip +c4e1fa21d21724ab7f5bcdb6c1bfc03dca837ebcca00d6af56944041499d35a9 *electron-v39.8.3-linux-x64-symbols.zip +5866d6c6f8fcf15967279107d2387edfa4589c5a00ad52d4b770d7504106a734 *electron-v39.8.3-linux-x64.zip +3ff4c9fb99f40dda465486fa6fa23eaedd89b87dcd9cce402a171accfcacc9bd *electron-v39.8.3-mas-arm64-dsym-snapshot.zip +9401101eabaf5e55063b9fad94bf3ac2fe9d743ff88ec638a3c6c665b2266564 *electron-v39.8.3-mas-arm64-dsym.zip +0905e57da501b64436dff51b1378bae311cb1276372dc39dedb7aef44f1b947a *electron-v39.8.3-mas-arm64-symbols.zip +1af2cdee3405c0b8e1c8145a65891b249ac737dd35d959cebd6833970ad5eb08 *electron-v39.8.3-mas-arm64.zip +9e8c6b7b880ac726cda52aaf2b02bc9f0750559be85ef1583789d52b617914b9 *electron-v39.8.3-mas-x64-dsym-snapshot.zip +483ce280606a61c7ed4394e99008ad6fc7e2ce9c35149c5ad745bce9ee78a7a2 *electron-v39.8.3-mas-x64-dsym.zip +1e16529ab1ee8404b1623df611077abcbbbabc1f825a57e93e2ef2b1f7ba788a *electron-v39.8.3-mas-x64-symbols.zip +19df399b352db2c3b3f26f830700b17adf697b70e4d361b1e0f20790e6e703b6 *electron-v39.8.3-mas-x64.zip +bdfd01e7c55ea4beb90afd4285ab3639e3a66808ec993389e9eaf62c3edcb5e9 *electron-v39.8.3-win32-arm64-pdb.zip +7329264c9d308a78081509cb4173f0bd931522655d2f434ad858555e735e5721 *electron-v39.8.3-win32-arm64-symbols.zip +b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.3-win32-arm64-toolchain-profile.zip +699933ff8c4d7216fc0318f239a5f593f06487c0dc9c3722b8744f6a44fca94e *electron-v39.8.3-win32-arm64.zip +1ffab5a8419a1a93476e2edc09754a52bbe9f3d39915e097f2a1ee50ffdbbd13 *electron-v39.8.3-win32-ia32-pdb.zip +6445047728d64a09db80205c24135f140ba60c25433d833f581c57092638b875 *electron-v39.8.3-win32-ia32-symbols.zip +b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.3-win32-ia32-toolchain-profile.zip +b80bb96a4eda2c2b6bd223d2d8b6abfc39abdebac0b36cf74cd70661d43258a5 *electron-v39.8.3-win32-ia32.zip +c8e3cab205bdfe42f916a4428fe0a5e88b6f90e8482e297dadfe1234420abb8f *electron-v39.8.3-win32-x64-pdb.zip +eda4a2f01e388eccfc2ecc7587b0987d123ae01e5b832b73a0a76bb62680bd7c *electron-v39.8.3-win32-x64-symbols.zip +b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.3-win32-x64-toolchain-profile.zip +12eabd7c5f08823525034c1ff3ab286f271af802928e0f224b458235e2689c5a *electron-v39.8.3-win32-x64.zip +d5345fc0cb336a425f7a25885f67969452746cbf30cc1e95449f7a68221aab07 *electron.d.ts +27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.3-darwin-arm64.zip +321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.3-darwin-x64.zip +52ae6eccbdb4a9403a6c3eb46b356a28940ec25958b6b9181fb2f38e612e40ed *ffmpeg-v39.8.3-linux-arm64.zip +622cb781fb1e3b9617e7e60c36384427f7b0d9b5ad888e9bc356a83b050e13f1 *ffmpeg-v39.8.3-linux-armv7l.zip +ba441851788008362f013bf2983b22b0042af8df31bf90123328f928cc067492 *ffmpeg-v39.8.3-linux-x64.zip +27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.3-mas-arm64.zip +321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.3-mas-x64.zip +06d402e51bf66fd1a0eddc7e8329b31eb8a1bc6c829e5bc13694708a010feb07 *ffmpeg-v39.8.3-win32-arm64.zip +4b1c2ddedebbf32b7792fb788ddce577f2dc6f8ecccd913e72e842068b2f81e5 *ffmpeg-v39.8.3-win32-ia32.zip +0cf0f521a452d6fdfe1313b81284d646e991954e91c356d805f5c066f2ecf278 *ffmpeg-v39.8.3-win32-x64.zip +bb2d6ec64c43e5a41da5bc55a3daa2b7ef8a0dee722dc73f4fa31bcae5487cb2 *hunspell_dictionaries.zip +7fd663a5eeaf4d0a93785ea4ea715d21464c70c1341b7d8629c96a7bfe24044a *libcxx-objects-v39.8.3-linux-arm64.zip +bdd7e9f732b97b6be2c8293d9391bce3a5cd60feae1c762a0dda0790493da7a2 *libcxx-objects-v39.8.3-linux-armv7l.zip +0de009f84fed7c1bba087ff161674177ca91951fca2f4c60850be0bffb42dfdf *libcxx-objects-v39.8.3-linux-x64.zip +8ae7fd5c3bdc332f9f49830a9316e470d43f17e6ad6adbd05ac629d03d1718c2 *libcxx_headers.zip +9b988e2bb379c6d3094872f600944ad3284510cf225f86368c4f43270b89673c *libcxxabi_headers.zip +d504296ed183e3f460028a73b4a5e2bcd99bc4a3c74b8dc73ba987719c005458 *mksnapshot-v39.8.3-darwin-arm64.zip +b18b8a0e902cf86961d53486826fb07feb3ac98e018b2849cf2bb13150077b13 *mksnapshot-v39.8.3-darwin-x64.zip +7d9dc2ceb3f88d8d532af5b90387479ade571a0370489429571871d386c12322 *mksnapshot-v39.8.3-linux-arm64-x64.zip +84114ba67259f52ae462210544e815b602d70b71162f7f70982a7c36db54b4fa *mksnapshot-v39.8.3-linux-armv7l-x64.zip +be08392f0964d2166bb84212d225c5380fde9b12e622599cb040f45524ff7882 *mksnapshot-v39.8.3-linux-x64.zip +ffebb01a6fe568ec51391f9585e8abf1a93a566a7c991b2abacc33cc2e94d705 *mksnapshot-v39.8.3-mas-arm64.zip +858a8ee80b6f826b1d24a9458b8acb0fcc9805ee3c309652d60ed5c07b578113 *mksnapshot-v39.8.3-mas-x64.zip +dbc82c573f1ba098b6d321ad79c6580f27066d94e13fd93c0b7650a54150eb5c *mksnapshot-v39.8.3-win32-arm64-x64.zip +c8f7c43741b20da99558db596d32c86ebff3483aaf6b3e2539cd85a471b92043 *mksnapshot-v39.8.3-win32-ia32.zip +000941eeb8e1169d581120df7d42aa0b76e33de99a06528a9c4767bcffb74cbf *mksnapshot-v39.8.3-win32-x64.zip diff --git a/cgmanifest.json b/cgmanifest.json index 2502a1b1ed7..281bfb40dc8 100644 --- a/cgmanifest.json +++ b/cgmanifest.json @@ -529,13 +529,13 @@ "git": { "name": "electron", "repositoryUrl": "https://github.com/electron/electron", - "commitHash": "8e0f534873e9fdba5b365879bbdf6b47a0a64e1d", - "tag": "39.8.2" + "commitHash": "e6928c13198c854aa014c319d72eea599e2e0ee7", + "tag": "39.8.3" } }, "isOnlyProductionDependency": true, "license": "MIT", - "version": "39.8.2" + "version": "39.8.3" }, { "component": { diff --git a/package-lock.json b/package-lock.json index c868d582c66..1a01c002543 100644 --- a/package-lock.json +++ b/package-lock.json @@ -106,7 +106,7 @@ "cookie": "^0.7.2", "debounce": "^1.0.0", "deemon": "^1.13.6", - "electron": "39.8.2", + "electron": "39.8.3", "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", @@ -8640,9 +8640,9 @@ "dev": true }, "node_modules/electron": { - "version": "39.8.2", - "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.2.tgz", - "integrity": "sha512-uwNJHeqm8pzQEZf/KX4XM1fJctZpHcA0Za/MlP9mOg0FAWHbKo6yRC33QbdfLX7PeNjYZC3I3nnVhE5L2CLqxw==", + "version": "39.8.3", + "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.3.tgz", + "integrity": "sha512-ZhetvWz2qbI2WbBHdK/utR8I5bi1pYWJdit9tP0sGzs42CpsAFyu/FirXE88NWSh+3U8X6Wuf9jjDEYvAyrxNw==", "dev": true, "hasInstallScript": true, "license": "MIT", diff --git a/package.json b/package.json index 2e7577e48b3..fb4e1fdd1eb 100644 --- a/package.json +++ b/package.json @@ -176,7 +176,7 @@ "cookie": "^0.7.2", "debounce": "^1.0.0", "deemon": "^1.13.6", - "electron": "39.8.2", + "electron": "39.8.3", "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", diff --git a/remote/.npmrc b/remote/.npmrc index 8310ec94634..7c6849a8708 100644 --- a/remote/.npmrc +++ b/remote/.npmrc @@ -1,6 +1,6 @@ disturl="https://nodejs.org/dist" target="22.22.1" -ms_build_id="420065" +ms_build_id="420922" runtime="node" build_from_source="true" legacy-peer-deps="true" From f9f6900d6ef12b126387b63c36bd2483ba5c1e35 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 18 Mar 2026 13:34:43 -0700 Subject: [PATCH 19/24] comments --- .../agentHost/common/state/sessionClientState.ts | 13 ++++++++----- .../platform/agentHost/node/sessionStateManager.ts | 6 ++++-- .../browser/remoteAgentHost.contribution.ts | 5 +++-- .../agentHost/agentHostChatContribution.ts | 2 +- .../agentHost/agentHostSessionHandler.ts | 2 +- 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/vs/platform/agentHost/common/state/sessionClientState.ts b/src/vs/platform/agentHost/common/state/sessionClientState.ts index 744ab7611a1..f40695bcf05 100644 --- a/src/vs/platform/agentHost/common/state/sessionClientState.ts +++ b/src/vs/platform/agentHost/common/state/sessionClientState.ts @@ -21,6 +21,7 @@ import { URI } from '../../../../base/common/uri.js'; import { IActionEnvelope, INotification, ISessionAction, isRootAction, isSessionAction, IStateAction } from './sessionActions.js'; import { rootReducer, sessionReducer } from './sessionReducers.js'; import { IRootState, ISessionState, ROOT_STATE_URI } from './sessionState.js'; +import { ILogService } from '../../../log/common/log.js'; // ---- Pending action tracking ------------------------------------------------ @@ -49,6 +50,7 @@ interface IPendingAction { export class SessionClientState extends Disposable { private readonly _clientId: string; + private readonly _log: (msg: string) => void; private _nextClientSeq = 1; private _lastSeenServerSeq = 0; @@ -72,9 +74,10 @@ export class SessionClientState extends Disposable { private readonly _onDidReceiveNotification = this._register(new Emitter()); readonly onDidReceiveNotification: Event = this._onDidReceiveNotification.event; - constructor(clientId: string) { + constructor(clientId: string, logService: ILogService) { super(); this._clientId = clientId; + this._log = msg => logService.warn(`[SessionClientState] ${msg}`); } get clientId(): string { @@ -208,13 +211,13 @@ export class SessionClientState extends Disposable { private _applyToConfirmed(action: IStateAction): void { if (isRootAction(action) && this._confirmedRootState) { - this._confirmedRootState = rootReducer(this._confirmedRootState, action); + this._confirmedRootState = rootReducer(this._confirmedRootState, action, this._log); } if (isSessionAction(action)) { const key = action.session.toString(); const state = this._confirmedSessionStates.get(key); if (state) { - this._confirmedSessionStates.set(key, sessionReducer(state, action)); + this._confirmedSessionStates.set(key, sessionReducer(state, action, this._log)); } } } @@ -223,7 +226,7 @@ export class SessionClientState extends Disposable { const key = action.session.toString(); const state = this._optimisticSessionStates.get(key); if (state) { - const newState = sessionReducer(state, action); + const newState = sessionReducer(state, action, this._log); this._optimisticSessionStates.set(key, newState); this._onDidChangeSessionState.fire({ session: action.session, state: newState }); } @@ -266,7 +269,7 @@ export class SessionClientState extends Disposable { let state = confirmed; for (const pending of this._pendingActions) { if (isSessionAction(pending.action) && pending.action.session === session) { - state = sessionReducer(state, pending.action); + state = sessionReducer(state, pending.action, this._log); } } diff --git a/src/vs/platform/agentHost/node/sessionStateManager.ts b/src/vs/platform/agentHost/node/sessionStateManager.ts index 99e8382f431..c9ad9235595 100644 --- a/src/vs/platform/agentHost/node/sessionStateManager.ts +++ b/src/vs/platform/agentHost/node/sessionStateManager.ts @@ -40,6 +40,8 @@ export class SessionStateManager extends Disposable { super(); this._rootState = createRootState(); } + private readonly _log = (msg: string) => this._logService.warn(`[SessionStateManager] ${msg}`); + get hasActiveSessions(): boolean { return this._activeTurnToSession.size > 0; } @@ -173,7 +175,7 @@ export class SessionStateManager extends Disposable { let resultingState: unknown = undefined; // Apply to state if (isRootAction(action)) { - this._rootState = rootReducer(this._rootState, action as IRootAction); + this._rootState = rootReducer(this._rootState, action as IRootAction, this._log); resultingState = this._rootState; } @@ -182,7 +184,7 @@ export class SessionStateManager extends Disposable { const key = sessionAction.session; const state = this._sessionStates.get(key); if (state) { - const newState = sessionReducer(state, sessionAction); + const newState = sessionReducer(state, sessionAction, this._log); this._sessionStates.set(key, newState); // Track active turn for turn lifecycle diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index e4eca99f515..b33e9488bd1 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -51,9 +51,10 @@ class ConnectionState extends Disposable { constructor( clientId: string, readonly name: string | undefined, + logService: ILogService, ) { super(); - this.clientState = this.store.add(new SessionClientState(clientId)); + this.clientState = this.store.add(new SessionClientState(clientId, logService)); } } @@ -142,7 +143,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc return; } - const connState = new ConnectionState(connection.clientId, name); + const connState = new ConnectionState(connection.clientId, name, this._logService); this._connections.set(address, connState); const store = connState.store; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index 28130358707..55109bc513f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -66,7 +66,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr this._setupIpcLogging(); // Shared client state for protocol reconciliation - this._clientState = this._register(new SessionClientState(this._agentHostService.clientId)); + this._clientState = this._register(new SessionClientState(this._agentHostService.clientId, this._logService)); // Forward action envelopes from the host to client state this._register(this._agentHostService.onDidAction(envelope => { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 6bd95dca882..1cbfe0e23be 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -120,7 +120,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._config = config; // Create shared client state manager for this handler instance - this._clientState = this._register(new SessionClientState(config.connection.clientId)); + this._clientState = this._register(new SessionClientState(config.connection.clientId, this._logService)); // Forward action envelopes from IPC to client state this._register(config.connection.onDidAction(envelope => { From ac0c8d93ca7f69f7b168909a5d08822d23f26080 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Wed, 18 Mar 2026 20:37:22 +0000 Subject: [PATCH 20/24] Sessions - remove the "Uncommitted Changes" from the changes view (#302848) --- .../contrib/changes/browser/changesView.ts | 73 +------------------ 1 file changed, 4 insertions(+), 69 deletions(-) diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index c7d7e3bcd4b..c7fd07a94e7 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -85,14 +85,12 @@ const changesViewModeContextKey = new RawContextKey('changesVie const enum ChangesVersionMode { AllChanges = 'allChanges', - LastTurn = 'lastTurn', - Uncommitted = 'uncommitted' + LastTurn = 'lastTurn' } const changesVersionModeContextKey = new RawContextKey('sessions.changesVersionMode', ChangesVersionMode.AllChanges); const isMergeBaseBranchProtectedContextKey = new RawContextKey('sessions.isMergeBaseBranchProtected', false); const hasOpenPullRequestContextKey = new RawContextKey('sessions.hasOpenPullRequest', false); -const hasUncommittedChangesContextKey = new RawContextKey('sessions.hasUncommittedChanges', false); // --- List Item @@ -256,7 +254,6 @@ export class ChangesViewPane extends ViewPane { private readonly activeSession: IObservableWithChange; private readonly activeSessionFileCountObs: IObservableWithChange; private readonly activeSessionHasChangesObs: IObservableWithChange; - private readonly activeSessionRepositoryChangesObs: IObservableWithChange; private readonly activeSessionRepositoryObs: IObservableWithChange; get activeSessionHasChanges(): IObservable { @@ -332,33 +329,6 @@ export class ChangesViewPane extends ViewPane { return activeSessionRepositoryPromise.read(reader); }); - this.activeSessionRepositoryChangesObs = derived(reader => { - const repository = this.activeSessionRepositoryObs.read(reader); - if (!repository) { - return undefined; - } - - const state = repository.state.read(reader); - const headCommit = state?.HEAD?.commit; - return (state?.workingTreeChanges ?? []).map(change => { - const isDeletion = change.modifiedUri === undefined; - const isAddition = change.originalUri === undefined; - const fileUri = change.modifiedUri ?? change.uri; - return { - type: 'file', - uri: fileUri, - originalUri: isDeletion || !headCommit ? change.originalUri - : fileUri.with({ scheme: 'git', query: JSON.stringify({ path: fileUri.fsPath, ref: headCommit }) }), - state: ModifiedFileEntryState.Accepted, - isDeletion, - changeType: isDeletion ? 'deleted' : isAddition ? 'added' : 'modified', - reviewCommentCount: 0, - linesAdded: 0, - linesRemoved: 0, - } satisfies IChangesFileItem; - }); - }); - this.activeSessionFileCountObs = this.createActiveSessionFileCountObservable(); this.activeSessionHasChangesObs = this.activeSessionFileCountObs.map(fileCount => fileCount > 0).recomputeInitiallyAndOnChange(this._store); @@ -632,13 +602,10 @@ export class ChangesViewPane extends ViewPane { const versionMode = this.versionModeObs.read(reader); const editEntries = editSessionEntriesObs.read(reader); const sessionFiles = sessionFilesObs.read(reader); - const repositoryFiles = this.activeSessionRepositoryChangesObs.read(reader) ?? []; const lastTurnDiffChanges = lastTurnChangesObs.read(reader).read(reader); let sourceEntries: IChangesFileItem[]; - if (versionMode === ChangesVersionMode.Uncommitted) { - sourceEntries = repositoryFiles; - } else if (versionMode === ChangesVersionMode.LastTurn) { + if (versionMode === ChangesVersionMode.LastTurn) { const diffChanges = lastTurnDiffChanges ?? []; const parentRef = headCommit ? `${headCommit}^` : ''; sourceEntries = diffChanges.map(change => { @@ -661,7 +628,7 @@ export class ChangesViewPane extends ViewPane { } satisfies IChangesFileItem; }); } else { - sourceEntries = [...editEntries, ...sessionFiles, ...repositoryFiles]; + sourceEntries = [...editEntries, ...sessionFiles]; } const resources = new Set(); @@ -679,7 +646,6 @@ export class ChangesViewPane extends ViewPane { const topLevelStats = derived(reader => { const editEntries = editSessionEntriesObs.read(reader); const sessionFiles = sessionFilesObs.read(reader); - const repositoryFiles = this.activeSessionRepositoryChangesObs.read(reader) ?? []; const entries = combinedEntriesObs.read(reader); let added = 0, removed = 0; @@ -690,7 +656,7 @@ export class ChangesViewPane extends ViewPane { } const files = entries.length; - const isSessionMenu = editEntries.length === 0 && (sessionFiles.length > 0 || repositoryFiles.length > 0); + const isSessionMenu = editEntries.length === 0 && sessionFiles.length > 0; return { files, added, removed, isSessionMenu }; }); @@ -735,13 +701,6 @@ export class ChangesViewPane extends ViewPane { this.renderDisposables.add(bindContextKey(ChatContextKeys.hasAgentSessionChanges, this.scopedContextKeyService, r => hasAgentSessionChangesObs.read(r))); - const hasUncommittedChangesObs = derived(reader => { - const repositoryFiles = this.activeSessionRepositoryChangesObs.read(reader); - return (repositoryFiles?.length ?? 0) > 0; - }); - - this.renderDisposables.add(bindContextKey(hasUncommittedChangesContextKey, this.scopedContextKeyService, r => hasUncommittedChangesObs.read(r))); - const isMergeBaseBranchProtectedObs = derived(reader => { const activeSession = this.activeSession.read(reader); return activeSession?.worktreeBaseBranchProtected === true; @@ -1443,27 +1402,3 @@ class LastTurnChangesAction extends Action2 { } } registerAction2(LastTurnChangesAction); - -class UncommittedChangesAction extends Action2 { - constructor() { - super({ - id: 'chatEditing.versionsUncommittedChanges', - title: localize2('chatEditing.versionsUncommittedChanges', 'Uncommitted Changes'), - category: CHAT_CATEGORY, - toggled: changesVersionModeContextKey.isEqualTo(ChangesVersionMode.Uncommitted), - precondition: hasUncommittedChangesContextKey, - menu: [{ - id: MenuId.ChatEditingSessionChangesVersionsSubmenu, - group: '2_uncommitted', - order: 1, - }], - }); - } - - override async run(accessor: ServicesAccessor): Promise { - const viewsService = accessor.get(IViewsService); - const view = viewsService.getActiveViewWithId(CHANGES_VIEW_ID); - view?.setVersionMode(ChangesVersionMode.Uncommitted); - } -} -registerAction2(UncommittedChangesAction); From b4d3ecb42354bc0551d1cd8a780629713f06d551 Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:57:28 -0700 Subject: [PATCH 21/24] Browser: fix URL overflow (#302973) --- .../contrib/browserView/electron-browser/media/browser.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css index 1fec1ab961c..8f092973c7d 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css +++ b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css @@ -58,6 +58,7 @@ flex: 1; display: flex; align-items: center; + min-width: 0; background-color: var(--vscode-input-background); border: 1px solid var(--vscode-input-border); border-radius: var(--vscode-cornerRadius-small); From 799c086df33bf3bde1d7538af812ba08f16d19ae Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:12:13 -0700 Subject: [PATCH 22/24] fix disposable in createToolPart (#302975) --- .../chatThinkingContentPart.ts | 24 +++++++++++++++---- .../chat/browser/widget/chatListRenderer.ts | 6 ++--- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index 83164c7a850..51e1915206d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -27,7 +27,7 @@ import { Codicon } from '../../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; import { Emitter } from '../../../../../../base/common/event.js'; -import { DisposableMap, DisposableStore, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { DisposableMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { autorun } from '../../../../../../base/common/observable.js'; import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { IChatMarkdownAnchorService } from './chatMarkdownAnchorService.js'; @@ -201,6 +201,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen private availableMessagesByCategory = new Map(); private readonly toolWrappersByCallId = new Map(); private readonly toolDisposables = this._register(new DisposableMap()); + private readonly ownedToolParts = new Map(); private pendingRemovals: { toolCallId: string; toolLabel: string }[] = []; private pendingRemovalFlushDisposable: IDisposable | undefined; private pendingScrollDisposable: IDisposable | undefined; @@ -308,6 +309,13 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.currentTitle = this.defaultTitle; } + this._register(toDisposable(() => { + for (const d of this.ownedToolParts.values()) { + d.dispose(); + } + this.ownedToolParts.clear(); + })); + // override for codicon chevron in the collapsible part this._register(autorun(r => { const isExpanded = this.expanded.read(r); @@ -834,7 +842,12 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen toolInvocation }; if (result.disposable) { - this._register(result.disposable); + const toolCallId = toolInvocation?.toolCallId; + if (toolCallId) { + this.ownedToolParts.set(toolCallId, result.disposable); + } else { + this._register(result.disposable); + } } } } @@ -1122,7 +1135,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): if (result.disposable) { const toolCallId = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? toolInvocationOrMarkdown.toolCallId : undefined; if (toolCallId) { - this.toolDisposables.get(toolCallId)?.add(result.disposable); + this.ownedToolParts.set(toolCallId, result.disposable); } else { this._register(result.disposable); } @@ -1145,6 +1158,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): public removeMaterializedItem(toolCallId: string): void { this.toolDisposables.deleteAndDispose(toolCallId); + this.ownedToolParts.delete(toolCallId); const wrapper = this.toolWrappersByCallId.get(toolCallId); if (wrapper) { @@ -1255,6 +1269,8 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): // removes the tool entry that was previously streaming and now is not. removes item from dom and internal tracking. private removeStreamingToolEntry(toolCallId: string, toolLabel: string): void { this.toolDisposables.deleteAndDispose(toolCallId); + this.ownedToolParts.get(toolCallId)?.dispose(); + this.ownedToolParts.delete(toolCallId); const wrapper = this.toolWrappersByCallId.get(toolCallId); if (wrapper) { @@ -1534,7 +1550,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): if (result.disposable) { const toolCallId = item.toolInvocationOrMarkdown && (item.toolInvocationOrMarkdown.kind === 'toolInvocation' || item.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? item.toolInvocationOrMarkdown.toolCallId : undefined; if (toolCallId) { - this.toolDisposables.get(toolCallId)?.add(result.disposable); + this.ownedToolParts.set(toolCallId, result.disposable); } else { this._register(result.disposable); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 7d3f433bef7..70c212e903e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -1973,10 +1973,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { + const createToolPart = (): { domNode: HTMLElement; disposable: ChatToolInvocationPart; part: ChatToolInvocationPart } => { lazilyCreatedPart = this.instantiationService.createInstance(ChatToolInvocationPart, toolInvocation, context, this.chatContentMarkdownRenderer, this._contentReferencesListPool, this._toolEditorPool, () => this._currentLayoutWidth.get(), this._toolInvocationCodeBlockCollection, this._announcedToolProgressKeys, codeBlockStartIndex); this.handleRenderedCodeblocks(context.element, lazilyCreatedPart, codeBlockStartIndex); - return { domNode: lazilyCreatedPart.domNode, part: lazilyCreatedPart }; + return { domNode: lazilyCreatedPart.domNode, disposable: lazilyCreatedPart, part: lazilyCreatedPart }; }; // handling for when we want to put tool invocations inside a thinking part @@ -2032,7 +2032,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer ChatToolInvocationPart | undefined, - createToolPart: () => { domNode: HTMLElement; part: ChatToolInvocationPart }, + createToolPart: () => { domNode: HTMLElement; disposable: ChatToolInvocationPart; part: ChatToolInvocationPart }, context: IChatContentPartRenderContext, templateData: IChatListItemTemplate ): void { From 9b45522685aea7b82c584d27557e0f43cf1301a4 Mon Sep 17 00:00:00 2001 From: dileepyavan <52841896+dileepyavan@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:15:51 -0700 Subject: [PATCH 23/24] In sandbox mode, the command executed is not shown in the tool output (#302935) * In sandbox mode, the command executed is not shown in the tool output * In sandbox mode, the command executed is not shown in the tool output --- .../chat/common/chatService/chatService.ts | 2 ++ .../commandLineRewriter.ts | 1 + .../commandLineSandboxRewriter.ts | 1 + .../browser/tools/runInTerminalTool.ts | 35 ++++++++++++------- 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 61bd25515b6..0f7dacc1034 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -488,6 +488,8 @@ export interface IChatTerminalToolInvocationData { toolEdited?: string; // command to show in the chat UI (potentially different from what is actually run in the terminal) forDisplay?: string; + // isSandboxWrapped boolean to run in the terminal (potentially different from original command) + isSandboxWrapped?: boolean; }; /** The working directory URI for the terminal */ cwd?: UriComponents; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineRewriter.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineRewriter.ts index 3a3c4a3c955..b51a50d9768 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineRewriter.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineRewriter.ts @@ -24,4 +24,5 @@ export interface ICommandLineRewriterResult { reasoning: string; //for scenarios where we want to show a different command in the chat UI than what is actually run in the terminal forDisplay?: string; + isSandboxWrapped?: boolean; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts index 030afe5a76a..209c0b11d7b 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts @@ -31,6 +31,7 @@ export class CommandLineSandboxRewriter extends Disposable implements ICommandLi rewritten: wrappedCommand, reasoning: 'Wrapped command for sandbox execution', forDisplay: options.commandLine, // show the command that is passed as input. In this case, the output from CommandLinePreventHistoryRewriter + isSandboxWrapped: true, }; } } 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 0c4358f1c09..375a7feafa7 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -487,6 +487,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { let rewrittenCommand: string | undefined = args.command; let forDisplayCommand: string | undefined = undefined; + let isSandboxWrapped = false; for (const rewriter of this._commandLineRewriters) { const rewriteResult = await rewriter.rewrite({ commandLine: rewrittenCommand, @@ -497,6 +498,9 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { if (rewriteResult) { rewrittenCommand = rewriteResult.rewritten; forDisplayCommand = rewriteResult.forDisplay; + if (rewriteResult.isSandboxWrapped) { + isSandboxWrapped = true; + } this._logService.info(`RunInTerminalTool: Command rewritten by ${rewriter.constructor.name}: ${rewriteResult.reasoning}`); } } @@ -509,6 +513,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { original: args.command, toolEdited: rewrittenCommand === args.command ? undefined : rewrittenCommand, forDisplay: forDisplayCommand ?? normalizeTerminalCommandForDisplay(rewrittenCommand ?? args.command), + isSandboxWrapped, }, cwd, language, @@ -781,6 +786,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { toolSpecificData.commandLine.toolEdited !== toolSpecificData.commandLine.original ); + const didSandboxWrapCommand = toolSpecificData.commandLine.isSandboxWrapped === true; + if (token.isCancellationRequested) { throw new CancellationError(); } @@ -922,12 +929,14 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { state.timestamp = state.timestamp ?? timingStart; toolSpecificData.terminalCommandState = state; + // if the command is wrapped in a sandbox, we will not show the command. This is because the sandbox may add additional commands that are not relevant to the user, and the output will provide more context about what is running. let resultText = ( - didUserEditCommand - ? `Note: The user manually edited the command to \`${command}\`, and that command is now running in terminal with ID=${termId}` - : didToolEditCommand - ? `Note: The tool simplified the command to \`${command}\`, and that command is now running in terminal with ID=${termId}` - : `Command is running in terminal with ID=${termId}` + didSandboxWrapCommand ? `Command is now running in terminal with ID=${termId}` + : didUserEditCommand + ? `Note: The user manually edited the command to \`${command}\`, and that command is now running in terminal with ID=${termId}` + : didToolEditCommand + ? `Note: The tool simplified the command to \`${command}\`, and that command is now running in terminal with ID=${termId}` + : `Command is running in terminal with ID=${termId}` ); if (pollingResult && pollingResult.modelOutputEvalResponse) { resultText += `\n\ The command became idle with output:\n${pollingResult.modelOutputEvalResponse}`; @@ -1116,13 +1125,15 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } const resultText: string[] = []; - if (didUserEditCommand) { - resultText.push(`Note: The user manually edited the command to \`${command}\`, and this is the output of running that command instead:\n`); - } else if (didToolEditCommand) { - resultText.push(`Note: The tool simplified the command to \`${command}\`, and this is the output of running that command instead:\n`); - } - if (didMoveToBackground && !args.isBackground) { - resultText.push(`Note: This terminal execution was moved to the background using the ID ${termId}\n`); + if (!didSandboxWrapCommand) { + if (didUserEditCommand) { + resultText.push(`Note: The user manually edited the command to \`${command}\`, and this is the output of running that command instead:\n`); + } else if (didToolEditCommand) { + resultText.push(`Note: The tool simplified the command to \`${command}\`, and this is the output of running that command instead:\n`); + } + if (didMoveToBackground && !args.isBackground) { + resultText.push(`Note: This terminal execution was moved to the background using the ID ${termId}\n`); + } } if (didTimeout && timeoutValue !== undefined && timeoutValue > 0) { resultText.push(`Note: Command timed out after ${timeoutValue}ms. Output collected so far is shown below and the command may still be running in terminal ID ${termId}.\n\n`); From 82f9e1e1df17fabbbf37370dfb285247fc05afd8 Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:16:52 -0700 Subject: [PATCH 24/24] Browser: move zoom and agent sharing to feature contributions (#302970) * Browser: move zoom and agent sharing to feature contributions * comment Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../electron-browser/browserEditor.ts | 212 ++----------- .../browserView.contribution.ts | 74 +---- .../electron-browser/browserViewActions.ts | 174 ++-------- .../browserEditorChatFeatures.ts} | 196 +++++++++--- .../features/browserEditorZoomFeature.ts | 297 ++++++++++++++++++ .../electron-browser/media/browser.css | 6 + 6 files changed, 536 insertions(+), 423 deletions(-) rename src/vs/workbench/contrib/browserView/electron-browser/{browserEditorChatIntegration.ts => features/browserEditorChatFeatures.ts} (64%) create mode 100644 src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 9c32141a514..26ce86842e3 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -6,11 +6,11 @@ import './media/browser.css'; import { localize } from '../../../../nls.js'; import { $, addDisposableListener, Dimension, EventType, IDomPosition, registerExternalFocusChecker } from '../../../../base/browser/dom.js'; -import { Button, ButtonBar } from '../../../../base/browser/ui/button/button.js'; +import { ButtonBar } from '../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { RawContextKey, IContextKey, IContextKeyService, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { RawContextKey, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; import { IInstantiationService, IConstructorSignature, BrandedService } from '../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; @@ -18,15 +18,11 @@ import { AUX_WINDOW_GROUP, IEditorService } from '../../../services/editor/commo import { EditorPane } from '../../../browser/parts/editor/editorPane.js'; import { IEditorOpenContext } from '../../../common/editor.js'; import { BrowserEditorInput } from '../common/browserEditorInput.js'; -import { - IBrowserEditorViewState, - IBrowserViewModel -} from '../../browserView/common/browserView.js'; -import { IBrowserZoomService } from '../../browserView/common/browserZoomService.js'; +import { IBrowserEditorViewState, IBrowserViewModel } from '../../browserView/common/browserView.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; -import { IBrowserViewKeyDownEvent, IBrowserViewNavigationEvent, IBrowserViewLoadError, IBrowserViewCertificateError, BrowserNewPageLocation, browserZoomFactors, browserZoomLabel, browserZoomAccessibilityLabel } from '../../../../platform/browserView/common/browserView.js'; +import { IBrowserViewKeyDownEvent, IBrowserViewNavigationEvent, IBrowserViewLoadError, IBrowserViewCertificateError, BrowserNewPageLocation } from '../../../../platform/browserView/common/browserView.js'; import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; @@ -36,23 +32,19 @@ import { BrowserOverlayManager, BrowserOverlayType, IBrowserOverlayInfo } from ' import { getZoomFactor, onDidChangeZoomLevel } from '../../../../base/browser/browser.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { disposableTimeout } from '../../../../base/common/async.js'; import { Lazy } from '../../../../base/common/lazy.js'; import { WorkbenchHoverDelegate } from '../../../../platform/hover/browser/hover.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; import { BrowserFindWidget, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE } from './browserFindWidget.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { SiteInfoWidget } from './siteInfoWidget.js'; import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; import { URI } from '../../../../base/common/uri.js'; -import { ChatConfiguration } from '../../chat/common/constants.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; +import { Emitter } from '../../../../base/common/event.js'; import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; -import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; export const CONTEXT_BROWSER_CAN_GO_BACK = new RawContextKey('browserCanGoBack', false, localize('browser.canGoBack', "Whether the browser can go back")); export const CONTEXT_BROWSER_CAN_GO_FORWARD = new RawContextKey('browserCanGoForward', false, localize('browser.canGoForward', "Whether the browser can go forward")); @@ -61,22 +53,10 @@ export const CONTEXT_BROWSER_STORAGE_SCOPE = new RawContextKey('browserS export const CONTEXT_BROWSER_HAS_URL = new RawContextKey('browserHasUrl', false, localize('browser.hasUrl', "Whether the browser has a URL loaded")); export const CONTEXT_BROWSER_HAS_ERROR = new RawContextKey('browserHasError', false, localize('browser.hasError', "Whether the browser has a load error")); export const CONTEXT_BROWSER_DEVTOOLS_OPEN = new RawContextKey('browserDevToolsOpen', false, localize('browser.devToolsOpen', "Whether developer tools are open for the current browser view")); -export const CONTEXT_BROWSER_CAN_ZOOM_IN = new RawContextKey('browserCanZoomIn', true, localize('browser.canZoomIn', "Whether the browser can zoom in further")); -export const CONTEXT_BROWSER_CAN_ZOOM_OUT = new RawContextKey('browserCanZoomOut', true, localize('browser.canZoomOut', "Whether the browser can zoom out further")); // Re-export find widget context keys for use in actions export { CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE }; -const canShareBrowserWithAgentContext = ContextKeyExpr.and( - ChatContextKeys.enabled, - ContextKeyExpr.has(`config.${ChatConfiguration.AgentEnabled}`), - ContextKeyExpr.has(`config.workbench.browser.enableChatTools`), -)!; -function watchForAgentSharingContextChanges(contextKeyService: IContextKeyService): Event { - const agentSharingKeys = new Set(canShareBrowserWithAgentContext.keys()); - return Event.filter(contextKeyService.onDidChangeContext, e => e.affectsSome(agentSharingKeys)); -} - /** * Get the original implementation of HTMLElement focus (without window auto-focusing) * before it gets overridden by the workbench. @@ -115,51 +95,29 @@ export abstract class BrowserEditorContribution extends Disposable { * Called when the model is cleared to reset state. */ clear(): void { } -} - - -/** - * Transient zoom-level indicator that briefly appears inside the URL bar on zoom changes. - * All DOM construction, state, and auto-hide logic are self-contained here. - */ -class BrowserZoomPill extends Disposable { - readonly element: HTMLElement; - private readonly _icon: HTMLElement; - private readonly _label: HTMLElement; - private readonly _timeout = this._register(new MutableDisposable()); - - constructor() { - super(); - this.element = $('.browser-zoom-pill'); - // Don't announce this transient element; the zoom level is announced via IAccessibilityService.status() in showZoomPill() - this.element.setAttribute('aria-hidden', 'true'); - this._icon = $('span'); - this._label = $('span'); - this.element.appendChild(this._icon); - this.element.appendChild(this._label); - } /** - * Briefly show the zoom level, then auto-hide after 750 ms. + * Optional widgets to display inside the URL bar (on the right side of the URL input, + * before the actions toolbar). + * Contributions can override this getter to provide widgets. */ - show(zoomLabel: string, isAtOrAboveDefault: boolean): void { - this._icon.className = ThemeIcon.asClassName(isAtOrAboveDefault ? Codicon.zoomIn : Codicon.zoomOut); - this._label.textContent = zoomLabel; - this.element.classList.add('visible'); - // Reset auto-hide timer so rapid zoom actions extend the display - this._timeout.value = disposableTimeout(() => { - this.element.classList.remove('visible'); - }, 750); // Chrome shows the zoom level for 1.5 seconds, but we show it for less because ours is non-interactive - } + get urlBarWidgets(): readonly IBrowserEditorWidgetContribution[] { return []; } +} + +/** + * A widget that can be contributed to the browser editor URL bar. + */ +export interface IBrowserEditorWidgetContribution { + readonly element: HTMLElement; + /** Ordering value — lower numbers appear first (left). */ + readonly order: number; } class BrowserNavigationBar extends Disposable { private readonly _urlInput: HTMLInputElement; private readonly _urlDisplay: HTMLElement; - private readonly _shareButton: Button; - private readonly _shareButtonContainer: HTMLElement; private readonly _siteInfoWidget: SiteInfoWidget; - private readonly _zoomPill: BrowserZoomPill; + private readonly _urlBarWidgetsContainer: HTMLElement; constructor( editor: BrowserEditor, @@ -221,23 +179,11 @@ class BrowserNavigationBar extends Disposable { urlInputWrapper.appendChild(this._urlDisplay); urlInputWrapper.appendChild(this._urlInput); - // Share toggle button (inside URL bar, right side) - this._shareButtonContainer = $('.browser-share-toggle-container'); - this._shareButton = this._register(new Button(this._shareButtonContainer, { - supportIcons: true, - title: localize('browser.shareWithAgent', "Share with Agent"), - small: true, - hoverDelegate - })); - this._shareButton.element.classList.add('browser-share-toggle'); - this._shareButton.label = '$(agent)'; - - this._zoomPill = this._register(new BrowserZoomPill()); + this._urlBarWidgetsContainer = $('.browser-url-bar-widgets'); urlContainer.appendChild(siteInfoContainer); urlContainer.appendChild(urlInputWrapper); - urlContainer.appendChild(this._zoomPill.element); - urlContainer.appendChild(this._shareButtonContainer); + urlContainer.appendChild(this._urlBarWidgetsContainer); // Create actions toolbar (right side) with scoped context const actionsContainer = $('.browser-actions-toolbar'); @@ -283,33 +229,6 @@ class BrowserNavigationBar extends Disposable { this._register(addDisposableListener(this._urlDisplay, EventType.FOCUS, () => { this._showInput(); })); - - // Share toggle click handler - this._register(this._shareButton.onDidClick(() => { - editor.toggleShareWithAgent(); - })); - - // Show share button only when chat is enabled and browser tools are enabled - const updateShareButtonVisibility = () => { - this._shareButtonContainer.style.display = scopedContextKeyService.contextMatchesRules(canShareBrowserWithAgentContext) ? '' : 'none'; - }; - updateShareButtonVisibility(); - this._register(watchForAgentSharingContextChanges(scopedContextKeyService)(() => { - updateShareButtonVisibility(); - })); - } - - /** - * Update the share toggle visual state - */ - setShared(isShared: boolean): void { - this._shareButton.checked = isShared; - this._shareButton.label = isShared - ? localize('browser.sharingWithAgent', "Sharing with Agent") + ' $(agent)' - : '$(agent)'; - this._shareButton.setTitle(isShared - ? localize('browser.unshareWithAgent', "Stop Sharing with Agent") - : localize('browser.shareWithAgent', "Share with Agent")); } /** @@ -347,10 +266,13 @@ class BrowserNavigationBar extends Disposable { } /** - * Briefly show the zoom level indicator pill, then auto-hide. + * Add widget elements inside the URL bar, sorted by order. */ - showZoomLevel(zoomLabel: string, isAtOrAboveDefault: boolean): void { - this._zoomPill.show(zoomLabel, isAtOrAboveDefault); + addUrlBarWidgets(widgets: readonly IBrowserEditorWidgetContribution[]): void { + const sorted = widgets.slice().sort((a, b) => a.order - b.order); + for (const widget of sorted) { + this._urlBarWidgetsContainer.appendChild(widget.element); + } } /** @@ -442,8 +364,6 @@ export class BrowserEditor extends EditorPane { private _hasUrlContext!: IContextKey; private _hasErrorContext!: IContextKey; private _devToolsOpenContext!: IContextKey; - private _canZoomInContext!: IContextKey; - private _canZoomOutContext!: IContextKey; private readonly _inputDisposables = this._register(new DisposableStore()); private overlayManager: BrowserOverlayManager | undefined; @@ -461,8 +381,6 @@ export class BrowserEditor extends EditorPane { @IContextKeyService private readonly contextKeyService: IContextKeyService, @IEditorService private readonly editorService: IEditorService, @ILayoutService private readonly layoutService: ILayoutService, - @IBrowserZoomService private readonly browserZoomService: IBrowserZoomService, - @IAccessibilityService private readonly accessibilityService: IAccessibilityService ) { super(BrowserEditorInput.EDITOR_ID, group, telemetryService, themeService, storageService); } @@ -481,8 +399,6 @@ export class BrowserEditor extends EditorPane { this._hasUrlContext = CONTEXT_BROWSER_HAS_URL.bindTo(contextKeyService); this._hasErrorContext = CONTEXT_BROWSER_HAS_ERROR.bindTo(contextKeyService); this._devToolsOpenContext = CONTEXT_BROWSER_DEVTOOLS_OPEN.bindTo(contextKeyService); - this._canZoomInContext = CONTEXT_BROWSER_CAN_ZOOM_IN.bindTo(contextKeyService); - this._canZoomOutContext = CONTEXT_BROWSER_CAN_ZOOM_OUT.bindTo(contextKeyService); // Currently this is always true since it is scoped to the editor container CONTEXT_BROWSER_FOCUSED.bindTo(contextKeyService); @@ -508,6 +424,13 @@ export class BrowserEditor extends EditorPane { // Create navigation bar widget with scoped context this._navigationBar = this._register(new BrowserNavigationBar(this, toolbar, this.instantiationService, contextKeyService)); + // Inject URL bar widgets from contributions + const allWidgets: IBrowserEditorWidgetContribution[] = []; + for (const contribution of this._contributionInstances.values()) { + allWidgets.push(...contribution.urlBarWidgets); + } + this._navigationBar.addUrlBarWidgets(allWidgets); + root.appendChild(toolbar); // Create find widget container (between toolbar and browser container) @@ -604,24 +527,10 @@ export class BrowserEditor extends EditorPane { this._storageScopeContext.set(this._model.storageScope); this._devToolsOpenContext.set(this._model.isDevToolsOpen); - this.updateZoomContext(); - this._updateSharingState(true); // Update find widget with new model this._findWidget.rawValue?.setModel(this._model); - // Listen for sharing state changes on the model - this._inputDisposables.add(this._model.onDidChangeSharedWithAgent(() => { - this._updateSharingState(false); - })); - this._inputDisposables.add(watchForAgentSharingContextChanges(this.contextKeyService)(() => { - this._updateSharingState(false); - })); - - this._inputDisposables.add(this._model.onDidChangeZoom(() => { - this.updateZoomContext(); - })); - // Initialize UI state and context keys from model this.updateNavigationState({ url: this._model.url, @@ -956,22 +865,6 @@ export class BrowserEditor extends EditorPane { this._model?.untrustCertificate(certError.host, certError.fingerprint); } - private _updateSharingState(isInitialState: boolean): void { - const sharingEnabled = this.contextKeyService.contextMatchesRules(canShareBrowserWithAgentContext); - const isShared = sharingEnabled && !!this._model && this._model.sharedWithAgent; - - this._browserContainer.classList.toggle('animate', !isInitialState); - this._browserContainer.classList.toggle('shared', isShared); - this._navigationBar.setShared(isShared); - } - - toggleShareWithAgent(): void { - if (!this._model) { - return; - } - this._model.setSharedWithAgent(!this._model.sharedWithAgent); - } - async navigateToUrl(url: string): Promise { if (this._model) { this.group.pinEditor(this.input); // pin editor on navigation @@ -1013,41 +906,6 @@ export class BrowserEditor extends EditorPane { return this._model?.clearStorage(); } - async zoomIn(): Promise { - await this._model?.zoomIn(); - this.showZoomPill(); - } - - async zoomOut(): Promise { - await this._model?.zoomOut(); - this.showZoomPill(); - } - - async resetZoom(): Promise { - await this._model?.resetZoom(); - this.showZoomPill(); - } - - private showZoomPill(): void { - if (!this._model) { - return; - } - const defaultIndex = this.browserZoomService.getEffectiveZoomIndex(undefined, false); - const defaultFactor = browserZoomFactors[defaultIndex]; - const currentFactor = this._model.zoomFactor; - const label = browserZoomLabel(currentFactor); - this._navigationBar.showZoomLevel(label, currentFactor >= defaultFactor); - // Announce the new zoom level to screen readers (polite, non-interruptive). - this.accessibilityService.status(browserZoomAccessibilityLabel(currentFactor)); - } - - private updateZoomContext(): void { - if (this._model) { - this._canZoomInContext.set(this._model.canZoomIn); - this._canZoomOutContext.set(this._model.canZoomOut); - } - } - /** * Show the find widget, optionally pre-populated with selected text from the browser view */ @@ -1252,8 +1110,6 @@ export class BrowserEditor extends EditorPane { this._hasErrorContext.reset(); this._storageScopeContext.reset(); this._devToolsOpenContext.reset(); - this._canZoomInContext.reset(); - this._canZoomOutContext.reset(); this._navigationBar.clear(); this.setBackgroundImage(undefined); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts index 935d4be771d..6bf1b7da480 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -19,29 +19,24 @@ import { workbenchConfigurationNodeBase } from '../../../common/configuration.js import { IEditorResolverService, RegisteredEditorPriority } from '../../../services/editor/common/editorResolverService.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; import { Schemas } from '../../../../base/common/network.js'; -import { IBrowserViewWorkbenchService, IBrowserViewCDPService } from '../common/browserView.js'; +import { IBrowserViewCDPService, IBrowserViewWorkbenchService } from '../common/browserView.js'; import { BrowserViewWorkbenchService } from './browserViewWorkbenchService.js'; import { BrowserViewCDPService } from './browserViewCDPService.js'; -import { BrowserZoomService, IBrowserZoomService, MATCH_WINDOW_ZOOM_LABEL } from '../common/browserZoomService.js'; -import { browserZoomFactors, BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js'; +import { BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js'; import { IExternalOpener, IOpenerService } from '../../../../platform/opener/common/opener.js'; import { isLocalhostAuthority } from '../../../../platform/url/common/trustedDomains.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { PolicyCategory } from '../../../../base/common/policy.js'; -import { getZoomLevel, onDidChangeZoomLevel } from '../../../../base/browser/browser.js'; -import { mainWindow } from '../../../../base/browser/window.js'; -import { zoomLevelToZoomFactor } from '../../../../platform/window/common/window.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { URI } from '../../../../base/common/uri.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; -// Register actions and browser tools +// Register actions and browser features import './browserViewActions.js'; -import './browserEditorChatIntegration.js'; -import './tools/browserTools.contribution.js'; +import './features/browserEditorChatFeatures.js'; +import './features/browserEditorZoomFeature.js'; Registry.as(EditorExtensions.EditorPane).registerEditorPane( EditorPaneDescriptor.create( @@ -152,27 +147,8 @@ class LocalhostLinkOpenerContribution extends Disposable implements IWorkbenchCo registerWorkbenchContribution2(LocalhostLinkOpenerContribution.ID, LocalhostLinkOpenerContribution, WorkbenchPhase.BlockStartup); -/** - * Bridges the application's UI zoom level changes into IBrowserZoomService so that - * views using the 'Match Window' default zoom level stay in sync. - */ -class WindowZoomSynchronizer extends Disposable implements IWorkbenchContribution { - static readonly ID = 'workbench.contrib.browserView.windowZoomSynchronizer'; - - constructor(@IBrowserZoomService browserZoomService: IBrowserZoomService) { - super(); - browserZoomService.notifyWindowZoomChanged(zoomLevelToZoomFactor(getZoomLevel(mainWindow))); - this._register(onDidChangeZoomLevel(() => { - browserZoomService.notifyWindowZoomChanged(zoomLevelToZoomFactor(getZoomLevel(mainWindow))); - })); - } -} - -registerWorkbenchContribution2(WindowZoomSynchronizer.ID, WindowZoomSynchronizer, WorkbenchPhase.BlockRestore); - registerSingleton(IBrowserViewWorkbenchService, BrowserViewWorkbenchService, InstantiationType.Delayed); registerSingleton(IBrowserViewCDPService, BrowserViewCDPService, InstantiationType.Delayed); -registerSingleton(IBrowserZoomService, BrowserZoomService, InstantiationType.Delayed); Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ ...workbenchConfigurationNodeBase, @@ -194,46 +170,6 @@ Registry.as(ConfigurationExtensions.Configuration).regis 'When enabled, localhost links from the terminal, chat, and other sources will open in the Integrated Browser instead of the system browser.' ) }, - 'workbench.browser.enableChatTools': { - type: 'boolean', - default: false, - experiment: { mode: 'startup' }, - tags: ['experimental'], - markdownDescription: localize( - { comment: ['This is the description for a setting.'], key: 'browser.enableChatTools' }, - 'When enabled, chat agents can use browser tools to open and interact with pages in the Integrated Browser.' - ), - policy: { - name: 'BrowserChatTools', - category: PolicyCategory.InteractiveSession, - minimumVersion: '1.110', - value: (policyData) => policyData.chat_preview_features_enabled === false ? false : undefined, - localization: { - description: { - key: 'browser.enableChatTools', - value: localize('browser.enableChatTools', 'When enabled, chat agents can use browser tools to open and interact with pages in the Integrated Browser.') - } - }, - } - }, - 'workbench.browser.pageZoom': { - type: 'string', - enum: [MATCH_WINDOW_ZOOM_LABEL, ...browserZoomFactors.map(f => `${Math.round(f * 100)}%`)], - markdownEnumDescriptions: [ - localize( - { comment: ['This is the description for a setting enum value.'], key: 'browser.defaultZoomLevel.matchWindow' }, - 'Matches the application\'s current UI zoom level.' - ), - ...browserZoomFactors.map(() => ''), - ], - default: MATCH_WINDOW_ZOOM_LABEL, - markdownDescription: localize( - { comment: ['This is the description for a setting.'], key: 'browser.pageZoom' }, - 'Default zoom level for all sites in the Integrated Browser.' - ), - // Zoom can change from machine to machine, so we don't need the workspace-level nor syncing that WINDOW has. - scope: ConfigurationScope.MACHINE - }, 'workbench.browser.dataStorage': { type: 'string', enum: [ diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts index ecd9d6bcd9a..46011e85290 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -11,7 +11,7 @@ import { KeybindingWeight } from '../../../../platform/keybinding/common/keybind import { KeyMod, KeyCode } from '../../../../base/common/keyCodes.js'; import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../services/editor/common/editorService.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { BrowserEditor, CONTEXT_BROWSER_CAN_GO_BACK, CONTEXT_BROWSER_CAN_GO_FORWARD, CONTEXT_BROWSER_CAN_ZOOM_IN, CONTEXT_BROWSER_CAN_ZOOM_OUT, CONTEXT_BROWSER_DEVTOOLS_OPEN, CONTEXT_BROWSER_FOCUSED, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_STORAGE_SCOPE, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE } from './browserEditor.js'; +import { BrowserEditor, CONTEXT_BROWSER_CAN_GO_BACK, CONTEXT_BROWSER_CAN_GO_FORWARD, CONTEXT_BROWSER_DEVTOOLS_OPEN, CONTEXT_BROWSER_FOCUSED, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_STORAGE_SCOPE, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE } from './browserEditor.js'; import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { IBrowserViewWorkbenchService } from '../common/browserView.js'; @@ -24,13 +24,15 @@ import { BrowserEditorInput } from '../common/browserEditorInput.js'; import { ToggleTitleBarConfigAction } from '../../../browser/parts/titlebar/titlebarActions.js'; // Context key expression to check if browser editor is active -const BROWSER_EDITOR_ACTIVE = ContextKeyExpr.equals('activeEditor', BrowserEditorInput.EDITOR_ID); +export const BROWSER_EDITOR_ACTIVE = ContextKeyExpr.equals('activeEditor', BrowserEditorInput.EDITOR_ID); -const BrowserCategory = localize2('browserCategory', "Browser"); -const ActionGroupTabs = '1_tabs'; -const ActionGroupZoom = '2_zoom'; -const ActionGroupPage = '3_page'; -const ActionGroupSettings = '4_settings'; +export const BrowserActionCategory = localize2('browserCategory', "Browser"); +export enum BrowserActionGroup { + Tabs = '1_tabs', + Zoom = '2_zoom', + Page = '3_page', + Settings = '4_settings' +} interface IOpenBrowserOptions { url?: string; @@ -42,7 +44,7 @@ class OpenIntegratedBrowserAction extends Action2 { super({ id: BrowserViewCommandId.Open, title: localize2('browser.openAction', "Open Integrated Browser"), - category: BrowserCategory, + category: BrowserActionCategory, icon: Codicon.globe, f1: true, menu: { @@ -79,12 +81,12 @@ class NewTabAction extends Action2 { super({ id: BrowserViewCommandId.NewTab, title: localize2('browser.newTabAction', "New Tab"), - category: BrowserCategory, + category: BrowserActionCategory, f1: true, precondition: BROWSER_EDITOR_ACTIVE, menu: { id: MenuId.BrowserActionsToolbar, - group: ActionGroupTabs, + group: BrowserActionGroup.Tabs, order: 1, }, // When already in a browser, Ctrl/Cmd + T opens a new tab @@ -113,7 +115,7 @@ class GoBackAction extends Action2 { super({ id: GoBackAction.ID, title: localize2('browser.goBackAction', 'Go Back'), - category: BrowserCategory, + category: BrowserActionCategory, icon: Codicon.arrowLeft, f1: true, precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_CAN_GO_BACK), @@ -145,7 +147,7 @@ class GoForwardAction extends Action2 { super({ id: GoForwardAction.ID, title: localize2('browser.goForwardAction', 'Go Forward'), - category: BrowserCategory, + category: BrowserActionCategory, icon: Codicon.arrowRight, f1: true, precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_CAN_GO_FORWARD), @@ -177,7 +179,7 @@ class ReloadAction extends Action2 { super({ id: ReloadAction.ID, title: localize2('browser.reloadAction', 'Reload'), - category: BrowserCategory, + category: BrowserActionCategory, icon: Codicon.refresh, f1: true, precondition: BROWSER_EDITOR_ACTIVE, @@ -215,7 +217,7 @@ class HardReloadAction extends Action2 { super({ id: HardReloadAction.ID, title: localize2('browser.hardReloadAction', 'Hard Reload'), - category: BrowserCategory, + category: BrowserActionCategory, icon: Codicon.refresh, f1: true, precondition: BROWSER_EDITOR_ACTIVE, @@ -243,7 +245,7 @@ class FocusUrlInputAction extends Action2 { super({ id: FocusUrlInputAction.ID, title: localize2('browser.focusUrlInputAction', 'Focus URL Input'), - category: BrowserCategory, + category: BrowserActionCategory, f1: true, precondition: BROWSER_EDITOR_ACTIVE, keybinding: { @@ -267,7 +269,7 @@ class ToggleDevToolsAction extends Action2 { super({ id: ToggleDevToolsAction.ID, title: localize2('browser.toggleDevToolsAction', 'Toggle Developer Tools'), - category: BrowserCategory, + category: BrowserActionCategory, icon: Codicon.terminal, f1: true, precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), @@ -298,14 +300,14 @@ class OpenInExternalBrowserAction extends Action2 { super({ id: OpenInExternalBrowserAction.ID, title: localize2('browser.openExternalAction', 'Open in External Browser'), - category: BrowserCategory, + category: BrowserActionCategory, icon: Codicon.linkExternal, f1: true, // Note: We do allow opening in an external browser even if there is an error page shown precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL), menu: { id: MenuId.BrowserActionsToolbar, - group: ActionGroupPage, + group: BrowserActionGroup.Page, order: 10 } }); @@ -334,12 +336,12 @@ class ClearGlobalBrowserStorageAction extends Action2 { super({ id: ClearGlobalBrowserStorageAction.ID, title: localize2('browser.clearGlobalStorageAction', 'Clear Storage (Global)'), - category: BrowserCategory, + category: BrowserActionCategory, icon: Codicon.clearAll, f1: true, menu: { id: MenuId.BrowserActionsToolbar, - group: ActionGroupSettings, + group: BrowserActionGroup.Settings, order: 1, when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Global) } @@ -359,12 +361,12 @@ class ClearWorkspaceBrowserStorageAction extends Action2 { super({ id: ClearWorkspaceBrowserStorageAction.ID, title: localize2('browser.clearWorkspaceStorageAction', 'Clear Storage (Workspace)'), - category: BrowserCategory, + category: BrowserActionCategory, icon: Codicon.clearAll, f1: true, menu: { id: MenuId.BrowserActionsToolbar, - group: ActionGroupSettings, + group: BrowserActionGroup.Settings, order: 1, when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Workspace) } @@ -384,13 +386,13 @@ class ClearEphemeralBrowserStorageAction extends Action2 { super({ id: ClearEphemeralBrowserStorageAction.ID, title: localize2('browser.clearEphemeralStorageAction', 'Clear Storage (Ephemeral)'), - category: BrowserCategory, + category: BrowserActionCategory, icon: Codicon.clearAll, f1: true, precondition: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Ephemeral), menu: { id: MenuId.BrowserActionsToolbar, - group: ActionGroupSettings, + group: BrowserActionGroup.Settings, order: 1, when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Ephemeral) } @@ -411,12 +413,12 @@ class OpenBrowserSettingsAction extends Action2 { super({ id: OpenBrowserSettingsAction.ID, title: localize2('browser.openSettingsAction', 'Open Browser Settings'), - category: BrowserCategory, + category: BrowserActionCategory, icon: Codicon.settingsGear, f1: false, menu: { id: MenuId.BrowserActionsToolbar, - group: ActionGroupSettings, + group: BrowserActionGroup.Settings, order: 2 } }); @@ -428,113 +430,6 @@ class OpenBrowserSettingsAction extends Action2 { } } -// Zoom actions - -class ZoomInAction extends Action2 { - static readonly ID = 'workbench.action.browser.zoomIn'; - - constructor() { - super({ - id: ZoomInAction.ID, - title: localize2('browser.zoomInAction', 'Zoom In'), - category: BrowserCategory, - icon: Codicon.zoomIn, - f1: true, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), - menu: { - id: MenuId.BrowserActionsToolbar, - group: ActionGroupZoom, - order: 1, - when: CONTEXT_BROWSER_CAN_ZOOM_IN, - }, - keybinding: { - when: CONTEXT_BROWSER_FOCUSED, - weight: KeybindingWeight.WorkbenchContrib + 75, - // Same shortcuts as 'workbench.action.zoomIn' - primary: KeyMod.CtrlCmd | KeyCode.Equal, - secondary: [KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Equal, KeyMod.CtrlCmd | KeyCode.NumpadAdd], - }, - }); - } - - async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { - if (browserEditor instanceof BrowserEditor) { - await browserEditor.zoomIn(); - } - } -} - -class ZoomOutAction extends Action2 { - static readonly ID = 'workbench.action.browser.zoomOut'; - - constructor() { - super({ - id: ZoomOutAction.ID, - title: localize2('browser.zoomOutAction', 'Zoom Out'), - category: BrowserCategory, - icon: Codicon.zoomOut, - f1: true, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), - menu: { - id: MenuId.BrowserActionsToolbar, - group: ActionGroupZoom, - order: 2, - when: CONTEXT_BROWSER_CAN_ZOOM_OUT, - }, - keybinding: { - when: CONTEXT_BROWSER_FOCUSED, - weight: KeybindingWeight.WorkbenchContrib + 75, - // Same shortcuts as 'workbench.action.zoomOut' - primary: KeyMod.CtrlCmd | KeyCode.Minus, - secondary: [KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Minus, KeyMod.CtrlCmd | KeyCode.NumpadSubtract], - linux: { - primary: KeyMod.CtrlCmd | KeyCode.Minus, - secondary: [KeyMod.CtrlCmd | KeyCode.NumpadSubtract] - } - }, - }); - } - - async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { - if (browserEditor instanceof BrowserEditor) { - await browserEditor.zoomOut(); - } - } -} - -class ResetZoomAction extends Action2 { - static readonly ID = 'workbench.action.browser.resetZoom'; - - constructor() { - super({ - id: ResetZoomAction.ID, - title: localize2('browser.resetZoomAction', 'Reset Zoom'), - category: BrowserCategory, - icon: Codicon.screenNormal, - f1: true, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), - menu: { - id: MenuId.BrowserActionsToolbar, - group: ActionGroupZoom, - order: 3, - }, - keybinding: { - when: CONTEXT_BROWSER_FOCUSED, - weight: KeybindingWeight.WorkbenchContrib + 75, - // Same shortcuts as 'workbench.action.zoomReset' - // (note: both workbench and here use Numpad0 instead of Digit0 to avoid conflicts with keybinding to focus sidebar.) - primary: KeyMod.CtrlCmd | KeyCode.Numpad0, - }, - }); - } - - async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { - if (browserEditor instanceof BrowserEditor) { - await browserEditor.resetZoom(); - } - } -} - // Find actions class ShowBrowserFindAction extends Action2 { @@ -544,12 +439,12 @@ class ShowBrowserFindAction extends Action2 { super({ id: ShowBrowserFindAction.ID, title: localize2('browser.showFindAction', 'Find in Page'), - category: BrowserCategory, + category: BrowserActionCategory, f1: true, precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), menu: { id: MenuId.BrowserActionsToolbar, - group: ActionGroupPage, + group: BrowserActionGroup.Page, order: 1, }, keybinding: { @@ -573,7 +468,7 @@ class HideBrowserFindAction extends Action2 { super({ id: HideBrowserFindAction.ID, title: localize2('browser.hideFindAction', 'Close Find Widget'), - category: BrowserCategory, + category: BrowserActionCategory, f1: false, precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE), keybinding: { @@ -598,7 +493,7 @@ class BrowserFindNextAction extends Action2 { super({ id: BrowserFindNextAction.ID, title: localize2('browser.findNextAction', 'Find Next'), - category: BrowserCategory, + category: BrowserActionCategory, f1: false, precondition: BROWSER_EDITOR_ACTIVE, keybinding: [{ @@ -629,7 +524,7 @@ class BrowserFindPreviousAction extends Action2 { super({ id: BrowserFindPreviousAction.ID, title: localize2('browser.findPreviousAction', 'Find Previous'), - category: BrowserCategory, + category: BrowserActionCategory, f1: false, precondition: BROWSER_EDITOR_ACTIVE, keybinding: [{ @@ -667,9 +562,6 @@ registerAction2(ClearGlobalBrowserStorageAction); registerAction2(ClearWorkspaceBrowserStorageAction); registerAction2(ClearEphemeralBrowserStorageAction); registerAction2(OpenBrowserSettingsAction); -registerAction2(ZoomInAction); -registerAction2(ZoomOutAction); -registerAction2(ResetZoomAction); registerAction2(ShowBrowserFindAction); registerAction2(HideBrowserFindAction); registerAction2(BrowserFindNextAction); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditorChatIntegration.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts similarity index 64% rename from src/vs/workbench/contrib/browserView/electron-browser/browserEditorChatIntegration.ts rename to src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts index bbca28ba10d..793dcc0a2e8 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditorChatIntegration.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts @@ -3,30 +3,43 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { localize, localize2 } from '../../../../nls.js'; -import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; -import { IContextKey, IContextKeyService, ContextKeyExpr, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -import { Action2, registerAction2, MenuId } from '../../../../platform/actions/common/actions.js'; -import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; -import { KeyMod, KeyCode } from '../../../../base/common/keyCodes.js'; -import { IEditorService } from '../../../services/editor/common/editorService.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { Event } from '../../../../base/common/event.js'; -import { ILogService } from '../../../../platform/log/common/log.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { IBrowserElementsService } from '../../../services/browserElements/browser/browserElementsService.js'; -import { IChatWidgetService } from '../../chat/browser/chat.js'; -import { IChatRequestVariableEntry } from '../../chat/common/attachments/chatVariableEntries.js'; -import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; -import { IElementData, IBrowserTargetLocator, getDisplayNameFromOuterHTML, createElementContextValue } from '../../../../platform/browserElements/common/browserElements.js'; -import { BrowserViewCommandId } from '../../../../platform/browserView/common/browserView.js'; -import { IBrowserViewModel } from '../../browserView/common/browserView.js'; -import { BrowserEditorInput } from '../common/browserEditorInput.js'; -import { BrowserEditor, BrowserEditorContribution, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_FOCUSED } from './browserEditor.js'; +import { localize, localize2 } from '../../../../../nls.js'; +import { $ } from '../../../../../base/browser/dom.js'; +import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { IContextKey, IContextKeyService, ContextKeyExpr, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { Action2, registerAction2, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { ServicesAccessor, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { KeyMod, KeyCode } from '../../../../../base/common/keyCodes.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { Event } from '../../../../../base/common/event.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IBrowserElementsService } from '../../../../services/browserElements/browser/browserElementsService.js'; +import { IChatWidgetService } from '../../../chat/browser/chat.js'; +import { IChatRequestVariableEntry } from '../../../chat/common/attachments/chatVariableEntries.js'; +import { ChatContextKeys } from '../../../chat/common/actions/chatContextKeys.js'; +import { ChatConfiguration } from '../../../chat/common/constants.js'; +import { IElementData, IBrowserTargetLocator, getDisplayNameFromOuterHTML, createElementContextValue } from '../../../../../platform/browserElements/common/browserElements.js'; +import { BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; +import { IBrowserViewModel } from '../../../browserView/common/browserView.js'; +import { BrowserEditorInput } from '../../common/browserEditorInput.js'; +import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { WorkbenchHoverDelegate } from '../../../../../platform/hover/browser/hover.js'; +import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; +import { BrowserEditor, BrowserEditorContribution, IBrowserEditorWidgetContribution, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_FOCUSED } from '../browserEditor.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../../platform/configuration/common/configurationRegistry.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { PolicyCategory } from '../../../../../base/common/policy.js'; +import { workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; + +// Register tools +import '../tools/browserTools.contribution.js'; +import { BrowserActionCategory } from '../browserViewActions.js'; // Context key expression to check if browser editor is active const BROWSER_EDITOR_ACTIVE = ContextKeyExpr.equals('activeEditor', BrowserEditorInput.EDITOR_ID); @@ -34,17 +47,29 @@ const BrowserCategory = localize2('browserCategory', "Browser"); export const CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE = new RawContextKey('browserElementSelectionActive', false, localize('browser.elementSelectionActive', "Whether element selection is currently active")); +const canShareBrowserWithAgentContext = ContextKeyExpr.and( + ChatContextKeys.enabled, + ContextKeyExpr.has(`config.${ChatConfiguration.AgentEnabled}`), + ContextKeyExpr.has(`config.workbench.browser.enableChatTools`), +)!; + + /** * Contribution that manages element selection, element attachment to chat, - * console session lifecycle, and console log attachment to chat. + * console session lifecycle, console log attachment to chat, and agent sharing. */ export class BrowserEditorChatIntegration extends BrowserEditorContribution { private _elementSelectionCts: CancellationTokenSource | undefined; private readonly _elementSelectionActiveContext: IContextKey; + // Share with Agent + private readonly _shareButtonContainer: HTMLElement; + private readonly _shareButton: Button; + constructor( editor: BrowserEditor, - @IContextKeyService contextKeyService: IContextKeyService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IInstantiationService instantiationService: IInstantiationService, @ITelemetryService private readonly telemetryService: ITelemetryService, @ILogService private readonly logService: ILogService, @IBrowserElementsService private readonly browserElementsService: IBrowserElementsService, @@ -53,6 +78,42 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { ) { super(editor); this._elementSelectionActiveContext = CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE.bindTo(contextKeyService); + + // Build share toggle button + const hoverDelegate = this._register(instantiationService.createInstance( + WorkbenchHoverDelegate, + 'element', + undefined, + { position: { hoverPosition: HoverPosition.ABOVE } } + )); + + this._shareButtonContainer = $('.browser-share-toggle-container'); + this._shareButton = this._register(new Button(this._shareButtonContainer, { + supportIcons: true, + title: localize('browser.shareWithAgent', "Share with Agent"), + small: true, + hoverDelegate + })); + this._shareButton.element.classList.add('browser-share-toggle'); + this._shareButton.label = '$(agent)'; + + this._register(this._shareButton.onDidClick(() => { + this._toggleShareWithAgent(); + })); + + // Show share button only when chat is enabled and browser tools are enabled + const updateShareButtonVisibility = () => { + this._shareButtonContainer.style.display = contextKeyService.contextMatchesRules(canShareBrowserWithAgentContext) ? '' : 'none'; + }; + updateShareButtonVisibility(); + const agentSharingKeys = new Set(canShareBrowserWithAgentContext.keys()); + this._register(Event.filter(contextKeyService.onDidChangeContext, e => e.affectsSome(agentSharingKeys))(() => { + updateShareButtonVisibility(); + })); + } + + override get urlBarWidgets(): readonly IBrowserEditorWidgetContribution[] { + return [{ element: this._shareButtonContainer, order: 100 }]; } protected override subscribeToModel(model: IBrowserViewModel, store: DisposableStore): void { @@ -64,6 +125,50 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { store.add(this._startConsoleSession(model.id)); })); } + + // Manage sharing state + this._updateSharingState(true); + store.add(model.onDidChangeSharedWithAgent(() => { + this._updateSharingState(false); + })); + store.add(Event.filter(this.contextKeyService.onDidChangeContext, e => e.affectsSome(new Set(canShareBrowserWithAgentContext.keys())))(() => { + this._updateSharingState(false); + })); + } + + override clear(): void { + if (this._elementSelectionCts) { + this._elementSelectionCts.dispose(true); + this._elementSelectionCts = undefined; + } + this._elementSelectionActiveContext.reset(); + } + + // -- Sharing ------------------------------------------------------- + + private _toggleShareWithAgent(): void { + const model = this.editor.model; + if (!model) { + return; + } + model.setSharedWithAgent(!model.sharedWithAgent); + } + + private _updateSharingState(isInitialState: boolean): void { + const model = this.editor.model; + const sharingEnabled = this.contextKeyService.contextMatchesRules(canShareBrowserWithAgentContext); + const isShared = sharingEnabled && !!model && model.sharedWithAgent; + + this.editor.browserContainer.classList.toggle('animate', !isInitialState); + this.editor.browserContainer.classList.toggle('shared', isShared); + + this._shareButton.checked = isShared; + this._shareButton.label = isShared + ? localize('browser.sharingWithAgent', "Sharing with Agent") + ' $(agent)' + : '$(agent)'; + this._shareButton.setTitle(isShared + ? localize('browser.unshareWithAgent', "Stop Sharing with Agent") + : localize('browser.shareWithAgent', "Share with Agent")); } // -- Element Selection ---------------------------------------------- @@ -178,14 +283,6 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { } } - override clear(): void { - if (this._elementSelectionCts) { - this._elementSelectionCts.dispose(true); - this._elementSelectionCts = undefined; - } - this._elementSelectionActiveContext.reset(); - } - private async _attachElementDataToChat(elementData: IElementData): Promise<{ attachCss: boolean; attachImages: boolean }> { const bounds = elementData.bounds; const toAttach: IChatRequestVariableEntry[] = []; @@ -337,7 +434,7 @@ class AddConsoleLogsToChatAction extends Action2 { super({ id: AddConsoleLogsToChatAction.ID, title: localize2('browser.addConsoleLogsToChatAction', 'Add Console Logs to Chat'), - category: BrowserCategory, + category: BrowserActionCategory, icon: Codicon.output, f1: true, precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate(), enabled), @@ -362,6 +459,7 @@ class AddFocusedElementToChatAction extends Action2 { super({ id: 'workbench.action.browser.addFocusedElementToChat', title: localize2('browser.addFocusedElementToChat', 'Add Focused Element to Chat'), + category: BrowserActionCategory, f1: false, precondition: CONTEXT_BROWSER_FOCUSED, keybinding: { @@ -383,3 +481,31 @@ class AddFocusedElementToChatAction extends Action2 { registerAction2(AddElementToChatAction); registerAction2(AddConsoleLogsToChatAction); registerAction2(AddFocusedElementToChatAction); + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + ...workbenchConfigurationNodeBase, + properties: { + 'workbench.browser.enableChatTools': { + type: 'boolean', + default: false, + experiment: { mode: 'startup' }, + tags: ['experimental'], + markdownDescription: localize( + { comment: ['This is the description for a setting.'], key: 'browser.enableChatTools' }, + 'When enabled, chat agents can use browser tools to open and interact with pages in the Integrated Browser.' + ), + policy: { + name: 'BrowserChatTools', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.110', + value: (policyData) => policyData.chat_preview_features_enabled === false ? false : undefined, + localization: { + description: { + key: 'browser.enableChatTools', + value: localize('browser.enableChatTools', 'When enabled, chat agents can use browser tools to open and interact with pages in the Integrated Browser.') + } + }, + } + } + } +}); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts new file mode 100644 index 00000000000..881467ec8e7 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts @@ -0,0 +1,297 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { $ } from '../../../../../base/browser/dom.js'; +import { RawContextKey, IContextKey, IContextKeyService, ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { Action2, registerAction2, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from '../../../../../platform/configuration/common/configurationRegistry.js'; +import { KeyMod, KeyCode } from '../../../../../base/common/keyCodes.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { disposableTimeout } from '../../../../../base/common/async.js'; +import { browserZoomFactors, browserZoomLabel, browserZoomAccessibilityLabel } from '../../../../../platform/browserView/common/browserView.js'; +import { IBrowserViewModel } from '../../../browserView/common/browserView.js'; +import { BrowserZoomService, IBrowserZoomService, MATCH_WINDOW_ZOOM_LABEL } from '../../../browserView/common/browserZoomService.js'; +import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; +import { BrowserEditor, BrowserEditorContribution, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_FOCUSED, IBrowserEditorWidgetContribution } from '../browserEditor.js'; +import { BROWSER_EDITOR_ACTIVE, BrowserActionCategory, BrowserActionGroup } from '../browserViewActions.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; +import { getZoomLevel, onDidChangeZoomLevel } from '../../../../../base/browser/browser.js'; +import { zoomLevelToZoomFactor } from '../../../../../platform/window/common/window.js'; +import { mainWindow } from '../../../../../base/browser/window.js'; +import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; +import { workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; + +export const CONTEXT_BROWSER_CAN_ZOOM_IN = new RawContextKey('browserCanZoomIn', true, localize('browser.canZoomIn', "Whether the browser can zoom in further")); +export const CONTEXT_BROWSER_CAN_ZOOM_OUT = new RawContextKey('browserCanZoomOut', true, localize('browser.canZoomOut', "Whether the browser can zoom out further")); + +/** + * Transient zoom-level indicator that briefly appears inside the URL bar on zoom changes. + */ +class BrowserZoomPill extends Disposable { + readonly element: HTMLElement; + private readonly _icon: HTMLElement; + private readonly _label: HTMLElement; + private readonly _timeout = this._register(new MutableDisposable()); + + constructor() { + super(); + this.element = $('.browser-zoom-pill'); + // Don't announce this transient element; the zoom level is announced via IAccessibilityService.status() + this.element.setAttribute('aria-hidden', 'true'); + this._icon = $('span'); + this._label = $('span'); + this.element.appendChild(this._icon); + this.element.appendChild(this._label); + } + + /** + * Briefly show the zoom level, then auto-hide after 750 ms. + */ + show(zoomLabel: string, isAtOrAboveDefault: boolean): void { + this._icon.className = ThemeIcon.asClassName(isAtOrAboveDefault ? Codicon.zoomIn : Codicon.zoomOut); + this._label.textContent = zoomLabel; + this.element.classList.add('visible'); + // Reset auto-hide timer so rapid zoom actions extend the display + this._timeout.value = disposableTimeout(() => { + this.element.classList.remove('visible'); + }, 750); // Chrome shows the zoom level for 1.5 seconds, but we show it for less because ours is non-interactive + } +} + +/** + * Browser editor contribution that manages zoom context keys and the zoom pill indicator. + */ +export class BrowserEditorZoomSupport extends BrowserEditorContribution { + private readonly _zoomPill: BrowserZoomPill; + private readonly _canZoomInContext: IContextKey; + private readonly _canZoomOutContext: IContextKey; + + constructor( + editor: BrowserEditor, + @IContextKeyService contextKeyService: IContextKeyService, + @IBrowserZoomService private readonly browserZoomService: IBrowserZoomService, + @IAccessibilityService private readonly accessibilityService: IAccessibilityService, + ) { + super(editor); + this._canZoomInContext = CONTEXT_BROWSER_CAN_ZOOM_IN.bindTo(contextKeyService); + this._canZoomOutContext = CONTEXT_BROWSER_CAN_ZOOM_OUT.bindTo(contextKeyService); + this._zoomPill = this._register(new BrowserZoomPill()); + } + + override get urlBarWidgets(): readonly IBrowserEditorWidgetContribution[] { + return [{ element: this._zoomPill.element, order: 0 }]; + } + + protected override subscribeToModel(model: IBrowserViewModel, store: DisposableStore): void { + this._updateZoomContext(model); + store.add(model.onDidChangeZoom(() => { + this._updateZoomContext(model); + })); + } + + override clear(): void { + this._canZoomInContext.reset(); + this._canZoomOutContext.reset(); + } + + async zoomIn(): Promise { + await this.editor.model?.zoomIn(); + this._showZoomPill(); + } + + async zoomOut(): Promise { + await this.editor.model?.zoomOut(); + this._showZoomPill(); + } + + async resetZoom(): Promise { + await this.editor.model?.resetZoom(); + this._showZoomPill(); + } + + private _updateZoomContext(model: IBrowserViewModel): void { + this._canZoomInContext.set(model.canZoomIn); + this._canZoomOutContext.set(model.canZoomOut); + } + + private _showZoomPill(): void { + const model = this.editor.model; + if (!model) { + return; + } + const defaultIndex = this.browserZoomService.getEffectiveZoomIndex(undefined, false); + const defaultFactor = browserZoomFactors[defaultIndex]; + const currentFactor = model.zoomFactor; + const label = browserZoomLabel(currentFactor); + this._zoomPill.show(label, currentFactor >= defaultFactor); + // Announce the new zoom level to screen readers (polite, non-interruptive). + this.accessibilityService.status(browserZoomAccessibilityLabel(currentFactor)); + } +} + +// Register the contribution +BrowserEditor.registerContribution(BrowserEditorZoomSupport); + +// -- Actions ------------------------------------------------------------ + +class ZoomInAction extends Action2 { + static readonly ID = 'workbench.action.browser.zoomIn'; + + constructor() { + super({ + id: ZoomInAction.ID, + title: localize2('browser.zoomInAction', 'Zoom In'), + category: BrowserActionCategory, + icon: Codicon.zoomIn, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Zoom, + order: 1, + when: CONTEXT_BROWSER_CAN_ZOOM_IN, + }, + keybinding: { + when: CONTEXT_BROWSER_FOCUSED, + weight: KeybindingWeight.WorkbenchContrib + 75, + // Same shortcuts as 'workbench.action.zoomIn' + primary: KeyMod.CtrlCmd | KeyCode.Equal, + secondary: [KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Equal, KeyMod.CtrlCmd | KeyCode.NumpadAdd], + }, + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + await browserEditor.getContribution(BrowserEditorZoomSupport)?.zoomIn(); + } + } +} + +class ZoomOutAction extends Action2 { + static readonly ID = 'workbench.action.browser.zoomOut'; + + constructor() { + super({ + id: ZoomOutAction.ID, + title: localize2('browser.zoomOutAction', 'Zoom Out'), + category: BrowserActionCategory, + icon: Codicon.zoomOut, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Zoom, + order: 2, + when: CONTEXT_BROWSER_CAN_ZOOM_OUT, + }, + keybinding: { + when: CONTEXT_BROWSER_FOCUSED, + weight: KeybindingWeight.WorkbenchContrib + 75, + // Same shortcuts as 'workbench.action.zoomOut' + primary: KeyMod.CtrlCmd | KeyCode.Minus, + secondary: [KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Minus, KeyMod.CtrlCmd | KeyCode.NumpadSubtract], + linux: { + primary: KeyMod.CtrlCmd | KeyCode.Minus, + secondary: [KeyMod.CtrlCmd | KeyCode.NumpadSubtract] + } + }, + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + await browserEditor.getContribution(BrowserEditorZoomSupport)?.zoomOut(); + } + } +} + +class ResetZoomAction extends Action2 { + static readonly ID = 'workbench.action.browser.resetZoom'; + + constructor() { + super({ + id: ResetZoomAction.ID, + title: localize2('browser.resetZoomAction', 'Reset Zoom'), + category: BrowserActionCategory, + icon: Codicon.screenNormal, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Zoom, + order: 3, + }, + keybinding: { + when: CONTEXT_BROWSER_FOCUSED, + weight: KeybindingWeight.WorkbenchContrib + 75, + // Same shortcuts as 'workbench.action.zoomReset' + // (note: both workbench and here use Numpad0 instead of Digit0 to avoid conflicts with keybinding to focus sidebar.) + primary: KeyMod.CtrlCmd | KeyCode.Numpad0, + }, + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + await browserEditor.getContribution(BrowserEditorZoomSupport)?.resetZoom(); + } + } +} + +registerAction2(ZoomInAction); +registerAction2(ZoomOutAction); +registerAction2(ResetZoomAction); + +/** + * Bridges the application's UI zoom level changes into IBrowserZoomService so that + * views using the 'Match Window' default zoom level stay in sync. + */ +class WindowZoomSynchronizer extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.browserView.windowZoomSynchronizer'; + + constructor(@IBrowserZoomService browserZoomService: IBrowserZoomService) { + super(); + browserZoomService.notifyWindowZoomChanged(zoomLevelToZoomFactor(getZoomLevel(mainWindow))); + this._register(onDidChangeZoomLevel(() => { + browserZoomService.notifyWindowZoomChanged(zoomLevelToZoomFactor(getZoomLevel(mainWindow))); + })); + } +} + +registerWorkbenchContribution2(WindowZoomSynchronizer.ID, WindowZoomSynchronizer, WorkbenchPhase.BlockRestore); + +registerSingleton(IBrowserZoomService, BrowserZoomService, InstantiationType.Delayed); + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + ...workbenchConfigurationNodeBase, + properties: { + 'workbench.browser.pageZoom': { + type: 'string', + enum: [MATCH_WINDOW_ZOOM_LABEL, ...browserZoomFactors.map(f => `${Math.round(f * 100)}%`)], + markdownEnumDescriptions: [ + localize( + { comment: ['This is the description for a setting enum value.'], key: 'browser.defaultZoomLevel.matchWindow' }, + 'Matches the application\'s current UI zoom level.' + ), + ...browserZoomFactors.map(() => ''), + ], + default: MATCH_WINDOW_ZOOM_LABEL, + markdownDescription: localize( + { comment: ['This is the description for a setting.'], key: 'browser.pageZoom' }, + 'Default zoom level for all sites in the Integrated Browser.' + ), + // Zoom can change from machine to machine, so we don't need the workspace-level nor syncing that WINDOW has. + scope: ConfigurationScope.MACHINE + } + } +}); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css index 8f092973c7d..929d720d448 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css +++ b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css @@ -84,6 +84,12 @@ white-space: nowrap; } + .browser-url-bar-widgets { + display: flex; + align-items: center; + flex-shrink: 0; + } + .browser-zoom-pill { display: none; align-items: center;